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:
|
||||
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_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>
|
||||
<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 {
|
||||
|
@ -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>
|
Loading…
Reference in New Issue
Block a user