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,
|
ListNotFoundError,
|
||||||
ListPermissionError,
|
ListPermissionError,
|
||||||
ListStatusNotFoundError,
|
ListStatusNotFoundError,
|
||||||
ConflictError # Added ConflictError
|
ConflictError, # Added ConflictError
|
||||||
|
DatabaseIntegrityError # Added DatabaseIntegrityError
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -29,7 +30,13 @@ router = APIRouter()
|
|||||||
response_model=ListPublic, # Return basic list info on creation
|
response_model=ListPublic, # Return basic list info on creation
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
summary="Create New List",
|
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(
|
async def create_list(
|
||||||
list_in: ListCreate,
|
list_in: ListCreate,
|
||||||
@ -40,6 +47,7 @@ async def create_list(
|
|||||||
Creates a new shopping list.
|
Creates a new shopping list.
|
||||||
- If `group_id` is provided, the user must be a member of that group.
|
- 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 `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}")
|
logger.info(f"User {current_user.email} creating list: {list_in.name}")
|
||||||
group_id = list_in.group_id
|
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.")
|
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")
|
raise GroupMembershipError(group_id, "create lists")
|
||||||
|
|
||||||
created_list = await crud_list.create_list(db=db, list_in=list_in, creator_id=current_user.id)
|
try:
|
||||||
logger.info(f"List '{created_list.name}' (ID: {created_list.id}) created successfully for user {current_user.email}.")
|
created_list = await crud_list.create_list(db=db, list_in=list_in, creator_id=current_user.id)
|
||||||
return created_list
|
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(
|
@router.get(
|
||||||
|
@ -206,4 +206,46 @@ async def get_list_status(db: AsyncSession, list_id: int) -> ListStatus:
|
|||||||
except OperationalError as e:
|
except OperationalError as e:
|
||||||
raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}")
|
raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}")
|
||||||
except SQLAlchemyError as e:
|
except SQLAlchemyError as e:
|
||||||
raise DatabaseQueryError(f"Failed to get list status: {str(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*
|
pnpm-debug.log*
|
||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
|
|
||||||
node_modules
|
**/node_modules/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
@ -28,6 +28,7 @@ coverage
|
|||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
*.sw.js
|
||||||
|
|
||||||
test-results/
|
test-results/
|
||||||
playwright-report/
|
playwright-report/
|
||||||
|
6
fe/package-lock.json
generated
6
fe/package-lock.json
generated
@ -17,7 +17,8 @@
|
|||||||
"pinia": "^3.0.2",
|
"pinia": "^3.0.2",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-i18n": "^12.0.0-alpha.2",
|
"vue-i18n": "^12.0.0-alpha.2",
|
||||||
"vue-router": "^4.5.1"
|
"vue-router": "^4.5.1",
|
||||||
|
"workbox-background-sync": "^7.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@intlify/unplugin-vue-i18n": "^6.0.8",
|
"@intlify/unplugin-vue-i18n": "^6.0.8",
|
||||||
@ -7585,7 +7586,6 @@
|
|||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
|
||||||
"integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
|
"integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/ignore": {
|
"node_modules/ignore": {
|
||||||
@ -12067,7 +12067,6 @@
|
|||||||
"version": "7.3.0",
|
"version": "7.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.3.0.tgz",
|
||||||
"integrity": "sha512-PCSk3eK7Mxeuyatb22pcSx9dlgWNv3+M8PqPaYDokks8Y5/FX4soaOqj3yhAZr5k6Q5JWTOMYgaJBpbw11G9Eg==",
|
"integrity": "sha512-PCSk3eK7Mxeuyatb22pcSx9dlgWNv3+M8PqPaYDokks8Y5/FX4soaOqj3yhAZr5k6Q5JWTOMYgaJBpbw11G9Eg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"idb": "^7.0.1",
|
"idb": "^7.0.1",
|
||||||
@ -12402,7 +12401,6 @@
|
|||||||
"version": "7.3.0",
|
"version": "7.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.3.0.tgz",
|
||||||
"integrity": "sha512-Z+mYrErfh4t3zi7NVTvOuACB0A/jA3bgxUN3PwtAVHvfEsZxV9Iju580VEETug3zYJRc0Dmii/aixI/Uxj8fmw==",
|
"integrity": "sha512-Z+mYrErfh4t3zi7NVTvOuACB0A/jA3bgxUN3PwtAVHvfEsZxV9Iju580VEETug3zYJRc0Dmii/aixI/Uxj8fmw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/workbox-expiration": {
|
"node_modules/workbox-expiration": {
|
||||||
|
@ -26,7 +26,8 @@
|
|||||||
"pinia": "^3.0.2",
|
"pinia": "^3.0.2",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-i18n": "^12.0.0-alpha.2",
|
"vue-i18n": "^12.0.0-alpha.2",
|
||||||
"vue-router": "^4.5.1"
|
"vue-router": "^4.5.1",
|
||||||
|
"workbox-background-sync": "^7.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@intlify/unplugin-vue-i18n": "^6.0.8",
|
"@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;
|
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
|
// Add more global utility classes or base styles
|
@ -1,53 +1,54 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div v-if="!isOnline || hasPendingActions" class="alert offline-indicator" :class="{
|
||||||
v-if="!isOnline || hasPendingActions"
|
'alert-error': !isOnline,
|
||||||
class="alert offline-indicator"
|
'alert-warning': isOnline && hasPendingActions
|
||||||
:class="{
|
}" role="status">
|
||||||
'alert-error': !isOnline,
|
|
||||||
'alert-warning': isOnline && hasPendingActions
|
|
||||||
}"
|
|
||||||
role="status"
|
|
||||||
>
|
|
||||||
<div class="alert-content">
|
<div class="alert-content">
|
||||||
<svg class="icon" aria-hidden="true">
|
<svg class="icon" aria-hidden="true">
|
||||||
<use :xlink:href="!isOnline ? '#icon-alert-triangle' : '#icon-info'" />
|
<use :xlink:href="!isOnline ? '#icon-wifi-off' : '#icon-sync'" />
|
||||||
<!-- Placeholder icons, wifi_off and sync are not in Valerie UI default -->
|
|
||||||
</svg>
|
</svg>
|
||||||
<span v-if="!isOnline">
|
<span v-if="!isOnline" class="status-text">
|
||||||
You are currently offline. Changes will be saved locally.
|
You are currently offline. Changes will be saved locally.
|
||||||
</span>
|
</span>
|
||||||
<span v-else>
|
<span v-else class="status-text">
|
||||||
Syncing {{ pendingActionCount }} pending {{ pendingActionCount === 1 ? 'change' : 'changes' }}...
|
Syncing {{ pendingActionCount }} pending {{ pendingActionCount === 1 ? 'change' : 'changes' }}...
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button v-if="hasPendingActions" class="btn btn-sm btn-neutral" @click="showPendingActionsModal = true">
|
||||||
v-if="hasPendingActions"
|
|
||||||
class="btn btn-sm btn-neutral"
|
|
||||||
@click="showPendingActionsModal = true"
|
|
||||||
>
|
|
||||||
View Changes
|
View Changes
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="showPendingActionsModal" class="modal-backdrop open" @click.self="showPendingActionsModal = false">
|
<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">
|
<div class="modal-header">
|
||||||
<h3 id="pendingActionsTitle">Pending Changes</h3>
|
<h3 id="pendingActionsTitle">Pending Changes</h3>
|
||||||
<button class="close-button" @click="showPendingActionsModal = false" aria-label="Close">
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<ul v-if="pendingActions.length" class="item-list">
|
<ul v-if="pendingActions.length" class="item-list">
|
||||||
<li v-for="action in pendingActions" :key="action.id" class="list-item">
|
<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">
|
||||||
<span class="item-text">{{ getActionLabel(action) }}</span>
|
<div class="action-info">
|
||||||
<small class="text-caption">{{ new Date(action.timestamp).toLocaleString() }}</small>
|
<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>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p v-else>No pending changes.</p>
|
<p v-else class="empty-state">No pending changes.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-primary" @click="showPendingActionsModal = false">Close</button>
|
<button type="button" class="btn btn-primary" @click="showPendingActionsModal = false">Close</button>
|
||||||
@ -56,58 +57,55 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Conflict Resolution Dialog -->
|
<!-- Conflict Resolution Dialog -->
|
||||||
<ConflictResolutionDialog
|
<ConflictResolutionDialog v-model="offlineStore.showConflictDialog" :conflict-data="offlineStore.currentConflict"
|
||||||
v-model="offlineStore.showConflictDialog"
|
@resolve="offlineStore.handleConflictResolution" />
|
||||||
:conflict-data="offlineStore.currentConflict"
|
|
||||||
@resolve="offlineStore.handleConflictResolution"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { useNetwork, onClickOutside } from '@vueuse/core';
|
import { useNetwork, onClickOutside } from '@vueuse/core';
|
||||||
import { useOfflineStore } from '@/stores/offline'; // Assuming path
|
import { useOfflineStore } from '@/stores/offline';
|
||||||
import type { OfflineAction } from '@/stores/offline'; // Assuming path
|
import type { OfflineAction } from '@/stores/offline';
|
||||||
import ConflictResolutionDialog from '@/components/ConflictResolutionDialog.vue';
|
import ConflictResolutionDialog from '@/components/ConflictResolutionDialog.vue';
|
||||||
|
|
||||||
const offlineStore = useOfflineStore();
|
const offlineStore = useOfflineStore();
|
||||||
const showPendingActionsModal = ref(false);
|
const showPendingActionsModal = ref(false);
|
||||||
const pendingActionsModalRef = ref<HTMLElement | null>(null);
|
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 {
|
const {
|
||||||
pendingActions,
|
pendingActions,
|
||||||
hasPendingActions,
|
hasPendingActions,
|
||||||
pendingActionCount,
|
pendingActionCount,
|
||||||
// showConflictDialog, // Handled by offlineStore.showConflictDialog
|
|
||||||
// currentConflict, // Handled by offlineStore.currentConflict
|
|
||||||
// handleConflictResolution // Handled by offlineStore.handleConflictResolution
|
|
||||||
} = offlineStore;
|
} = offlineStore;
|
||||||
|
|
||||||
|
|
||||||
onClickOutside(pendingActionsModalRef, () => {
|
onClickOutside(pendingActionsModalRef, () => {
|
||||||
showPendingActionsModal.value = false;
|
showPendingActionsModal.value = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const removePendingAction = (actionId: string) => {
|
||||||
|
offlineStore.pendingActions = offlineStore.pendingActions.filter(a => a.id !== actionId);
|
||||||
|
};
|
||||||
|
|
||||||
const getActionLabel = (action: OfflineAction) => {
|
const getActionLabel = (action: OfflineAction) => {
|
||||||
// This is a simplified version of your original getActionLabel
|
const data = action.payload as { title?: string; name?: string;[key: string]: unknown };
|
||||||
// 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 itemTitle = data.title || data.name || (typeof data === 'string' ? data : 'Untitled Item');
|
const itemTitle = data.title || data.name || (typeof data === 'string' ? data : 'Untitled Item');
|
||||||
|
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case 'add':
|
case 'create_list':
|
||||||
case 'create': // Common alias
|
return `Create List: ${itemTitle}`;
|
||||||
return `Add: ${itemTitle}`;
|
case 'update_list':
|
||||||
case 'complete':
|
return `Update List: ${itemTitle}`;
|
||||||
return `Complete: ${itemTitle}`;
|
case 'delete_list':
|
||||||
case 'update':
|
return `Delete List: ${itemTitle}`;
|
||||||
return `Update: ${itemTitle}`;
|
case 'create_list_item':
|
||||||
case 'delete':
|
return `Add Item: ${itemTitle}`;
|
||||||
return `Delete: ${itemTitle}`;
|
case 'update_list_item':
|
||||||
|
return `Update Item: ${itemTitle}`;
|
||||||
|
case 'delete_list_item':
|
||||||
|
return `Delete Item: ${itemTitle}`;
|
||||||
default:
|
default:
|
||||||
return `Unknown action: ${action.type} for ${itemTitle}`;
|
return `Unknown action: ${action.type} for ${itemTitle}`;
|
||||||
}
|
}
|
||||||
@ -121,22 +119,83 @@ const getActionLabel = (action: OfflineAction) => {
|
|||||||
right: 1rem;
|
right: 1rem;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
max-width: 400px;
|
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 {
|
.text-caption {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--dark);
|
color: var(--dark);
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Simplified list item for pending actions modal */
|
.item-list {
|
||||||
.item-list .list-item .list-item-content {
|
list-style: none;
|
||||||
padding: 0.75rem 1rem;
|
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;
|
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>
|
</style>
|
@ -21,14 +21,14 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Continue with Google
|
Continue with Google
|
||||||
</button>
|
</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">
|
<svg class="icon" viewBox="0 0 24 24">
|
||||||
<path
|
<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"
|
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" />
|
fill="#000" />
|
||||||
</svg>
|
</svg>
|
||||||
Continue with Apple
|
Continue with Apple
|
||||||
</button>
|
</button> -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -19,16 +19,20 @@
|
|||||||
<div class="flex justify-between items-center flex-wrap mb-2">
|
<div class="flex justify-between items-center flex-wrap mb-2">
|
||||||
<h1>{{ list.name }}</h1>
|
<h1>{{ list.name }}</h1>
|
||||||
<div class="flex items-center flex-wrap" style="gap: 0.5rem;">
|
<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">
|
<svg class="icon icon-sm">
|
||||||
<use xlink:href="#icon-clipboard" />
|
<use xlink:href="#icon-clipboard" />
|
||||||
</svg> <!-- Placeholder icon -->
|
</svg>
|
||||||
Cost Summary
|
Cost Summary
|
||||||
</button>
|
</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">
|
<svg class="icon icon-sm">
|
||||||
<use xlink:href="#icon-plus" />
|
<use xlink:href="#icon-plus" />
|
||||||
</svg> <!-- Placeholder, camera_alt not in Valerie -->
|
</svg>
|
||||||
Add via OCR
|
Add via OCR
|
||||||
</button>
|
</button>
|
||||||
<span class="item-badge ml-1" :class="list.is_complete ? 'badge-settled' : 'badge-pending'">
|
<span class="item-badge ml-1" :class="list.is_complete ? 'badge-settled' : 'badge-pending'">
|
||||||
@ -68,9 +72,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul v-else class="item-list">
|
<ul v-else class="item-list">
|
||||||
<li v-for="item in list.items" :key="item.id" class="list-item"
|
<li v-for="item in list.items" :key="item.id" class="list-item" :class="{
|
||||||
:class="{ 'completed': item.is_complete, 'is-swiped': item.swiped }" @touchstart="handleTouchStart"
|
'completed': item.is_complete,
|
||||||
@touchmove="handleTouchMove" @touchend="handleTouchEnd">
|
'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-content">
|
||||||
<div class="list-item-main">
|
<div class="list-item-main">
|
||||||
<label class="checkbox-label mb-0 flex-shrink-0">
|
<label class="checkbox-label mb-0 flex-shrink-0">
|
||||||
@ -90,7 +97,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Non-swipe actions can be added here or handled by swipe -->
|
|
||||||
<div class="list-item-actions">
|
<div class="list-item-actions">
|
||||||
<button class="btn btn-danger btn-sm btn-icon-only" @click.stop="confirmDeleteItem(item)"
|
<button class="btn btn-danger btn-sm btn-icon-only" @click.stop="confirmDeleteItem(item)"
|
||||||
:disabled="item.deleting" aria-label="Delete item">
|
:disabled="item.deleting" aria-label="Delete item">
|
||||||
@ -100,7 +106,6 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Swipe actions could be added here if fully implementing swipe from Valerie UI example -->
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</template>
|
</template>
|
||||||
@ -152,6 +157,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Cost Summary Dialog -->
|
||||||
<div v-if="showCostSummaryDialog" class="modal-backdrop open" @click.self="showCostSummaryDialog = false">
|
<div v-if="showCostSummaryDialog" class="modal-backdrop open" @click.self="showCostSummaryDialog = false">
|
||||||
<div class="modal-container" ref="costSummaryModalRef" style="min-width: 550px;">
|
<div class="modal-container" ref="costSummaryModalRef" style="min-width: 550px;">
|
||||||
@ -208,37 +235,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { useRoute } from 'vue-router';
|
||||||
import { apiClient, API_ENDPOINTS } from '@/config/api';
|
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 { useNotificationStore } from '@/stores/notifications';
|
||||||
|
import { useOfflineStore, type CreateListItemPayload } from '@/stores/offline';
|
||||||
|
|
||||||
interface Item {
|
interface Item {
|
||||||
id: number;
|
id: number;
|
||||||
@ -283,8 +289,9 @@ interface ListCostSummaryData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const { isOnline } = useNetwork();
|
||||||
const notificationStore = useNotificationStore();
|
const notificationStore = useNotificationStore();
|
||||||
|
const offlineStore = useOfflineStore();
|
||||||
const list = ref<List | null>(null);
|
const list = ref<List | null>(null);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
@ -391,6 +398,16 @@ const stopPolling = () => {
|
|||||||
if (pollingInterval.value) clearInterval(pollingInterval.value);
|
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 () => {
|
const onAddItem = async () => {
|
||||||
if (!list.value || !newItem.value.name.trim()) {
|
if (!list.value || !newItem.value.name.trim()) {
|
||||||
notificationStore.addNotification({ message: 'Please enter an item name.', type: 'warning' });
|
notificationStore.addNotification({ message: 'Please enter an item name.', type: 'warning' });
|
||||||
@ -398,6 +415,35 @@ const onAddItem = async () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
addingItem.value = true;
|
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 {
|
try {
|
||||||
const response = await apiClient.post(
|
const response = await apiClient.post(
|
||||||
API_ENDPOINTS.LISTS.ITEMS(String(list.value.id)),
|
API_ENDPOINTS.LISTS.ITEMS(String(list.value.id)),
|
||||||
@ -420,77 +466,104 @@ const updateItem = async (item: Item, newCompleteStatus: boolean) => {
|
|||||||
const originalCompleteStatus = item.is_complete;
|
const originalCompleteStatus = item.is_complete;
|
||||||
item.is_complete = newCompleteStatus; // Optimistic update
|
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 {
|
try {
|
||||||
const payload: { is_complete: boolean; version: number; price?: number | null } = {
|
await apiClient.put(
|
||||||
is_complete: item.is_complete,
|
API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)),
|
||||||
version: item.version,
|
{ completed: newCompleteStatus, version: item.version }
|
||||||
};
|
);
|
||||||
if (item.is_complete && item.priceInput !== undefined && item.priceInput !== null && String(item.priceInput).trim() !== '') {
|
item.version++;
|
||||||
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();
|
|
||||||
|
|
||||||
} catch (err) {
|
} 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' });
|
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to update item.', type: 'error' });
|
||||||
} finally {
|
} finally {
|
||||||
item.updating = false;
|
item.updating = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const updateItemPrice = async (item: Item) => {
|
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;
|
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;
|
item.updating = true;
|
||||||
const originalPrice = item.price;
|
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 {
|
try {
|
||||||
const response = await apiClient.put(
|
await apiClient.put(
|
||||||
API_ENDPOINTS.ITEMS.BY_ID(String(item.id)),
|
API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)),
|
||||||
{ price: item.price, is_complete: item.is_complete, version: item.version }
|
{ price: newPrice, completed: item.is_complete, version: item.version }
|
||||||
);
|
);
|
||||||
const updatedItemFromServer = processListItems([response.data as Item])[0];
|
item.version++;
|
||||||
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();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
item.price = originalPrice; // Revert
|
item.price = originalPrice;
|
||||||
item.priceInput = originalPrice !== null && originalPrice !== undefined ? originalPrice : '';
|
item.priceInput = originalPriceInput;
|
||||||
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to update item price.', type: 'error' });
|
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to update item price.', type: 'error' });
|
||||||
} finally {
|
} finally {
|
||||||
item.updating = false;
|
item.updating = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const deleteItem = async (item: Item) => {
|
const deleteItem = async (item: Item) => {
|
||||||
if (!list.value) return;
|
if (!list.value) return;
|
||||||
item.deleting = true;
|
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 {
|
try {
|
||||||
await apiClient.delete(API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)));
|
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);
|
list.value.items = list.value.items.filter(i => i.id !== item.id);
|
||||||
if (showCostSummaryDialog.value) await fetchListCostSummary();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to delete item.', type: 'error' });
|
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to delete item.', type: 'error' });
|
||||||
} finally {
|
} finally {
|
||||||
@ -757,4 +830,70 @@ onUnmounted(() => {
|
|||||||
padding-left: 1rem;
|
padding-left: 1rem;
|
||||||
/* Space before actions */
|
/* 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>
|
</style>
|
@ -2,7 +2,7 @@
|
|||||||
<main class="flex items-center justify-center page-container">
|
<main class="flex items-center justify-center page-container">
|
||||||
<div class="card login-card">
|
<div class="card login-card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3>Login</h3>
|
<h3>mitlist</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form @submit.prevent="onSubmit" class="form-layout">
|
<form @submit.prevent="onSubmit" class="form-layout">
|
||||||
|
@ -4,12 +4,32 @@ import { ref, computed } from 'vue';
|
|||||||
// import { LocalStorage } from 'quasar'; // REMOVE
|
// import { LocalStorage } from 'quasar'; // REMOVE
|
||||||
import { useStorage } from '@vueuse/core'; // VueUse alternative
|
import { useStorage } from '@vueuse/core'; // VueUse alternative
|
||||||
import { useNotificationStore } from '@/stores/notifications'; // Your custom notification store
|
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 = {
|
export type OfflineAction = {
|
||||||
id: string;
|
id: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
type: string;
|
type:
|
||||||
payload: Record<string, unknown>; // Added payload property
|
| '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 = {
|
export type ConflictData = {
|
||||||
@ -18,6 +38,20 @@ export type ConflictData = {
|
|||||||
action: OfflineAction;
|
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', () => {
|
export const useOfflineStore = defineStore('offline', () => {
|
||||||
// const $q = useQuasar(); // REMOVE
|
// const $q = useQuasar(); // REMOVE
|
||||||
const notificationStore = useNotificationStore();
|
const notificationStore = useNotificationStore();
|
||||||
@ -35,13 +69,12 @@ export const useOfflineStore = defineStore('offline', () => {
|
|||||||
// saveToStorage is also handled by useStorage automatically saving on change
|
// saveToStorage is also handled by useStorage automatically saving on change
|
||||||
|
|
||||||
const addAction = (action: Omit<OfflineAction, 'id' | 'timestamp'>) => {
|
const addAction = (action: Omit<OfflineAction, 'id' | 'timestamp'>) => {
|
||||||
const newAction: OfflineAction = {
|
const newAction = {
|
||||||
...action,
|
...action,
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
};
|
} as OfflineAction;
|
||||||
pendingActions.value.push(newAction);
|
pendingActions.value.push(newAction);
|
||||||
// useStorage handles saving
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const processQueue = async () => {
|
const processQueue = async () => {
|
||||||
@ -51,52 +84,132 @@ export const useOfflineStore = defineStore('offline', () => {
|
|||||||
|
|
||||||
for (const action of actionsToProcess) {
|
for (const action of actionsToProcess) {
|
||||||
try {
|
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);
|
pendingActions.value = pendingActions.value.filter(a => a.id !== action.id);
|
||||||
} catch (error) {
|
} catch (error: any) { // Catch error as any to check for our custom flag
|
||||||
if (error instanceof Error && 'response' in error && typeof error.response === 'object' && error.response && 'status' in error.response && error.response.status === 409) {
|
if (error && error.isConflict && error.serverVersionData) {
|
||||||
notificationStore.addNotification({
|
notificationStore.addNotification({
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
message: 'Item was modified by someone else. Please review.',
|
message: `Conflict detected for action ${action.type}. Please review.`,
|
||||||
// actions: [ ... ] // Custom actions for notifications would be more complex
|
|
||||||
});
|
});
|
||||||
// Here you would trigger the conflict resolution dialog
|
|
||||||
// For example, find the item and its server version, then:
|
let localData: Record<string, unknown>;
|
||||||
// currentConflict.value = { localVersion: ..., serverVersion: ..., action };
|
// Extract local data based on action type
|
||||||
// showConflictDialog.value = true;
|
if (action.type === 'update_list' || action.type === 'update_list_item') {
|
||||||
// The loop should probably pause or handle this conflict before continuing
|
localData = (action.payload as UpdateListPayload | UpdateListItemPayload).data;
|
||||||
console.warn('Conflict detected for action:', action.id, error);
|
} else if (action.type === 'create_list' || action.type === 'create_list_item') {
|
||||||
// Break or decide how to handle queue processing on conflict
|
localData = action.payload as CreateListPayload | CreateListItemPayload;
|
||||||
break;
|
} else {
|
||||||
|
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 {
|
} else {
|
||||||
console.error('Failed to process offline action:', action.id, error);
|
console.error('processQueue: Action failed, remains in queue:', action.id, error);
|
||||||
notificationStore.addNotification({
|
|
||||||
type: 'error',
|
|
||||||
message: `Failed to sync action: ${action.type}`,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
isProcessingQueue.value = false;
|
isProcessingQueue.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
// processAction needs to be implemented with your actual API calls
|
|
||||||
const processAction = async (action: OfflineAction) => {
|
const processAction = async (action: OfflineAction) => {
|
||||||
console.log('Processing action (TODO: Implement API call):', action);
|
try {
|
||||||
// Example:
|
let request: Request;
|
||||||
// import { apiClient } from '@/services/api';
|
let endpoint: string;
|
||||||
// import { API_ENDPOINTS } from '@/config/api-config';
|
let method: 'POST' | 'PUT' | 'DELETE' = 'POST';
|
||||||
// switch (action.type) {
|
let body: any;
|
||||||
// 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));
|
|
||||||
};
|
|
||||||
|
|
||||||
|
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 = () => {
|
const setupNetworkListeners = () => {
|
||||||
window.addEventListener('online', () => {
|
window.addEventListener('online', () => {
|
||||||
@ -113,28 +226,148 @@ export const useOfflineStore = defineStore('offline', () => {
|
|||||||
const hasPendingActions = computed(() => pendingActions.value.length > 0);
|
const hasPendingActions = computed(() => pendingActions.value.length > 0);
|
||||||
const pendingActionCount = computed(() => pendingActions.value.length);
|
const pendingActionCount = computed(() => pendingActions.value.length);
|
||||||
|
|
||||||
const handleConflictResolution = (resolution: { version: 'local' | 'server' | 'merge'; action: OfflineAction; mergedData?: Record<string, unknown> }) => {
|
const handleConflictResolution = async (resolution: { version: 'local' | 'server' | 'merge'; action: OfflineAction; mergedData?: Record<string, unknown> }) => {
|
||||||
console.log('Conflict resolution chosen:', resolution);
|
if (!resolution.action || !currentConflict.value) {
|
||||||
// TODO: Implement logic to apply the chosen resolution
|
console.error("handleConflictResolution called without an action or active conflict.");
|
||||||
// This might involve making another API call with the resolved data
|
showConflictDialog.value = false;
|
||||||
// or updating local state and then trying to sync again.
|
currentConflict.value = null;
|
||||||
// After resolving, remove the action from pending or mark as resolved.
|
return;
|
||||||
// For now, just remove it as an example:
|
}
|
||||||
pendingActions.value = pendingActions.value.filter(a => a.id !== resolution.action.id);
|
const { action, version, mergedData } = resolution;
|
||||||
showConflictDialog.value = false;
|
const serverVersionNumber = (currentConflict.value.serverVersion.data as any)?.version;
|
||||||
currentConflict.value = null;
|
|
||||||
processQueue().catch(err => console.error("Error processing queue after conflict resolution:", err)); // Try processing queue again
|
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 {
|
return {
|
||||||
isOnline,
|
isOnline,
|
||||||
pendingActions,
|
pendingActions,
|
||||||
hasPendingActions,
|
isProcessingQueue,
|
||||||
pendingActionCount,
|
|
||||||
showConflictDialog,
|
showConflictDialog,
|
||||||
currentConflict,
|
currentConflict,
|
||||||
addAction,
|
addAction,
|
||||||
|
processAction,
|
||||||
processQueue,
|
processQueue,
|
||||||
handleConflictResolution,
|
handleConflictResolution,
|
||||||
|
hasPendingActions,
|
||||||
|
pendingActionCount,
|
||||||
};
|
};
|
||||||
});
|
});
|
37
fe/src/sw.ts
37
fe/src/sw.ts
@ -5,7 +5,10 @@
|
|||||||
/// <reference lib="webworker" />
|
/// <reference lib="webworker" />
|
||||||
|
|
||||||
declare const self: ServiceWorkerGlobalScope &
|
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 { clientsClaim } from 'workbox-core';
|
||||||
import {
|
import {
|
||||||
@ -17,16 +20,32 @@ import { registerRoute, NavigationRoute } from 'workbox-routing';
|
|||||||
import { CacheFirst, NetworkFirst } from 'workbox-strategies';
|
import { CacheFirst, NetworkFirst } from 'workbox-strategies';
|
||||||
import { ExpirationPlugin } from 'workbox-expiration';
|
import { ExpirationPlugin } from 'workbox-expiration';
|
||||||
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
|
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
|
||||||
|
import { BackgroundSyncPlugin } from 'workbox-background-sync';
|
||||||
import type { WorkboxPlugin } from 'workbox-core/types';
|
import type { WorkboxPlugin } from 'workbox-core/types';
|
||||||
|
|
||||||
self.skipWaiting().catch((error) => {
|
// Create a background sync plugin instance
|
||||||
console.error('Error during service worker activation:', error);
|
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
|
// Use with precache injection
|
||||||
// vite-plugin-pwa will populate self.__WB_MANIFEST
|
// 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();
|
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(
|
registerRoute(
|
||||||
({ url }) => url.pathname.startsWith('/api/'), // Make sure this matches your actual API path structure
|
({ url }) => url.pathname.startsWith('/api/'), // Make sure this matches your actual API path structure
|
||||||
new NetworkFirst({
|
new NetworkFirst({
|
||||||
@ -64,6 +83,7 @@ registerRoute(
|
|||||||
maxEntries: 50,
|
maxEntries: 50,
|
||||||
maxAgeSeconds: 24 * 60 * 60, // 24 hours
|
maxAgeSeconds: 24 * 60 * 60, // 24 hours
|
||||||
}) as WorkboxPlugin,
|
}) as WorkboxPlugin,
|
||||||
|
bgSyncPlugin, // Add background sync plugin for failed requests
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@ -80,4 +100,7 @@ if (import.meta.env.MODE !== 'ssr' || import.meta.env.PROD) {
|
|||||||
denylist: [PWA_SERVICE_WORKER_REGEX, /workbox-(.)*\.js$/],
|
denylist: [PWA_SERVICE_WORKER_REGEX, /workbox-(.)*\.js$/],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize the service worker
|
||||||
|
initializeSW();
|
@ -7,12 +7,15 @@ import path from 'node:path';
|
|||||||
|
|
||||||
const pwaOptions: Partial<VitePWAOptions> = {
|
const pwaOptions: Partial<VitePWAOptions> = {
|
||||||
registerType: 'autoUpdate',
|
registerType: 'autoUpdate',
|
||||||
strategies: 'injectManifest', // Crucial for custom service worker
|
strategies: 'injectManifest',
|
||||||
srcDir: 'src', // Directory where sw.ts is located
|
srcDir: 'src',
|
||||||
filename: 'sw.ts', // Your custom service worker filename
|
filename: 'sw.ts',
|
||||||
devOptions: {
|
devOptions: {
|
||||||
enabled: true, // Enable PWA in development
|
enabled: true,
|
||||||
type: 'module',
|
type: 'module',
|
||||||
|
navigateFallback: 'index.html',
|
||||||
|
suppressWarnings: true,
|
||||||
|
swSrc: 'src/sw.ts',
|
||||||
},
|
},
|
||||||
manifest: {
|
manifest: {
|
||||||
name: 'mitlist',
|
name: 'mitlist',
|
||||||
@ -31,8 +34,20 @@ const pwaOptions: Partial<VitePWAOptions> = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
injectManifest: {
|
injectManifest: {
|
||||||
// Options for workbox.injectManifest
|
globPatterns: [
|
||||||
// Ensure your custom service worker (sw.ts) correctly handles __WB_MANIFEST
|
'**/*.{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),
|
VitePWA(pwaOptions),
|
||||||
VueI18nPlugin({
|
VueI18nPlugin({
|
||||||
include: [path.resolve(path.dirname(fileURLToPath(import.meta.url)), './src/i18n/**')],
|
include: [path.resolve(path.dirname(fileURLToPath(import.meta.url)), './src/i18n/**')],
|
||||||
strictMessage: false, // To avoid warnings for missing Quasar translations initially
|
strictMessage: false,
|
||||||
runtimeOnly: false, // If you use <i18n> component or complex messages
|
runtimeOnly: false,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
@ -51,9 +66,8 @@ export default defineConfig({
|
|||||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Define env variables similar to Quasar's process.env
|
|
||||||
define: {
|
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.PWA_SERVICE_WORKER_REGEX': JSON.stringify(/^(sw|workbox)-.*\.js$/),
|
||||||
'process.env.MODE': JSON.stringify(process.env.NODE_ENV),
|
'process.env.MODE': JSON.stringify(process.env.NODE_ENV),
|
||||||
'process.env.PROD': JSON.stringify(process.env.NODE_ENV === 'production'),
|
'process.env.PROD': JSON.stringify(process.env.NODE_ENV === 'production'),
|
||||||
|
Loading…
Reference in New Issue
Block a user