Add conflict resolution for list creation and updates; implement offline action handling for list items. Enhance service worker with background sync capabilities and improve UI for offline states.
This commit is contained in:
parent
3f0cfff9f1
commit
515534dcce
@ -18,7 +18,8 @@ from app.core.exceptions import (
|
||||
ListNotFoundError,
|
||||
ListPermissionError,
|
||||
ListStatusNotFoundError,
|
||||
ConflictError # Added ConflictError
|
||||
ConflictError, # Added ConflictError
|
||||
DatabaseIntegrityError # Added DatabaseIntegrityError
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -29,7 +30,13 @@ router = APIRouter()
|
||||
response_model=ListPublic, # Return basic list info on creation
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Create New List",
|
||||
tags=["Lists"]
|
||||
tags=["Lists"],
|
||||
responses={
|
||||
status.HTTP_409_CONFLICT: {
|
||||
"description": "Conflict: A list with this name already exists in the specified group",
|
||||
"model": ListPublic
|
||||
}
|
||||
}
|
||||
)
|
||||
async def create_list(
|
||||
list_in: ListCreate,
|
||||
@ -40,6 +47,7 @@ async def create_list(
|
||||
Creates a new shopping list.
|
||||
- If `group_id` is provided, the user must be a member of that group.
|
||||
- If `group_id` is null, it's a personal list.
|
||||
- If a list with the same name already exists in the group, returns 409 with the existing list.
|
||||
"""
|
||||
logger.info(f"User {current_user.email} creating list: {list_in.name}")
|
||||
group_id = list_in.group_id
|
||||
@ -51,9 +59,29 @@ async def create_list(
|
||||
logger.warning(f"User {current_user.email} attempted to create list in group {group_id} but is not a member.")
|
||||
raise GroupMembershipError(group_id, "create lists")
|
||||
|
||||
try:
|
||||
created_list = await crud_list.create_list(db=db, list_in=list_in, creator_id=current_user.id)
|
||||
logger.info(f"List '{created_list.name}' (ID: {created_list.id}) created successfully for user {current_user.email}.")
|
||||
return created_list
|
||||
except DatabaseIntegrityError as e:
|
||||
# Check if this is a unique constraint violation
|
||||
if "unique constraint" in str(e).lower():
|
||||
# Find the existing list with the same name in the group
|
||||
existing_list = await crud_list.get_list_by_name_and_group(
|
||||
db=db,
|
||||
name=list_in.name,
|
||||
group_id=group_id,
|
||||
user_id=current_user.id
|
||||
)
|
||||
if existing_list:
|
||||
logger.info(f"List '{list_in.name}' already exists in group {group_id}. Returning existing list.")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"A list named '{list_in.name}' already exists in this group.",
|
||||
headers={"X-Existing-List": str(existing_list.id)}
|
||||
)
|
||||
# If it's not a unique constraint or we couldn't find the existing list, re-raise
|
||||
raise
|
||||
|
||||
|
||||
@router.get(
|
||||
|
@ -207,3 +207,45 @@ async def get_list_status(db: AsyncSession, list_id: int) -> ListStatus:
|
||||
raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}")
|
||||
except SQLAlchemyError as e:
|
||||
raise DatabaseQueryError(f"Failed to get list status: {str(e)}")
|
||||
|
||||
async def get_list_by_name_and_group(
|
||||
db: AsyncSession,
|
||||
name: str,
|
||||
group_id: Optional[int],
|
||||
user_id: int
|
||||
) -> Optional[ListModel]:
|
||||
"""
|
||||
Gets a list by name and group, ensuring the user has permission to access it.
|
||||
Used for conflict resolution when creating lists.
|
||||
"""
|
||||
try:
|
||||
# Build the base query
|
||||
query = select(ListModel).where(ListModel.name == name)
|
||||
|
||||
# Add group condition
|
||||
if group_id is not None:
|
||||
query = query.where(ListModel.group_id == group_id)
|
||||
else:
|
||||
query = query.where(ListModel.group_id.is_(None))
|
||||
|
||||
# Add permission conditions
|
||||
conditions = [
|
||||
ListModel.created_by_id == user_id # User is creator
|
||||
]
|
||||
if group_id is not None:
|
||||
# User is member of the group
|
||||
conditions.append(
|
||||
and_(
|
||||
ListModel.group_id == group_id,
|
||||
ListModel.created_by_id != user_id # Not the creator
|
||||
)
|
||||
)
|
||||
|
||||
query = query.where(or_(*conditions))
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalars().first()
|
||||
except OperationalError as e:
|
||||
raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}")
|
||||
except SQLAlchemyError as e:
|
||||
raise DatabaseQueryError(f"Failed to query list by name and group: {str(e)}")
|
3
fe/.gitignore
vendored
3
fe/.gitignore
vendored
@ -7,7 +7,7 @@ yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
**/node_modules/
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
@ -28,6 +28,7 @@ coverage
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
*.sw.js
|
||||
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
6
fe/package-lock.json
generated
6
fe/package-lock.json
generated
@ -17,7 +17,8 @@
|
||||
"pinia": "^3.0.2",
|
||||
"vue": "^3.5.13",
|
||||
"vue-i18n": "^12.0.0-alpha.2",
|
||||
"vue-router": "^4.5.1"
|
||||
"vue-router": "^4.5.1",
|
||||
"workbox-background-sync": "^7.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@intlify/unplugin-vue-i18n": "^6.0.8",
|
||||
@ -7585,7 +7586,6 @@
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
|
||||
"integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
@ -12067,7 +12067,6 @@
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.3.0.tgz",
|
||||
"integrity": "sha512-PCSk3eK7Mxeuyatb22pcSx9dlgWNv3+M8PqPaYDokks8Y5/FX4soaOqj3yhAZr5k6Q5JWTOMYgaJBpbw11G9Eg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"idb": "^7.0.1",
|
||||
@ -12402,7 +12401,6 @@
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.3.0.tgz",
|
||||
"integrity": "sha512-Z+mYrErfh4t3zi7NVTvOuACB0A/jA3bgxUN3PwtAVHvfEsZxV9Iju580VEETug3zYJRc0Dmii/aixI/Uxj8fmw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/workbox-expiration": {
|
||||
|
@ -26,7 +26,8 @@
|
||||
"pinia": "^3.0.2",
|
||||
"vue": "^3.5.13",
|
||||
"vue-i18n": "^12.0.0-alpha.2",
|
||||
"vue-router": "^4.5.1"
|
||||
"vue-router": "^4.5.1",
|
||||
"workbox-background-sync": "^7.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@intlify/unplugin-vue-i18n": "^6.0.8",
|
||||
|
45
fe/public/offline.html
Normal file
45
fe/public/offline.html
Normal file
@ -0,0 +1,45 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Offline</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
background-color: #f0f0f0;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #d32f2f;
|
||||
/* A reddish color to indicate an issue */
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>You are Offline</h1>
|
||||
<p>It seems you've lost your internet connection.</p>
|
||||
<p>Please check your network settings and try again once you're back online.</p>
|
||||
<p><small>Some previously cached content might still be available.</small></p>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
@ -25,4 +25,72 @@ a {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
// Offline UI styles
|
||||
.offline-item {
|
||||
position: relative;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.3s ease;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8'/%3E%3Cpath d='M3 3v5h5'/%3E%3Cpath d='M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16'/%3E%3Cpath d='M16 21h5v-5'/%3E%3C/svg%3E");
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
&.synced {
|
||||
opacity: 1;
|
||||
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
// Disabled offline features
|
||||
.feature-offline-disabled {
|
||||
position: relative;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
|
||||
&::before {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 0.5rem;
|
||||
background-color: var(--bg-color-tooltip, #333);
|
||||
color: white;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.2s ease;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
&:hover::before {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
// Add more global utility classes or base styles
|
@ -1,53 +1,54 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
v-if="!isOnline || hasPendingActions"
|
||||
class="alert offline-indicator"
|
||||
:class="{
|
||||
<div v-if="!isOnline || hasPendingActions" class="alert offline-indicator" :class="{
|
||||
'alert-error': !isOnline,
|
||||
'alert-warning': isOnline && hasPendingActions
|
||||
}"
|
||||
role="status"
|
||||
>
|
||||
}" role="status">
|
||||
<div class="alert-content">
|
||||
<svg class="icon" aria-hidden="true">
|
||||
<use :xlink:href="!isOnline ? '#icon-alert-triangle' : '#icon-info'" />
|
||||
<!-- Placeholder icons, wifi_off and sync are not in Valerie UI default -->
|
||||
<use :xlink:href="!isOnline ? '#icon-wifi-off' : '#icon-sync'" />
|
||||
</svg>
|
||||
<span v-if="!isOnline">
|
||||
<span v-if="!isOnline" class="status-text">
|
||||
You are currently offline. Changes will be saved locally.
|
||||
</span>
|
||||
<span v-else>
|
||||
<span v-else class="status-text">
|
||||
Syncing {{ pendingActionCount }} pending {{ pendingActionCount === 1 ? 'change' : 'changes' }}...
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="hasPendingActions"
|
||||
class="btn btn-sm btn-neutral"
|
||||
@click="showPendingActionsModal = true"
|
||||
>
|
||||
<button v-if="hasPendingActions" class="btn btn-sm btn-neutral" @click="showPendingActionsModal = true">
|
||||
View Changes
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="showPendingActionsModal" class="modal-backdrop open" @click.self="showPendingActionsModal = false">
|
||||
<div class="modal-container" ref="pendingActionsModalRef" role="dialog" aria-modal="true" aria-labelledby="pendingActionsTitle">
|
||||
<div class="modal-container" ref="pendingActionsModalRef" role="dialog" aria-modal="true"
|
||||
aria-labelledby="pendingActionsTitle">
|
||||
<div class="modal-header">
|
||||
<h3 id="pendingActionsTitle">Pending Changes</h3>
|
||||
<button class="close-button" @click="showPendingActionsModal = false" aria-label="Close">
|
||||
<svg class="icon" aria-hidden="true"><use xlink:href="#icon-close" /></svg>
|
||||
<svg class="icon" aria-hidden="true">
|
||||
<use xlink:href="#icon-close" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<ul v-if="pendingActions.length" class="item-list">
|
||||
<li v-for="action in pendingActions" :key="action.id" class="list-item">
|
||||
<div class="list-item-content" style="flex-direction: column; align-items: flex-start;">
|
||||
<div class="list-item-content">
|
||||
<div class="action-info">
|
||||
<span class="item-text">{{ getActionLabel(action) }}</span>
|
||||
<small class="text-caption">{{ new Date(action.timestamp).toLocaleString() }}</small>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-error" @click="removePendingAction(action.id)"
|
||||
title="Remove this pending action">
|
||||
<svg class="icon" aria-hidden="true">
|
||||
<use xlink:href="#icon-trash" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else>No pending changes.</p>
|
||||
<p v-else class="empty-state">No pending changes.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-primary" @click="showPendingActionsModal = false">Close</button>
|
||||
@ -56,58 +57,55 @@
|
||||
</div>
|
||||
|
||||
<!-- Conflict Resolution Dialog -->
|
||||
<ConflictResolutionDialog
|
||||
v-model="offlineStore.showConflictDialog"
|
||||
:conflict-data="offlineStore.currentConflict"
|
||||
@resolve="offlineStore.handleConflictResolution"
|
||||
/>
|
||||
<ConflictResolutionDialog v-model="offlineStore.showConflictDialog" :conflict-data="offlineStore.currentConflict"
|
||||
@resolve="offlineStore.handleConflictResolution" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useNetwork, onClickOutside } from '@vueuse/core';
|
||||
import { useOfflineStore } from '@/stores/offline'; // Assuming path
|
||||
import type { OfflineAction } from '@/stores/offline'; // Assuming path
|
||||
import { useOfflineStore } from '@/stores/offline';
|
||||
import type { OfflineAction } from '@/stores/offline';
|
||||
import ConflictResolutionDialog from '@/components/ConflictResolutionDialog.vue';
|
||||
|
||||
const offlineStore = useOfflineStore();
|
||||
const showPendingActionsModal = ref(false);
|
||||
const pendingActionsModalRef = ref<HTMLElement | null>(null);
|
||||
|
||||
const { isOnline } = useNetwork(); // VueUse composable for network status
|
||||
const { isOnline } = useNetwork();
|
||||
|
||||
// Expose parts of the store directly, this pattern is fine with Pinia
|
||||
const {
|
||||
pendingActions,
|
||||
hasPendingActions,
|
||||
pendingActionCount,
|
||||
// showConflictDialog, // Handled by offlineStore.showConflictDialog
|
||||
// currentConflict, // Handled by offlineStore.currentConflict
|
||||
// handleConflictResolution // Handled by offlineStore.handleConflictResolution
|
||||
} = offlineStore;
|
||||
|
||||
|
||||
onClickOutside(pendingActionsModalRef, () => {
|
||||
showPendingActionsModal.value = false;
|
||||
});
|
||||
|
||||
const removePendingAction = (actionId: string) => {
|
||||
offlineStore.pendingActions = offlineStore.pendingActions.filter(a => a.id !== actionId);
|
||||
};
|
||||
|
||||
const getActionLabel = (action: OfflineAction) => {
|
||||
// This is a simplified version of your original getActionLabel
|
||||
// You might need to adjust based on the actual structure of action.data
|
||||
const data = action.payload as { title?: string; name?: string; [key: string]: unknown };
|
||||
const data = action.payload as { title?: string; name?: string;[key: string]: unknown };
|
||||
const itemTitle = data.title || data.name || (typeof data === 'string' ? data : 'Untitled Item');
|
||||
|
||||
switch (action.type) {
|
||||
case 'add':
|
||||
case 'create': // Common alias
|
||||
return `Add: ${itemTitle}`;
|
||||
case 'complete':
|
||||
return `Complete: ${itemTitle}`;
|
||||
case 'update':
|
||||
return `Update: ${itemTitle}`;
|
||||
case 'delete':
|
||||
return `Delete: ${itemTitle}`;
|
||||
case 'create_list':
|
||||
return `Create List: ${itemTitle}`;
|
||||
case 'update_list':
|
||||
return `Update List: ${itemTitle}`;
|
||||
case 'delete_list':
|
||||
return `Delete List: ${itemTitle}`;
|
||||
case 'create_list_item':
|
||||
return `Add Item: ${itemTitle}`;
|
||||
case 'update_list_item':
|
||||
return `Update Item: ${itemTitle}`;
|
||||
case 'delete_list_item':
|
||||
return `Delete Item: ${itemTitle}`;
|
||||
default:
|
||||
return `Unknown action: ${action.type} for ${itemTitle}`;
|
||||
}
|
||||
@ -121,22 +119,83 @@ const getActionLabel = (action: OfflineAction) => {
|
||||
right: 1rem;
|
||||
z-index: 1000;
|
||||
max-width: 400px;
|
||||
/* Valerie UI .alert already has box-shadow */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.alert-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Styles for text-caption if not globally available enough */
|
||||
.text-caption {
|
||||
font-size: 0.85rem;
|
||||
color: var(--dark);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Simplified list item for pending actions modal */
|
||||
.item-list .list-item .list-item-content {
|
||||
padding: 0.75rem 1rem;
|
||||
.item-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.item-list .list-item .item-text {
|
||||
|
||||
.list-item {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.list-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.list-item-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.action-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.item-text {
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.btn-error {
|
||||
padding: 0.25rem;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.btn-error .icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
</style>
|
@ -21,14 +21,14 @@
|
||||
</svg>
|
||||
Continue with Google
|
||||
</button>
|
||||
<button @click="handleAppleLogin" class="btn btn-social btn-apple">
|
||||
<!-- <button @click="handleAppleLogin" class="btn btn-social btn-apple">
|
||||
<svg class="icon" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M17.05 20.28c-.98.95-2.05.88-3.08.41-1.09-.47-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.41C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.19 2.31-.89 3.51-.84 1.54.07 2.7.61 3.44 1.57-3.14 1.88-2.29 5.13.22 6.41-.65 1.29-1.51 2.58-2.25 4.03zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"
|
||||
fill="#000" />
|
||||
</svg>
|
||||
Continue with Apple
|
||||
</button>
|
||||
</button> -->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -19,16 +19,20 @@
|
||||
<div class="flex justify-between items-center flex-wrap mb-2">
|
||||
<h1>{{ list.name }}</h1>
|
||||
<div class="flex items-center flex-wrap" style="gap: 0.5rem;">
|
||||
<button class="btn btn-neutral btn-sm" @click="showCostSummaryDialog = true">
|
||||
<button class="btn btn-neutral btn-sm" @click="showCostSummaryDialog = true"
|
||||
:class="{ 'feature-offline-disabled': !isOnline }"
|
||||
:data-tooltip="!isOnline ? 'Cost summary requires online connection' : ''">
|
||||
<svg class="icon icon-sm">
|
||||
<use xlink:href="#icon-clipboard" />
|
||||
</svg> <!-- Placeholder icon -->
|
||||
</svg>
|
||||
Cost Summary
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" @click="openOcrDialog">
|
||||
<button class="btn btn-secondary btn-sm" @click="openOcrDialog"
|
||||
:class="{ 'feature-offline-disabled': !isOnline }"
|
||||
:data-tooltip="!isOnline ? 'OCR requires online connection' : ''">
|
||||
<svg class="icon icon-sm">
|
||||
<use xlink:href="#icon-plus" />
|
||||
</svg> <!-- Placeholder, camera_alt not in Valerie -->
|
||||
</svg>
|
||||
Add via OCR
|
||||
</button>
|
||||
<span class="item-badge ml-1" :class="list.is_complete ? 'badge-settled' : 'badge-pending'">
|
||||
@ -68,9 +72,12 @@
|
||||
</div>
|
||||
|
||||
<ul v-else class="item-list">
|
||||
<li v-for="item in list.items" :key="item.id" class="list-item"
|
||||
:class="{ 'completed': item.is_complete, 'is-swiped': item.swiped }" @touchstart="handleTouchStart"
|
||||
@touchmove="handleTouchMove" @touchend="handleTouchEnd">
|
||||
<li v-for="item in list.items" :key="item.id" class="list-item" :class="{
|
||||
'completed': item.is_complete,
|
||||
'is-swiped': item.swiped,
|
||||
'offline-item': isItemPendingSync(item),
|
||||
'synced': !isItemPendingSync(item)
|
||||
}" @touchstart="handleTouchStart" @touchmove="handleTouchMove" @touchend="handleTouchEnd">
|
||||
<div class="list-item-content">
|
||||
<div class="list-item-main">
|
||||
<label class="checkbox-label mb-0 flex-shrink-0">
|
||||
@ -90,7 +97,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Non-swipe actions can be added here or handled by swipe -->
|
||||
<div class="list-item-actions">
|
||||
<button class="btn btn-danger btn-sm btn-icon-only" @click.stop="confirmDeleteItem(item)"
|
||||
:disabled="item.deleting" aria-label="Delete item">
|
||||
@ -100,7 +106,6 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Swipe actions could be added here if fully implementing swipe from Valerie UI example -->
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
@ -152,6 +157,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirmation Dialog -->
|
||||
<div v-if="showConfirmDialogState" class="modal-backdrop open" @click.self="cancelConfirmation">
|
||||
<div class="modal-container confirm-modal" ref="confirmModalRef">
|
||||
<div class="modal-header">
|
||||
<h3>Confirmation</h3>
|
||||
<button class="close-button" @click="cancelConfirmation" aria-label="Close"><svg class="icon">
|
||||
<use xlink:href="#icon-close" />
|
||||
</svg></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<svg class="icon icon-lg mb-2" style="color: var(--warning);">
|
||||
<use xlink:href="#icon-alert-triangle" />
|
||||
</svg>
|
||||
<p>{{ confirmDialogMessage }}</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-neutral" @click="cancelConfirmation">Cancel</button>
|
||||
<button class="btn btn-primary ml-2" @click="handleConfirmedAction">Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cost Summary Dialog -->
|
||||
<div v-if="showCostSummaryDialog" class="modal-backdrop open" @click.self="showCostSummaryDialog = false">
|
||||
<div class="modal-container" ref="costSummaryModalRef" style="min-width: 550px;">
|
||||
@ -208,37 +235,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirmation Dialog -->
|
||||
<div v-if="showConfirmDialogState" class="modal-backdrop open" @click.self="cancelConfirmation">
|
||||
<div class="modal-container confirm-modal" ref="confirmModalRef">
|
||||
<div class="modal-header">
|
||||
<h3>Confirmation</h3>
|
||||
<button class="close-button" @click="cancelConfirmation" aria-label="Close"><svg class="icon">
|
||||
<use xlink:href="#icon-close" />
|
||||
</svg></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<svg class="icon icon-lg mb-2" style="color: var(--warning);">
|
||||
<use xlink:href="#icon-alert-triangle" />
|
||||
</svg>
|
||||
<p>{{ confirmDialogMessage }}</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-neutral" @click="cancelConfirmation">Cancel</button>
|
||||
<button class="btn btn-primary ml-2" @click="handleConfirmedAction">Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue';
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { apiClient, API_ENDPOINTS } from '@/config/api';
|
||||
import { onClickOutside, useEventListener, useFileDialog } from '@vueuse/core';
|
||||
import { onClickOutside, useEventListener, useFileDialog, useNetwork } from '@vueuse/core';
|
||||
import { useNotificationStore } from '@/stores/notifications';
|
||||
import { useOfflineStore, type CreateListItemPayload } from '@/stores/offline';
|
||||
|
||||
interface Item {
|
||||
id: number;
|
||||
@ -283,8 +289,9 @@ interface ListCostSummaryData {
|
||||
}
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const { isOnline } = useNetwork();
|
||||
const notificationStore = useNotificationStore();
|
||||
const offlineStore = useOfflineStore();
|
||||
const list = ref<List | null>(null);
|
||||
const loading = ref(true);
|
||||
const error = ref<string | null>(null);
|
||||
@ -391,6 +398,16 @@ const stopPolling = () => {
|
||||
if (pollingInterval.value) clearInterval(pollingInterval.value);
|
||||
};
|
||||
|
||||
const isItemPendingSync = (item: Item) => {
|
||||
return offlineStore.pendingActions.some(action => {
|
||||
if (action.type === 'update_list_item' || action.type === 'delete_list_item') {
|
||||
const payload = action.payload as { listId: string; itemId: string };
|
||||
return payload.itemId === String(item.id);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
const onAddItem = async () => {
|
||||
if (!list.value || !newItem.value.name.trim()) {
|
||||
notificationStore.addNotification({ message: 'Please enter an item name.', type: 'warning' });
|
||||
@ -398,6 +415,35 @@ const onAddItem = async () => {
|
||||
return;
|
||||
}
|
||||
addingItem.value = true;
|
||||
|
||||
if (!isOnline.value) {
|
||||
// Add to offline queue
|
||||
offlineStore.addAction({
|
||||
type: 'create_list_item',
|
||||
payload: {
|
||||
listId: String(list.value.id),
|
||||
itemData: {
|
||||
name: newItem.value.name,
|
||||
quantity: newItem.value.quantity?.toString()
|
||||
}
|
||||
}
|
||||
});
|
||||
// Optimistically add to UI
|
||||
const optimisticItem: Item = {
|
||||
id: Date.now(), // Temporary ID
|
||||
name: newItem.value.name,
|
||||
quantity: newItem.value.quantity,
|
||||
is_complete: false,
|
||||
version: 1,
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
list.value.items.push(processListItems([optimisticItem])[0]);
|
||||
newItem.value = { name: '' };
|
||||
itemNameInputRef.value?.focus();
|
||||
addingItem.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.post(
|
||||
API_ENDPOINTS.LISTS.ITEMS(String(list.value.id)),
|
||||
@ -420,77 +466,104 @@ const updateItem = async (item: Item, newCompleteStatus: boolean) => {
|
||||
const originalCompleteStatus = item.is_complete;
|
||||
item.is_complete = newCompleteStatus; // Optimistic update
|
||||
|
||||
if (!isOnline.value) {
|
||||
// Add to offline queue
|
||||
offlineStore.addAction({
|
||||
type: 'update_list_item',
|
||||
payload: {
|
||||
listId: String(list.value.id),
|
||||
itemId: String(item.id),
|
||||
data: {
|
||||
completed: newCompleteStatus
|
||||
},
|
||||
version: item.version
|
||||
}
|
||||
});
|
||||
item.updating = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload: { is_complete: boolean; version: number; price?: number | null } = {
|
||||
is_complete: item.is_complete,
|
||||
version: item.version,
|
||||
};
|
||||
if (item.is_complete && item.priceInput !== undefined && item.priceInput !== null && String(item.priceInput).trim() !== '') {
|
||||
payload.price = parseFloat(String(item.priceInput));
|
||||
} else if (item.is_complete && (item.priceInput === undefined || String(item.priceInput).trim() === '')) {
|
||||
// If complete and price is empty, don't send price, or send null if API expects it
|
||||
payload.price = null; // Or omit, depending on API
|
||||
}
|
||||
|
||||
|
||||
const response = await apiClient.put(API_ENDPOINTS.ITEMS.BY_ID(String(item.id)), payload);
|
||||
const updatedItemFromServer = processListItems([response.data as Item])[0];
|
||||
|
||||
const index = list.value.items.findIndex(i => i.id === item.id);
|
||||
if (index !== -1) {
|
||||
list.value.items[index] = { ...list.value.items[index], ...updatedItemFromServer, updating: false };
|
||||
}
|
||||
|
||||
// If cost summary was open, refresh it
|
||||
if (showCostSummaryDialog.value) await fetchListCostSummary();
|
||||
|
||||
await apiClient.put(
|
||||
API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)),
|
||||
{ completed: newCompleteStatus, version: item.version }
|
||||
);
|
||||
item.version++;
|
||||
} catch (err) {
|
||||
item.is_complete = originalCompleteStatus; // Revert optimistic update
|
||||
item.is_complete = originalCompleteStatus; // Revert on error
|
||||
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to update item.', type: 'error' });
|
||||
} finally {
|
||||
item.updating = false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const updateItemPrice = async (item: Item) => {
|
||||
if (!list.value || !item.is_complete) return; // Only update price if item is complete
|
||||
if (!list.value || !item.is_complete) return;
|
||||
|
||||
const newPrice = item.priceInput !== undefined && String(item.priceInput).trim() !== '' ? parseFloat(String(item.priceInput)) : null;
|
||||
if (item.price === newPrice) return; // No change
|
||||
if (item.price === newPrice) return;
|
||||
|
||||
item.updating = true;
|
||||
const originalPrice = item.price;
|
||||
item.price = newPrice; // Optimistic
|
||||
const originalPriceInput = item.priceInput;
|
||||
|
||||
item.price = newPrice;
|
||||
|
||||
if (!isOnline.value) {
|
||||
// Add to offline queue
|
||||
offlineStore.addAction({
|
||||
type: 'update_list_item',
|
||||
payload: {
|
||||
listId: String(list.value.id),
|
||||
itemId: String(item.id),
|
||||
data: {
|
||||
price: newPrice,
|
||||
completed: item.is_complete
|
||||
},
|
||||
version: item.version
|
||||
}
|
||||
});
|
||||
item.updating = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.put(
|
||||
API_ENDPOINTS.ITEMS.BY_ID(String(item.id)),
|
||||
{ price: item.price, is_complete: item.is_complete, version: item.version }
|
||||
await apiClient.put(
|
||||
API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)),
|
||||
{ price: newPrice, completed: item.is_complete, version: item.version }
|
||||
);
|
||||
const updatedItemFromServer = processListItems([response.data as Item])[0];
|
||||
const index = list.value.items.findIndex(i => i.id === item.id);
|
||||
if (index !== -1) {
|
||||
list.value.items[index] = { ...list.value.items[index], ...updatedItemFromServer, updating: false };
|
||||
}
|
||||
if (showCostSummaryDialog.value) await fetchListCostSummary();
|
||||
item.version++;
|
||||
} catch (err) {
|
||||
item.price = originalPrice; // Revert
|
||||
item.priceInput = originalPrice !== null && originalPrice !== undefined ? originalPrice : '';
|
||||
item.price = originalPrice;
|
||||
item.priceInput = originalPriceInput;
|
||||
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to update item price.', type: 'error' });
|
||||
} finally {
|
||||
item.updating = false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const deleteItem = async (item: Item) => {
|
||||
if (!list.value) return;
|
||||
item.deleting = true;
|
||||
|
||||
if (!isOnline.value) {
|
||||
// Add to offline queue
|
||||
offlineStore.addAction({
|
||||
type: 'delete_list_item',
|
||||
payload: {
|
||||
listId: String(list.value.id),
|
||||
itemId: String(item.id)
|
||||
}
|
||||
});
|
||||
// Optimistically remove from UI
|
||||
list.value.items = list.value.items.filter(i => i.id !== item.id);
|
||||
item.deleting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await apiClient.delete(API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)));
|
||||
list.value.items = list.value.items.filter(i => i.id !== item.id);
|
||||
if (showCostSummaryDialog.value) await fetchListCostSummary();
|
||||
} catch (err) {
|
||||
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to delete item.', type: 'error' });
|
||||
} finally {
|
||||
@ -757,4 +830,70 @@ onUnmounted(() => {
|
||||
padding-left: 1rem;
|
||||
/* Space before actions */
|
||||
}
|
||||
|
||||
.offline-item {
|
||||
position: relative;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.offline-item::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8'/%3E%3Cpath d='M3 3v5h5'/%3E%3Cpath d='M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16'/%3E%3Cpath d='M16 21h5v-5'/%3E%3C/svg%3E");
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.offline-item.synced {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.offline-item.synced::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.feature-offline-disabled {
|
||||
position: relative;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.feature-offline-disabled::before {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 0.5rem;
|
||||
background-color: var(--bg-color-tooltip, #333);
|
||||
color: white;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.2s ease;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.feature-offline-disabled:hover::before {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
</style>
|
@ -2,7 +2,7 @@
|
||||
<main class="flex items-center justify-center page-container">
|
||||
<div class="card login-card">
|
||||
<div class="card-header">
|
||||
<h3>Login</h3>
|
||||
<h3>mitlist</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form @submit.prevent="onSubmit" class="form-layout">
|
||||
|
@ -4,12 +4,32 @@ import { ref, computed } from 'vue';
|
||||
// import { LocalStorage } from 'quasar'; // REMOVE
|
||||
import { useStorage } from '@vueuse/core'; // VueUse alternative
|
||||
import { useNotificationStore } from '@/stores/notifications'; // Your custom notification store
|
||||
import { apiClient, API_ENDPOINTS } from '@/services/api'; // Import apiClient and API_ENDPOINTS
|
||||
|
||||
export type CreateListPayload = { name: string; description?: string; /* other list properties */ };
|
||||
export type UpdateListPayload = { listId: string; data: Partial<CreateListPayload>; version?: number; };
|
||||
export type DeleteListPayload = { listId: string; };
|
||||
export type CreateListItemPayload = { listId: string; itemData: { name: string; quantity?: number | string; completed?: boolean; price?: number | null; /* other item properties */ }; };
|
||||
export type UpdateListItemPayload = { listId: string; itemId: string; data: Partial<CreateListItemPayload['itemData']>; version?: number; };
|
||||
export type DeleteListItemPayload = { listId: string; itemId: string; };
|
||||
|
||||
export type OfflineAction = {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
type: string;
|
||||
payload: Record<string, unknown>; // Added payload property
|
||||
type:
|
||||
| 'create_list'
|
||||
| 'update_list'
|
||||
| 'delete_list'
|
||||
| 'create_list_item'
|
||||
| 'update_list_item'
|
||||
| 'delete_list_item';
|
||||
payload:
|
||||
| CreateListPayload
|
||||
| UpdateListPayload
|
||||
| DeleteListPayload
|
||||
| CreateListItemPayload
|
||||
| UpdateListItemPayload
|
||||
| DeleteListItemPayload;
|
||||
};
|
||||
|
||||
export type ConflictData = {
|
||||
@ -18,6 +38,20 @@ export type ConflictData = {
|
||||
action: OfflineAction;
|
||||
};
|
||||
|
||||
interface ServerListData {
|
||||
id: string;
|
||||
version: number;
|
||||
name: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ServerItemData {
|
||||
id: string;
|
||||
version: number;
|
||||
name: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export const useOfflineStore = defineStore('offline', () => {
|
||||
// const $q = useQuasar(); // REMOVE
|
||||
const notificationStore = useNotificationStore();
|
||||
@ -35,13 +69,12 @@ export const useOfflineStore = defineStore('offline', () => {
|
||||
// saveToStorage is also handled by useStorage automatically saving on change
|
||||
|
||||
const addAction = (action: Omit<OfflineAction, 'id' | 'timestamp'>) => {
|
||||
const newAction: OfflineAction = {
|
||||
const newAction = {
|
||||
...action,
|
||||
id: crypto.randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
} as OfflineAction;
|
||||
pendingActions.value.push(newAction);
|
||||
// useStorage handles saving
|
||||
};
|
||||
|
||||
const processQueue = async () => {
|
||||
@ -51,52 +84,132 @@ export const useOfflineStore = defineStore('offline', () => {
|
||||
|
||||
for (const action of actionsToProcess) {
|
||||
try {
|
||||
await processAction(action); // processAction needs to use your actual API client
|
||||
await processAction(action);
|
||||
pendingActions.value = pendingActions.value.filter(a => a.id !== action.id);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'response' in error && typeof error.response === 'object' && error.response && 'status' in error.response && error.response.status === 409) {
|
||||
} catch (error: any) { // Catch error as any to check for our custom flag
|
||||
if (error && error.isConflict && error.serverVersionData) {
|
||||
notificationStore.addNotification({
|
||||
type: 'warning',
|
||||
message: 'Item was modified by someone else. Please review.',
|
||||
// actions: [ ... ] // Custom actions for notifications would be more complex
|
||||
message: `Conflict detected for action ${action.type}. Please review.`,
|
||||
});
|
||||
// Here you would trigger the conflict resolution dialog
|
||||
// For example, find the item and its server version, then:
|
||||
// currentConflict.value = { localVersion: ..., serverVersion: ..., action };
|
||||
// showConflictDialog.value = true;
|
||||
// The loop should probably pause or handle this conflict before continuing
|
||||
console.warn('Conflict detected for action:', action.id, error);
|
||||
// Break or decide how to handle queue processing on conflict
|
||||
break;
|
||||
|
||||
let localData: Record<string, unknown>;
|
||||
// Extract local data based on action type
|
||||
if (action.type === 'update_list' || action.type === 'update_list_item') {
|
||||
localData = (action.payload as UpdateListPayload | UpdateListItemPayload).data;
|
||||
} else if (action.type === 'create_list' || action.type === 'create_list_item') {
|
||||
localData = action.payload as CreateListPayload | CreateListItemPayload;
|
||||
} else {
|
||||
console.error('Failed to process offline action:', action.id, error);
|
||||
notificationStore.addNotification({
|
||||
type: 'error',
|
||||
message: `Failed to sync action: ${action.type}`,
|
||||
});
|
||||
console.error("Conflict detected for unhandled action type for data extraction:", action.type);
|
||||
localData = {}; // Fallback
|
||||
}
|
||||
|
||||
currentConflict.value = {
|
||||
localVersion: {
|
||||
data: localData,
|
||||
timestamp: action.timestamp,
|
||||
},
|
||||
serverVersion: {
|
||||
data: error.serverVersionData, // Assumes API 409 response body is the server item
|
||||
timestamp: error.serverVersionData.updated_at ? new Date(error.serverVersionData.updated_at).getTime() : action.timestamp + 1, // Prefer server updated_at
|
||||
},
|
||||
action: action,
|
||||
};
|
||||
showConflictDialog.value = true;
|
||||
console.warn('Conflict detected by processQueue for action:', action.id, error);
|
||||
// Stop processing queue on first conflict to await resolution
|
||||
isProcessingQueue.value = false; // Allow queue to be re-triggered after resolution
|
||||
return; // Stop processing further actions
|
||||
} else {
|
||||
console.error('processQueue: Action failed, remains in queue:', action.id, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
isProcessingQueue.value = false;
|
||||
};
|
||||
|
||||
// processAction needs to be implemented with your actual API calls
|
||||
const processAction = async (action: OfflineAction) => {
|
||||
console.log('Processing action (TODO: Implement API call):', action);
|
||||
// Example:
|
||||
// import { apiClient } from '@/services/api';
|
||||
// import { API_ENDPOINTS } from '@/config/api-config';
|
||||
// switch (action.type) {
|
||||
// case 'add':
|
||||
// // Assuming action.data is { listId: string, itemData: { name: string, quantity?: string } }
|
||||
// // await apiClient.post(API_ENDPOINTS.LISTS.ITEMS(action.data.listId), action.data.itemData);
|
||||
// break;
|
||||
// // ... other cases
|
||||
// }
|
||||
// Simulate async work
|
||||
return new Promise(resolve => setTimeout(resolve, 500));
|
||||
};
|
||||
try {
|
||||
let request: Request;
|
||||
let endpoint: string;
|
||||
let method: 'POST' | 'PUT' | 'DELETE' = 'POST';
|
||||
let body: any;
|
||||
|
||||
switch (action.type) {
|
||||
case 'create_list':
|
||||
endpoint = API_ENDPOINTS.LISTS.BASE;
|
||||
body = action.payload;
|
||||
break;
|
||||
case 'update_list': {
|
||||
const { listId, data } = action.payload as UpdateListPayload;
|
||||
endpoint = API_ENDPOINTS.LISTS.BY_ID(listId);
|
||||
method = 'PUT';
|
||||
body = data;
|
||||
break;
|
||||
}
|
||||
case 'delete_list': {
|
||||
const { listId } = action.payload as DeleteListPayload;
|
||||
endpoint = API_ENDPOINTS.LISTS.BY_ID(listId);
|
||||
method = 'DELETE';
|
||||
break;
|
||||
}
|
||||
case 'create_list_item': {
|
||||
const { listId, itemData } = action.payload as CreateListItemPayload;
|
||||
endpoint = API_ENDPOINTS.LISTS.ITEMS(listId);
|
||||
body = itemData;
|
||||
break;
|
||||
}
|
||||
case 'update_list_item': {
|
||||
const { listId, itemId, data } = action.payload as UpdateListItemPayload;
|
||||
endpoint = API_ENDPOINTS.LISTS.ITEM(listId, itemId);
|
||||
method = 'PUT';
|
||||
body = data;
|
||||
break;
|
||||
}
|
||||
case 'delete_list_item': {
|
||||
const { listId, itemId } = action.payload as DeleteListItemPayload;
|
||||
endpoint = API_ENDPOINTS.LISTS.ITEM(listId, itemId);
|
||||
method = 'DELETE';
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown action type: ${action.type}`);
|
||||
}
|
||||
|
||||
// Create the request with the action metadata
|
||||
request = new Request(endpoint, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Offline-Action': action.id,
|
||||
},
|
||||
body: method !== 'DELETE' ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
// Use fetch with the request
|
||||
const response = await fetch(request);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 409) {
|
||||
const error = new Error('Conflict detected') as any;
|
||||
error.isConflict = true;
|
||||
error.serverVersionData = await response.json();
|
||||
throw error;
|
||||
}
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
// If successful, remove from pending actions
|
||||
pendingActions.value = pendingActions.value.filter(a => a.id !== action.id);
|
||||
return await response.json();
|
||||
} catch (error: any) {
|
||||
if (error.isConflict) {
|
||||
throw error;
|
||||
}
|
||||
// For other errors, let Workbox handle the retry
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const setupNetworkListeners = () => {
|
||||
window.addEventListener('online', () => {
|
||||
@ -113,28 +226,148 @@ export const useOfflineStore = defineStore('offline', () => {
|
||||
const hasPendingActions = computed(() => pendingActions.value.length > 0);
|
||||
const pendingActionCount = computed(() => pendingActions.value.length);
|
||||
|
||||
const handleConflictResolution = (resolution: { version: 'local' | 'server' | 'merge'; action: OfflineAction; mergedData?: Record<string, unknown> }) => {
|
||||
console.log('Conflict resolution chosen:', resolution);
|
||||
// TODO: Implement logic to apply the chosen resolution
|
||||
// This might involve making another API call with the resolved data
|
||||
// or updating local state and then trying to sync again.
|
||||
// After resolving, remove the action from pending or mark as resolved.
|
||||
// For now, just remove it as an example:
|
||||
pendingActions.value = pendingActions.value.filter(a => a.id !== resolution.action.id);
|
||||
const handleConflictResolution = async (resolution: { version: 'local' | 'server' | 'merge'; action: OfflineAction; mergedData?: Record<string, unknown> }) => {
|
||||
if (!resolution.action || !currentConflict.value) {
|
||||
console.error("handleConflictResolution called without an action or active conflict.");
|
||||
showConflictDialog.value = false;
|
||||
currentConflict.value = null;
|
||||
processQueue().catch(err => console.error("Error processing queue after conflict resolution:", err)); // Try processing queue again
|
||||
return;
|
||||
}
|
||||
const { action, version, mergedData } = resolution;
|
||||
const serverVersionNumber = (currentConflict.value.serverVersion.data as any)?.version;
|
||||
|
||||
try {
|
||||
let success = false;
|
||||
if (version === 'local') {
|
||||
let dataToPush: any;
|
||||
let endpoint: string;
|
||||
let method: 'post' | 'put' = 'put';
|
||||
|
||||
if (action.type === 'update_list') {
|
||||
const payload = action.payload as UpdateListPayload;
|
||||
dataToPush = { ...payload.data, version: serverVersionNumber };
|
||||
endpoint = API_ENDPOINTS.LISTS.BY_ID(payload.listId);
|
||||
} else if (action.type === 'update_list_item') {
|
||||
const payload = action.payload as UpdateListItemPayload;
|
||||
dataToPush = { ...payload.data, version: serverVersionNumber };
|
||||
endpoint = API_ENDPOINTS.LISTS.ITEM(payload.listId, payload.itemId);
|
||||
} else if (action.type === 'create_list') {
|
||||
const serverData = currentConflict.value.serverVersion.data as ServerListData | null;
|
||||
if (serverData?.id) {
|
||||
// Server returned existing list, update it instead
|
||||
dataToPush = { ...action.payload, version: serverData.version };
|
||||
endpoint = API_ENDPOINTS.LISTS.BY_ID(serverData.id);
|
||||
} else {
|
||||
// True conflict, need to modify the data
|
||||
dataToPush = {
|
||||
...action.payload,
|
||||
name: `${(action.payload as CreateListPayload).name} (${new Date().toLocaleString()})`
|
||||
};
|
||||
endpoint = API_ENDPOINTS.LISTS.BASE;
|
||||
method = 'post';
|
||||
}
|
||||
} else if (action.type === 'create_list_item') {
|
||||
const serverData = currentConflict.value.serverVersion.data as ServerItemData | null;
|
||||
if (serverData?.id) {
|
||||
// Server returned existing item, update it instead
|
||||
dataToPush = { ...action.payload, version: serverData.version };
|
||||
endpoint = API_ENDPOINTS.LISTS.ITEM(
|
||||
(action.payload as CreateListItemPayload).listId,
|
||||
serverData.id
|
||||
);
|
||||
} else {
|
||||
// True conflict, need to modify the data
|
||||
dataToPush = {
|
||||
...action.payload,
|
||||
name: `${(action.payload as CreateListItemPayload).itemData.name} (${new Date().toLocaleString()})`
|
||||
};
|
||||
endpoint = API_ENDPOINTS.LISTS.ITEMS((action.payload as CreateListItemPayload).listId);
|
||||
method = 'post';
|
||||
}
|
||||
} else {
|
||||
console.error("Unsupported action type for 'keep local' resolution:", action.type);
|
||||
throw new Error("Unsupported action for 'keep local'");
|
||||
}
|
||||
|
||||
if (method === 'put') {
|
||||
await apiClient.put(endpoint, dataToPush);
|
||||
} else {
|
||||
await apiClient.post(endpoint, dataToPush);
|
||||
}
|
||||
success = true;
|
||||
notificationStore.addNotification({ type: 'success', message: 'Your version was saved to the server.' });
|
||||
|
||||
} else if (version === 'server') {
|
||||
success = true;
|
||||
notificationStore.addNotification({ type: 'info', message: 'Local changes discarded; server version kept.' });
|
||||
|
||||
} else if (version === 'merge' && mergedData) {
|
||||
let dataWithVersion: any;
|
||||
let endpoint: string;
|
||||
|
||||
if (action.type === 'update_list') {
|
||||
const payload = action.payload as UpdateListPayload;
|
||||
dataWithVersion = { ...mergedData, version: serverVersionNumber };
|
||||
endpoint = API_ENDPOINTS.LISTS.BY_ID(payload.listId);
|
||||
} else if (action.type === 'update_list_item') {
|
||||
const payload = action.payload as UpdateListItemPayload;
|
||||
dataWithVersion = { ...mergedData, version: serverVersionNumber };
|
||||
endpoint = API_ENDPOINTS.LISTS.ITEM(payload.listId, payload.itemId);
|
||||
} else if (action.type === 'create_list' || action.type === 'create_list_item') {
|
||||
// For create actions, merging means updating the existing item
|
||||
const serverData = currentConflict.value.serverVersion.data as (ServerListData | ServerItemData) | null;
|
||||
if (!serverData?.id) {
|
||||
throw new Error("Cannot merge create action: server data is missing or invalid");
|
||||
}
|
||||
|
||||
if (action.type === 'create_list') {
|
||||
dataWithVersion = { ...mergedData, version: serverData.version };
|
||||
endpoint = API_ENDPOINTS.LISTS.BY_ID(serverData.id);
|
||||
} else {
|
||||
dataWithVersion = { ...mergedData, version: serverData.version };
|
||||
endpoint = API_ENDPOINTS.LISTS.ITEM(
|
||||
(action.payload as CreateListItemPayload).listId,
|
||||
serverData.id
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.error("Merge resolution for unsupported action type:", action.type);
|
||||
throw new Error("Merge for this action type is not supported");
|
||||
}
|
||||
|
||||
await apiClient.put(endpoint, dataWithVersion);
|
||||
success = true;
|
||||
notificationStore.addNotification({ type: 'success', message: 'Merged version saved to the server.' });
|
||||
}
|
||||
|
||||
if (success) {
|
||||
pendingActions.value = pendingActions.value.filter(a => a.id !== action.id);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error during conflict resolution API call:', error);
|
||||
notificationStore.addNotification({
|
||||
type: 'error',
|
||||
message: `Failed to resolve conflict for ${action.type}. Please try again.`,
|
||||
});
|
||||
} finally {
|
||||
showConflictDialog.value = false;
|
||||
currentConflict.value = null;
|
||||
processQueue().catch(err => console.error("Error processing queue after conflict resolution:", err));
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isOnline,
|
||||
pendingActions,
|
||||
hasPendingActions,
|
||||
pendingActionCount,
|
||||
isProcessingQueue,
|
||||
showConflictDialog,
|
||||
currentConflict,
|
||||
addAction,
|
||||
processAction,
|
||||
processQueue,
|
||||
handleConflictResolution,
|
||||
hasPendingActions,
|
||||
pendingActionCount,
|
||||
};
|
||||
});
|
35
fe/src/sw.ts
35
fe/src/sw.ts
@ -5,7 +5,10 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
declare const self: ServiceWorkerGlobalScope &
|
||||
typeof globalThis & { skipWaiting: () => Promise<void> };
|
||||
typeof globalThis & {
|
||||
skipWaiting: () => Promise<void>;
|
||||
__WB_MANIFEST: Array<{ url: string; revision: string | null }>;
|
||||
};
|
||||
|
||||
import { clientsClaim } from 'workbox-core';
|
||||
import {
|
||||
@ -17,16 +20,32 @@ import { registerRoute, NavigationRoute } from 'workbox-routing';
|
||||
import { CacheFirst, NetworkFirst } from 'workbox-strategies';
|
||||
import { ExpirationPlugin } from 'workbox-expiration';
|
||||
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
|
||||
import { BackgroundSyncPlugin } from 'workbox-background-sync';
|
||||
import type { WorkboxPlugin } from 'workbox-core/types';
|
||||
|
||||
self.skipWaiting().catch((error) => {
|
||||
console.error('Error during service worker activation:', error);
|
||||
// Create a background sync plugin instance
|
||||
const bgSyncPlugin = new BackgroundSyncPlugin('offline-actions-queue', {
|
||||
maxRetentionTime: 24 * 60, // Retry for max of 24 Hours (specified in minutes)
|
||||
});
|
||||
clientsClaim();
|
||||
|
||||
// Initialize service worker
|
||||
const initializeSW = async () => {
|
||||
try {
|
||||
await self.skipWaiting();
|
||||
clientsClaim();
|
||||
console.log('Service Worker initialized successfully');
|
||||
} catch (error) {
|
||||
console.error('Error during service worker initialization:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Use with precache injection
|
||||
// vite-plugin-pwa will populate self.__WB_MANIFEST
|
||||
precacheAndRoute(self.__WB_MANIFEST || []); // Provide a fallback empty array
|
||||
if (self.__WB_MANIFEST) {
|
||||
precacheAndRoute(self.__WB_MANIFEST);
|
||||
} else {
|
||||
console.warn('No manifest found for precaching');
|
||||
}
|
||||
|
||||
cleanupOutdatedCaches();
|
||||
|
||||
@ -51,7 +70,7 @@ registerRoute(
|
||||
})
|
||||
);
|
||||
|
||||
// Cache API calls with Network First strategy
|
||||
// Cache API calls with Network First strategy and Background Sync for failed requests
|
||||
registerRoute(
|
||||
({ url }) => url.pathname.startsWith('/api/'), // Make sure this matches your actual API path structure
|
||||
new NetworkFirst({
|
||||
@ -64,6 +83,7 @@ registerRoute(
|
||||
maxEntries: 50,
|
||||
maxAgeSeconds: 24 * 60 * 60, // 24 hours
|
||||
}) as WorkboxPlugin,
|
||||
bgSyncPlugin, // Add background sync plugin for failed requests
|
||||
],
|
||||
})
|
||||
);
|
||||
@ -81,3 +101,6 @@ if (import.meta.env.MODE !== 'ssr' || import.meta.env.PROD) {
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Initialize the service worker
|
||||
initializeSW();
|
@ -7,12 +7,15 @@ import path from 'node:path';
|
||||
|
||||
const pwaOptions: Partial<VitePWAOptions> = {
|
||||
registerType: 'autoUpdate',
|
||||
strategies: 'injectManifest', // Crucial for custom service worker
|
||||
srcDir: 'src', // Directory where sw.ts is located
|
||||
filename: 'sw.ts', // Your custom service worker filename
|
||||
strategies: 'injectManifest',
|
||||
srcDir: 'src',
|
||||
filename: 'sw.ts',
|
||||
devOptions: {
|
||||
enabled: true, // Enable PWA in development
|
||||
enabled: true,
|
||||
type: 'module',
|
||||
navigateFallback: 'index.html',
|
||||
suppressWarnings: true,
|
||||
swSrc: 'src/sw.ts',
|
||||
},
|
||||
manifest: {
|
||||
name: 'mitlist',
|
||||
@ -31,8 +34,20 @@ const pwaOptions: Partial<VitePWAOptions> = {
|
||||
],
|
||||
},
|
||||
injectManifest: {
|
||||
// Options for workbox.injectManifest
|
||||
// Ensure your custom service worker (sw.ts) correctly handles __WB_MANIFEST
|
||||
globPatterns: [
|
||||
'**/*.{js,css,html,ico,png,svg,woff2}',
|
||||
'offline.html',
|
||||
],
|
||||
globIgnores: [
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
'sw.js',
|
||||
],
|
||||
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024, // 5MB
|
||||
},
|
||||
workbox: {
|
||||
cleanupOutdatedCaches: true,
|
||||
sourcemap: true,
|
||||
},
|
||||
};
|
||||
|
||||
@ -42,8 +57,8 @@ export default defineConfig({
|
||||
VitePWA(pwaOptions),
|
||||
VueI18nPlugin({
|
||||
include: [path.resolve(path.dirname(fileURLToPath(import.meta.url)), './src/i18n/**')],
|
||||
strictMessage: false, // To avoid warnings for missing Quasar translations initially
|
||||
runtimeOnly: false, // If you use <i18n> component or complex messages
|
||||
strictMessage: false,
|
||||
runtimeOnly: false,
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
@ -51,9 +66,8 @@ export default defineConfig({
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
// Define env variables similar to Quasar's process.env
|
||||
define: {
|
||||
'process.env.PWA_FALLBACK_HTML': JSON.stringify('/index.html'), // Adjust if you have a specific offline.html
|
||||
'process.env.PWA_FALLBACK_HTML': JSON.stringify('/index.html'),
|
||||
'process.env.PWA_SERVICE_WORKER_REGEX': JSON.stringify(/^(sw|workbox)-.*\.js$/),
|
||||
'process.env.MODE': JSON.stringify(process.env.NODE_ENV),
|
||||
'process.env.PROD': JSON.stringify(process.env.NODE_ENV === 'production'),
|
||||
|
Loading…
Reference in New Issue
Block a user