diff --git a/be/app/api/v1/endpoints/lists.py b/be/app/api/v1/endpoints/lists.py index a91b6e9..b864f3d 100644 --- a/be/app/api/v1/endpoints/lists.py +++ b/be/app/api/v1/endpoints/lists.py @@ -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") - 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 + 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( diff --git a/be/app/crud/list.py b/be/app/crud/list.py index 3921cae..35527c8 100644 --- a/be/app/crud/list.py +++ b/be/app/crud/list.py @@ -206,4 +206,46 @@ async def get_list_status(db: AsyncSession, list_id: int) -> ListStatus: except OperationalError as e: raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}") except SQLAlchemyError as e: - raise DatabaseQueryError(f"Failed to get list status: {str(e)}") \ No newline at end of file + 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)}") \ No newline at end of file diff --git a/fe/.gitignore b/fe/.gitignore index aef72d0..c77e501 100644 --- a/fe/.gitignore +++ b/fe/.gitignore @@ -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/ diff --git a/fe/package-lock.json b/fe/package-lock.json index cd6b435..6fea4b0 100644 --- a/fe/package-lock.json +++ b/fe/package-lock.json @@ -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": { diff --git a/fe/package.json b/fe/package.json index ae02d36..82e96f0 100644 --- a/fe/package.json +++ b/fe/package.json @@ -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", diff --git a/fe/public/offline.html b/fe/public/offline.html new file mode 100644 index 0000000..364bc7b --- /dev/null +++ b/fe/public/offline.html @@ -0,0 +1,45 @@ + + + + + + + Offline + + + + +
+

You are Offline

+

It seems you've lost your internet connection.

+

Please check your network settings and try again once you're back online.

+

Some previously cached content might still be available.

+
+ + + \ No newline at end of file diff --git a/fe/src/assets/main.scss b/fe/src/assets/main.scss index 454dfc0..893a981 100644 --- a/fe/src/assets/main.scss +++ b/fe/src/assets/main.scss @@ -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 \ No newline at end of file diff --git a/fe/src/components/OfflineIndicator.vue b/fe/src/components/OfflineIndicator.vue index 8d36b3f..016dc65 100644 --- a/fe/src/components/OfflineIndicator.vue +++ b/fe/src/components/OfflineIndicator.vue @@ -1,53 +1,54 @@