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:
mohamad 2025-05-31 14:08:40 +02:00
parent cb51186830
commit ce67570cfb
3 changed files with 335 additions and 77 deletions

View File

@ -1,9 +1,9 @@
name: Deploy to Production
name: Deploy to Production, build images and push to Gitea Registry
on:
push:
branches:
- main # Trigger deployment only on pushes to main
- prod # Trigger deployment only on pushes to main
jobs:
deploy:
@ -15,64 +15,30 @@ jobs:
- name: Set up Docker Buildx
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
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
# For Gitea Container Registry, you might use:
# registry: your-gitea-instance.com:5000
# username: ${{ gitea.actor }}
# password: ${{ secrets.GITEA_TOKEN }}
registry: git.vinylnostalgia.com:5000 # IMPORTANT: Verify this is your Gitea registry URL (e.g., git.vinylnostalgia.com or with a different port).
username: ${{ gitea.actor }} # Uses the user that triggered the action. You can replace with 'mo' if needed.
password: ${{ secrets.GITEA_TOKEN }} # IMPORTANT: Create a Gitea repository secret named GITEA_TOKEN with your password or access token.
- name: Build and push backend image
- name: Build and push backend image to Gitea Registry
uses: docker/build-push-action@v4
with:
context: ./be
file: ./be/Dockerfile.prod
push: true
tags: ${{ secrets.DOCKER_USERNAME }}/mitlist-backend:latest # Replace with your image name
# Gitea registry example: your-gitea-instance.com:5000/${{ gitea.repository_owner }}/${{ gitea.repository_name }}-backend:latest
tags: git.vinylnostalgia.com:5000/${{ gitea.repository_owner }}/${{ gitea.repository_name }}-backend:latest # IMPORTANT: Verify registry URL matches the login step.
# 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
with:
context: ./fe
file: ./fe/Dockerfile.prod
push: true
tags: ${{ secrets.DOCKER_USERNAME }}/mitlist-frontend:latest # Replace with your image name
# Gitea registry example: your-gitea-instance.com:5000/${{ gitea.repository_owner }}/${{ gitea.repository_name }}-frontend:latest
tags: git.vinylnostalgia.com:5000/${{ gitea.repository_owner }}/${{ gitea.repository_name }}-frontend:latest # IMPORTANT: Verify registry URL matches the login step.
# Ensure gitea.repository_owner and gitea.repository_name resolve as expected for your image path.
build-args: |
VITE_API_URL=${{ secrets.VITE_API_URL }}
VITE_API_URL=${{ secrets.VITE_API_URL }}
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

View File

@ -1,11 +1,11 @@
<template>
<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>
<p>Loading list...</p>
</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">
<use xlink:href="#icon-alert-triangle" />
</svg>
@ -37,15 +37,18 @@
</div>
<p v-if="list.description" class="neo-description">{{ list.description }}</p>
<!-- Items List -->
<div v-if="list.items.length === 0" class="neo-empty-state">
<!-- Items List Section -->
<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">
<use xlink:href="#icon-clipboard" />
</svg>
<h3>No Items Yet!</h3>
<p>Add some items using the form below.</p>
</div>
<div v-else class="neo-list-card">
<ul class="neo-item-list">
<li v-for="item in list.items" :key="item.id" class="neo-item"
@ -98,7 +101,7 @@
</template>
<!-- Expenses Section -->
<section v-if="list" class="neo-expenses-section">
<section v-if="list && !itemsAreLoading" class="neo-expenses-section">
<div class="neo-expenses-header">
<h2 class="neo-expenses-title">Expenses</h2>
<button class="neo-action-button" @click="showCreateExpenseForm = true">
@ -418,8 +421,9 @@ const { isOnline } = useNetwork();
const notificationStore = useNotificationStore();
const offlineStore = useOfflineStore();
const list = ref<List | null>(null);
const loading = ref(true); // For initial list (items) loading
const error = ref<string | null>(null); // For initial list (items) loading
const pageInitialLoad = ref(true); // True until shell is loaded or first fetch begins
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 pollingInterval = ref<ReturnType<typeof setInterval> | null>(null);
const lastListUpdate = ref<string | null>(null);
@ -499,10 +503,29 @@ const processListItems = (items: Item[]): ItemWithUI[] => {
};
const fetchListDetails = async () => {
loading.value = true;
error.value = null;
// If pageInitialLoad is still true here, it means no shell was loaded.
// 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 {
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;
// Map API response to local List type
const localList: List = {
@ -524,9 +547,20 @@ const fetchListDetails = async () => {
await fetchListCostSummary();
}
} 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 {
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(() => {
pageInitialLoad.value = true;
itemsAreLoading.value = false;
error.value = null; // Clear stale errors on mount
if (!route.params.id) {
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
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
startPolling();
});
// Fetch expenses using the store when component is mounted
const routeParamsId = route.params.id;
// if (routeParamsId) { // Already checked above
listDetailStore.fetchListWithExpenses(String(routeParamsId));
// }
});
onUnmounted(() => {
@ -1538,28 +1603,151 @@ const handleExpenseCreated = (expense: any) => {
padding: 0.5rem;
}
.neo-header-actions {
.neo-list-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
gap: 0.8rem;
}
.neo-title {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
.neo-header-actions {
flex-wrap: wrap;
gap: 0.5rem;
}
.neo-action-button {
padding: 0.8rem;
font-size: 0.9rem;
}
.neo-description {
font-size: 1rem;
margin-bottom: 1.5rem;
}
.neo-item {
padding: 1rem;
}
.neo-item-name {
font-size: 1rem;
}
.neo-add-item-form {
flex-direction: column;
padding: 0.8rem;
.neo-item-quantity {
font-size: 0.85rem;
}
.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 {
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 {

View File

@ -29,7 +29,10 @@
<div v-else>
<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-desc">{{ list.description || 'No description' }}</div>
<ul class="neo-item-list">
@ -59,7 +62,7 @@
</template>
<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 { apiClient, API_ENDPOINTS } from '@/config/api';
import CreateListModal from '@/components/CreateListModal.vue';
@ -276,15 +279,82 @@ const addNewItem = async (list: (List & { items: Item[] }), event: 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 } });
};
// 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(() => {
// Load cached data immediately
loadCachedData();
// 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
@ -435,25 +505,52 @@ watch(currentGroupId, () => {
.neo-lists-grid {
columns: 1 280px;
column-gap: 1rem;
}
.neo-list-card,
.neo-create-list-card {
padding: 1.2rem 0.7rem 1rem 0.7rem;
.neo-list-card {
margin-bottom: 1rem;
padding: 1rem;
font-size: 1rem;
/* Optimize touch target size */
min-height: 80px;
}
.neo-list-header {
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 {
outline: none;
outline: none;
border: none;
margin-top: 0.5rem;
padding: 0.5rem;
background-color: var(--light) !important ;
background-color: var(--light) !important;
}
.neo-new-item-input input[type="text"] {
@ -470,4 +567,11 @@ watch(currentGroupId, () => {
color: #999;
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>