feat: Update deployment workflow and enhance ListDetailPage functionality
- Modified the production deployment workflow to trigger on pushes to the 'prod' branch and updated Docker registry login to use Gitea Container Registry. - Enhanced ListDetailPage.vue to improve loading states and error handling, introducing a new loading mechanism for items and utilizing session storage for cached data. - Implemented Intersection Observer for pre-fetching list details to optimize user experience during navigation. - Improved touch feedback for list cards and optimized styles for mobile responsiveness.
This commit is contained in:
parent
cb51186830
commit
ce67570cfb
@ -1,9 +1,9 @@
|
|||||||
name: Deploy to Production
|
name: Deploy to Production, build images and push to Gitea Registry
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main # Trigger deployment only on pushes to main
|
- prod # Trigger deployment only on pushes to main
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
@ -15,64 +15,30 @@ jobs:
|
|||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v2
|
uses: docker/setup-buildx-action@v2
|
||||||
|
|
||||||
- name: Log in to Docker Hub (or your registry)
|
- name: Log in to Gitea Container Registry
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
registry: git.vinylnostalgia.com:5000 # IMPORTANT: Verify this is your Gitea registry URL (e.g., git.vinylnostalgia.com or with a different port).
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
username: ${{ gitea.actor }} # Uses the user that triggered the action. You can replace with 'mo' if needed.
|
||||||
# For Gitea Container Registry, you might use:
|
password: ${{ secrets.GITEA_TOKEN }} # IMPORTANT: Create a Gitea repository secret named GITEA_TOKEN with your password or access token.
|
||||||
# registry: your-gitea-instance.com:5000
|
|
||||||
# username: ${{ gitea.actor }}
|
|
||||||
# password: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
|
|
||||||
- name: Build and push backend image
|
- name: Build and push backend image to Gitea Registry
|
||||||
uses: docker/build-push-action@v4
|
uses: docker/build-push-action@v4
|
||||||
with:
|
with:
|
||||||
context: ./be
|
context: ./be
|
||||||
file: ./be/Dockerfile.prod
|
file: ./be/Dockerfile.prod
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ secrets.DOCKER_USERNAME }}/mitlist-backend:latest # Replace with your image name
|
tags: git.vinylnostalgia.com:5000/${{ gitea.repository_owner }}/${{ gitea.repository_name }}-backend:latest # IMPORTANT: Verify registry URL matches the login step.
|
||||||
# Gitea registry example: your-gitea-instance.com:5000/${{ gitea.repository_owner }}/${{ gitea.repository_name }}-backend:latest
|
# Ensure gitea.repository_owner and gitea.repository_name resolve as expected for your image path.
|
||||||
|
|
||||||
- name: Build and push frontend image
|
- name: Build and push frontend image to Gitea Registry
|
||||||
uses: docker/build-push-action@v4
|
uses: docker/build-push-action@v4
|
||||||
with:
|
with:
|
||||||
context: ./fe
|
context: ./fe
|
||||||
file: ./fe/Dockerfile.prod
|
file: ./fe/Dockerfile.prod
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ secrets.DOCKER_USERNAME }}/mitlist-frontend:latest # Replace with your image name
|
tags: git.vinylnostalgia.com:5000/${{ gitea.repository_owner }}/${{ gitea.repository_name }}-frontend:latest # IMPORTANT: Verify registry URL matches the login step.
|
||||||
# Gitea registry example: your-gitea-instance.com:5000/${{ gitea.repository_owner }}/${{ gitea.repository_name }}-frontend:latest
|
# Ensure gitea.repository_owner and gitea.repository_name resolve as expected for your image path.
|
||||||
build-args: |
|
build-args: |
|
||||||
VITE_API_URL=${{ secrets.VITE_API_URL }}
|
VITE_API_URL=${{ secrets.VITE_API_URL }}
|
||||||
VITE_SENTRY_DSN=${{ secrets.VITE_SENTRY_DSN }}
|
VITE_SENTRY_DSN=${{ secrets.VITE_SENTRY_DSN }}
|
||||||
|
|
||||||
- name: Deploy to server
|
|
||||||
uses: appleboy/ssh-action@master
|
|
||||||
with:
|
|
||||||
host: ${{ secrets.SERVER_HOST }}
|
|
||||||
username: ${{ secrets.SERVER_USERNAME }}
|
|
||||||
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
|
||||||
port: ${{ secrets.SERVER_PORT || 22 }}
|
|
||||||
script: |
|
|
||||||
cd /path/to/your/app # e.g., /srv/mitlist
|
|
||||||
echo "POSTGRES_USER=${{ secrets.POSTGRES_USER }}" > .env.production
|
|
||||||
echo "POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}" >> .env.production
|
|
||||||
echo "POSTGRES_DB=${{ secrets.POSTGRES_DB }}" >> .env.production
|
|
||||||
echo "DATABASE_URL=${{ secrets.DATABASE_URL }}" >> .env.production
|
|
||||||
echo "SECRET_KEY=${{ secrets.SECRET_KEY }}" >> .env.production
|
|
||||||
echo "SESSION_SECRET_KEY=${{ secrets.SESSION_SECRET_KEY }}" >> .env.production
|
|
||||||
echo "GEMINI_API_KEY=${{ secrets.GEMINI_API_KEY }}" >> .env.production
|
|
||||||
echo "REDIS_PASSWORD=${{ secrets.REDIS_PASSWORD }}" >> .env.production
|
|
||||||
echo "SENTRY_DSN=${{ secrets.SENTRY_DSN }}" >> .env.production
|
|
||||||
echo "CORS_ORIGINS=${{ secrets.CORS_ORIGINS }}" >> .env.production
|
|
||||||
echo "FRONTEND_URL=${{ secrets.FRONTEND_URL }}" >> .env.production
|
|
||||||
echo "VITE_API_URL=${{ secrets.VITE_API_URL }}" >> .env.production
|
|
||||||
echo "VITE_SENTRY_DSN=${{ secrets.VITE_SENTRY_DSN }}" >> .env.production
|
|
||||||
echo "ENVIRONMENT=production" >> .env.production
|
|
||||||
echo "LOG_LEVEL=INFO" >> .env.production
|
|
||||||
|
|
||||||
# Ensure docker-compose.prod.yml is present on the server or copy it
|
|
||||||
# git pull # If repo is cloned on server
|
|
||||||
docker-compose -f docker-compose.prod.yml pull
|
|
||||||
docker-compose -f docker-compose.prod.yml up -d --remove-orphans
|
|
||||||
docker image prune -af
|
|
@ -1,11 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="neo-container page-padding">
|
<main class="neo-container page-padding">
|
||||||
<div v-if="loading && !list" class="neo-loading-state"> <!-- Modified loading condition -->
|
<div v-if="pageInitialLoad && !list && !error" class="neo-loading-state">
|
||||||
<div class="spinner-dots" role="status"><span /><span /><span /></div>
|
<div class="spinner-dots" role="status"><span /><span /><span /></div>
|
||||||
<p>Loading list...</p>
|
<p>Loading list...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="error" class="neo-error-state">
|
<div v-else-if="error && !list" class="neo-error-state">
|
||||||
<svg class="icon" aria-hidden="true">
|
<svg class="icon" aria-hidden="true">
|
||||||
<use xlink:href="#icon-alert-triangle" />
|
<use xlink:href="#icon-alert-triangle" />
|
||||||
</svg>
|
</svg>
|
||||||
@ -37,15 +37,18 @@
|
|||||||
</div>
|
</div>
|
||||||
<p v-if="list.description" class="neo-description">{{ list.description }}</p>
|
<p v-if="list.description" class="neo-description">{{ list.description }}</p>
|
||||||
|
|
||||||
<!-- Items List -->
|
<!-- Items List Section -->
|
||||||
<div v-if="list.items.length === 0" class="neo-empty-state">
|
<div v-if="itemsAreLoading" class="neo-list-card neo-loading-state" style="padding: 2rem; min-height: 150px;">
|
||||||
|
<div class="spinner-dots" role="status"><span /><span /><span /></div>
|
||||||
|
<p>Loading items...</p>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="!itemsAreLoading && list.items.length === 0" class="neo-empty-state">
|
||||||
<svg class="icon icon-lg" aria-hidden="true">
|
<svg class="icon icon-lg" aria-hidden="true">
|
||||||
<use xlink:href="#icon-clipboard" />
|
<use xlink:href="#icon-clipboard" />
|
||||||
</svg>
|
</svg>
|
||||||
<h3>No Items Yet!</h3>
|
<h3>No Items Yet!</h3>
|
||||||
<p>Add some items using the form below.</p>
|
<p>Add some items using the form below.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="neo-list-card">
|
<div v-else class="neo-list-card">
|
||||||
<ul class="neo-item-list">
|
<ul class="neo-item-list">
|
||||||
<li v-for="item in list.items" :key="item.id" class="neo-item"
|
<li v-for="item in list.items" :key="item.id" class="neo-item"
|
||||||
@ -98,7 +101,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Expenses Section -->
|
<!-- Expenses Section -->
|
||||||
<section v-if="list" class="neo-expenses-section">
|
<section v-if="list && !itemsAreLoading" class="neo-expenses-section">
|
||||||
<div class="neo-expenses-header">
|
<div class="neo-expenses-header">
|
||||||
<h2 class="neo-expenses-title">Expenses</h2>
|
<h2 class="neo-expenses-title">Expenses</h2>
|
||||||
<button class="neo-action-button" @click="showCreateExpenseForm = true">
|
<button class="neo-action-button" @click="showCreateExpenseForm = true">
|
||||||
@ -418,8 +421,9 @@ const { isOnline } = useNetwork();
|
|||||||
const notificationStore = useNotificationStore();
|
const notificationStore = useNotificationStore();
|
||||||
const offlineStore = useOfflineStore();
|
const offlineStore = useOfflineStore();
|
||||||
const list = ref<List | null>(null);
|
const list = ref<List | null>(null);
|
||||||
const loading = ref(true); // For initial list (items) loading
|
const pageInitialLoad = ref(true); // True until shell is loaded or first fetch begins
|
||||||
const error = ref<string | null>(null); // For initial list (items) loading
|
const itemsAreLoading = ref(false); // True when items are actively being fetched/processed
|
||||||
|
const error = ref<string | null>(null); // For page-level errors
|
||||||
const addingItem = ref(false);
|
const addingItem = ref(false);
|
||||||
const pollingInterval = ref<ReturnType<typeof setInterval> | null>(null);
|
const pollingInterval = ref<ReturnType<typeof setInterval> | null>(null);
|
||||||
const lastListUpdate = ref<string | null>(null);
|
const lastListUpdate = ref<string | null>(null);
|
||||||
@ -499,10 +503,29 @@ const processListItems = (items: Item[]): ItemWithUI[] => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const fetchListDetails = async () => {
|
const fetchListDetails = async () => {
|
||||||
loading.value = true;
|
// If pageInitialLoad is still true here, it means no shell was loaded.
|
||||||
error.value = null;
|
// The main spinner might be showing. We're about to fetch details, so turn off main spinner.
|
||||||
|
if (pageInitialLoad.value) {
|
||||||
|
pageInitialLoad.value = false;
|
||||||
|
}
|
||||||
|
itemsAreLoading.value = true;
|
||||||
|
|
||||||
|
// Check for pre-fetched full data first
|
||||||
|
const routeId = String(route.params.id);
|
||||||
|
const cachedFullData = sessionStorage.getItem(`listDetailFull_${routeId}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get(API_ENDPOINTS.LISTS.BY_ID(String(route.params.id)));
|
let response;
|
||||||
|
if (cachedFullData) {
|
||||||
|
// Use cached data
|
||||||
|
response = { data: JSON.parse(cachedFullData) };
|
||||||
|
// Clear the cache after using it
|
||||||
|
sessionStorage.removeItem(`listDetailFull_${routeId}`);
|
||||||
|
} else {
|
||||||
|
// Fetch fresh data
|
||||||
|
response = await apiClient.get(API_ENDPOINTS.LISTS.BY_ID(routeId));
|
||||||
|
}
|
||||||
|
|
||||||
const rawList = response.data as ListWithExpenses;
|
const rawList = response.data as ListWithExpenses;
|
||||||
// Map API response to local List type
|
// Map API response to local List type
|
||||||
const localList: List = {
|
const localList: List = {
|
||||||
@ -524,9 +547,20 @@ const fetchListDetails = async () => {
|
|||||||
await fetchListCostSummary();
|
await fetchListCostSummary();
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
error.value = (err instanceof Error ? err.message : String(err)) || 'Failed to load list details.';
|
const errorMessage = (err instanceof Error ? err.message : String(err)) || 'Failed to load list details.';
|
||||||
|
if (!list.value) { // If there was no shell AND this fetch failed
|
||||||
|
error.value = errorMessage; // This error is for the whole page
|
||||||
|
} else {
|
||||||
|
// We have a shell, but items failed to load.
|
||||||
|
// Show a notification for item loading failure. list.items will remain as per shell (empty).
|
||||||
|
notificationStore.addNotification({ message: `Failed to load items: ${errorMessage}`, type: 'error' });
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
itemsAreLoading.value = false;
|
||||||
|
// If list is still null and no error was set (e.g. silent failure), ensure pageInitialLoad is false.
|
||||||
|
if (!list.value && !error.value) {
|
||||||
|
pageInitialLoad.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -917,20 +951,51 @@ const handleTouchEnd = () => {
|
|||||||
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
pageInitialLoad.value = true;
|
||||||
|
itemsAreLoading.value = false;
|
||||||
|
error.value = null; // Clear stale errors on mount
|
||||||
|
|
||||||
if (!route.params.id) {
|
if (!route.params.id) {
|
||||||
error.value = 'No list ID provided';
|
error.value = 'No list ID provided';
|
||||||
loading.value = false; // Stop item loading
|
pageInitialLoad.value = false; // Stop initial load phase, show error
|
||||||
listDetailStore.setError('No list ID provided for expenses.'); // Set error in expense store
|
listDetailStore.setError('No list ID provided for expenses.'); // Set error in expense store
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Attempt to load shell data from sessionStorage
|
||||||
|
const listShellJSON = sessionStorage.getItem('listDetailShell');
|
||||||
|
const routeId = String(route.params.id);
|
||||||
|
|
||||||
|
if (listShellJSON) {
|
||||||
|
const shellData = JSON.parse(listShellJSON);
|
||||||
|
// Ensure the shell data is for the current list
|
||||||
|
if (shellData.id === parseInt(routeId, 10)) {
|
||||||
|
list.value = {
|
||||||
|
id: shellData.id,
|
||||||
|
name: shellData.name,
|
||||||
|
description: shellData.description,
|
||||||
|
is_complete: false, // Assume not complete until full data loaded
|
||||||
|
items: [], // Start with no items, they will be fetched by fetchListDetails
|
||||||
|
version: 0, // Placeholder, will be updated
|
||||||
|
updated_at: new Date().toISOString(), // Placeholder
|
||||||
|
group_id: shellData.group_id,
|
||||||
|
};
|
||||||
|
pageInitialLoad.value = false; // Shell loaded, main page spinner can go
|
||||||
|
// Optionally, clear the sessionStorage item after use
|
||||||
|
// sessionStorage.removeItem('listDetailShell');
|
||||||
|
} else {
|
||||||
|
// Shell data is for a different list, clear it
|
||||||
|
sessionStorage.removeItem('listDetailShell');
|
||||||
|
// pageInitialLoad remains true, will be set to false by fetchListDetails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fetchListDetails().then(() => { // Fetches items
|
fetchListDetails().then(() => { // Fetches items
|
||||||
startPolling();
|
startPolling();
|
||||||
});
|
});
|
||||||
// Fetch expenses using the store when component is mounted
|
// Fetch expenses using the store when component is mounted
|
||||||
const routeParamsId = route.params.id;
|
const routeParamsId = route.params.id;
|
||||||
// if (routeParamsId) { // Already checked above
|
|
||||||
listDetailStore.fetchListWithExpenses(String(routeParamsId));
|
listDetailStore.fetchListWithExpenses(String(routeParamsId));
|
||||||
// }
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@ -1538,28 +1603,151 @@ const handleExpenseCreated = (expense: any) => {
|
|||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.neo-header-actions {
|
.neo-list-header {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 0.5rem;
|
gap: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.neo-title {
|
.neo-title {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.neo-header-actions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.neo-action-button {
|
||||||
|
padding: 0.8rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.neo-description {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.neo-item {
|
||||||
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.neo-item-name {
|
.neo-item-name {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.neo-add-item-form {
|
.neo-item-quantity {
|
||||||
flex-direction: column;
|
font-size: 0.85rem;
|
||||||
padding: 0.8rem;
|
}
|
||||||
|
|
||||||
|
.neo-checkbox-label input[type="checkbox"] {
|
||||||
|
width: 1.4em;
|
||||||
|
height: 1.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.neo-icon-button {
|
||||||
|
padding: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.neo-new-item-form {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.neo-new-item-input {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.neo-quantity-input {
|
.neo-quantity-input {
|
||||||
width: 100%;
|
width: 80px;
|
||||||
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.neo-add-button {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Optimize modals for mobile */
|
||||||
|
.modal-container {
|
||||||
|
width: 95%;
|
||||||
|
max-height: 85vh;
|
||||||
|
margin: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Improve touch targets */
|
||||||
|
button,
|
||||||
|
input[type="checkbox"],
|
||||||
|
.neo-checkbox-label {
|
||||||
|
min-height: 44px;
|
||||||
|
/* Apple's recommended minimum touch target size */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Optimize loading states for mobile */
|
||||||
|
.neo-loading-state {
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-dots span {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Improve scrolling performance */
|
||||||
|
.neo-item-list {
|
||||||
|
-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 */
|
||||||
|
.neo-action-button,
|
||||||
|
.neo-icon-button,
|
||||||
|
.neo-checkbox-label,
|
||||||
|
.neo-add-button {
|
||||||
|
transition: transform 0.1s ease-out, opacity 0.1s ease-out;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.neo-action-button:active,
|
||||||
|
.neo-icon-button:active,
|
||||||
|
.neo-checkbox-label:active,
|
||||||
|
.neo-add-button:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Improve scrolling performance */
|
||||||
|
.neo-item-list {
|
||||||
|
will-change: transform;
|
||||||
|
transform: translateZ(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-backdrop {
|
.modal-backdrop {
|
||||||
|
@ -29,7 +29,10 @@
|
|||||||
|
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<div class="neo-lists-grid">
|
<div class="neo-lists-grid">
|
||||||
<div v-for="list in lists" :key="list.id" class="neo-list-card" @click="navigateToList(list.id)">
|
<div v-for="list in lists" :key="list.id" class="neo-list-card"
|
||||||
|
:class="{ 'touch-active': touchActiveListId === list.id }" @click="navigateToList(list.id)"
|
||||||
|
@touchstart="handleTouchStart(list.id)" @touchend="handleTouchEnd" @touchcancel="handleTouchEnd"
|
||||||
|
:data-list-id="list.id">
|
||||||
<div class="neo-list-header">{{ list.name }}</div>
|
<div class="neo-list-header">{{ list.name }}</div>
|
||||||
<div class="neo-list-desc">{{ list.description || 'No description' }}</div>
|
<div class="neo-list-desc">{{ list.description || 'No description' }}</div>
|
||||||
<ul class="neo-item-list">
|
<ul class="neo-item-list">
|
||||||
@ -59,7 +62,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed, watch } from 'vue';
|
import { ref, onMounted, computed, watch, onUnmounted } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { apiClient, API_ENDPOINTS } from '@/config/api';
|
import { apiClient, API_ENDPOINTS } from '@/config/api';
|
||||||
import CreateListModal from '@/components/CreateListModal.vue';
|
import CreateListModal from '@/components/CreateListModal.vue';
|
||||||
@ -276,15 +279,82 @@ const addNewItem = async (list: (List & { items: Item[] }), event: Event) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const navigateToList = (listId: number) => {
|
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 } });
|
router.push({ name: 'ListDetail', params: { id: listId } });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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)));
|
||||||
|
const fullListData = response.data;
|
||||||
|
sessionStorage.setItem(`listDetailFull_${listId}`, JSON.stringify(fullListData));
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Pre-fetch failed:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Setup Intersection Observer for pre-fetching
|
||||||
|
const setupIntersectionObserver = () => {
|
||||||
|
const observer = 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: '50px 0px', // Start loading when card is 50px from viewport
|
||||||
|
threshold: 0.1 // Trigger when at least 10% of the card is visible
|
||||||
|
});
|
||||||
|
|
||||||
|
// Observe all list cards
|
||||||
|
document.querySelectorAll('.neo-list-card').forEach(card => {
|
||||||
|
observer.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;
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// Load cached data immediately
|
// Load cached data immediately
|
||||||
loadCachedData();
|
loadCachedData();
|
||||||
|
|
||||||
// Then fetch fresh data in background
|
// Then fetch fresh data in background
|
||||||
fetchListsAndGroups();
|
fetchListsAndGroups().then(() => {
|
||||||
|
// Setup intersection observer after lists are loaded
|
||||||
|
const observer = setupIntersectionObserver();
|
||||||
|
|
||||||
|
// Cleanup observer on component unmount
|
||||||
|
onUnmounted(() => {
|
||||||
|
observer.disconnect();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Watch for changes in groupId
|
// Watch for changes in groupId
|
||||||
@ -435,25 +505,52 @@ watch(currentGroupId, () => {
|
|||||||
|
|
||||||
.neo-lists-grid {
|
.neo-lists-grid {
|
||||||
columns: 1 280px;
|
columns: 1 280px;
|
||||||
|
column-gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.neo-list-card,
|
.neo-list-card {
|
||||||
.neo-create-list-card {
|
margin-bottom: 1rem;
|
||||||
padding: 1.2rem 0.7rem 1rem 0.7rem;
|
padding: 1rem;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
|
/* Optimize touch target size */
|
||||||
|
min-height: 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.neo-list-header {
|
.neo-list-header {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.neo-list-desc {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.neo-new-item-input {
|
.neo-new-item-input {
|
||||||
outline: none;
|
outline: none;
|
||||||
border: none;
|
border: none;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
background-color: var(--light) !important ;
|
background-color: var(--light) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.neo-new-item-input input[type="text"] {
|
.neo-new-item-input input[type="text"] {
|
||||||
@ -470,4 +567,11 @@ watch(currentGroupId, () => {
|
|||||||
color: #999;
|
color: #999;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 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 */
|
||||||
|
}
|
||||||
</style>
|
</style>
|
Loading…
Reference in New Issue
Block a user