feat: Enhance OCR processing and UI components
All checks were successful
Deploy to Production, build images and push to Gitea Registry / build_and_push (pull_request) Successful in 1m23s
All checks were successful
Deploy to Production, build images and push to Gitea Registry / build_and_push (pull_request) Successful in 1m23s
This commit introduces significant updates to the OCR processing logic and UI components: - Updated the OCR extraction prompt in `config.py` to provide detailed instructions for handling shopping list images, improving accuracy in item identification and extraction. - Upgraded the `GEMINI_MODEL_NAME` to a newer version for enhanced OCR capabilities. - Added a new `CreateGroupModal.vue` component for creating groups, improving user interaction. - Updated `MainLayout.vue` to integrate the new group creation modal and enhance the user menu and language selector functionality. - Improved styling and structure in various components, including `ChoresPage.vue` and `GroupDetailPage.vue`, for better user experience and visual consistency. These changes aim to enhance the overall functionality and usability of the application.
This commit is contained in:
parent
dccd7bb300
commit
10845d2e5f
176
be/app/config.py
176
be/app/config.py
@ -26,18 +26,168 @@ class Settings(BaseSettings):
|
|||||||
MAX_FILE_SIZE_MB: int = 10 # Maximum allowed file size for OCR processing
|
MAX_FILE_SIZE_MB: int = 10 # Maximum allowed file size for OCR processing
|
||||||
ALLOWED_IMAGE_TYPES: list[str] = ["image/jpeg", "image/png", "image/webp"] # Supported image formats
|
ALLOWED_IMAGE_TYPES: list[str] = ["image/jpeg", "image/png", "image/webp"] # Supported image formats
|
||||||
OCR_ITEM_EXTRACTION_PROMPT: str = """
|
OCR_ITEM_EXTRACTION_PROMPT: str = """
|
||||||
Extract the shopping list items from this image.
|
**ROLE & GOAL**
|
||||||
List each distinct item on a new line.
|
|
||||||
Ignore prices, quantities, store names, discounts, taxes, totals, and other non-item text.
|
You are an expert AI assistant specializing in Optical Character Recognition (OCR) and structured data extraction. Your primary function is to act as a "Shopping List Digitizer."
|
||||||
Focus only on the names of the products or items to be purchased.
|
|
||||||
Add 2 underscores before and after the item name, if it is struck through.
|
Your goal is to meticulously analyze the provided image of a shopping list, which is likely handwritten, and convert it into a structured, machine-readable JSON format. You must be accurate, infer context where necessary, and handle the inherent ambiguities of handwriting and informal list-making.
|
||||||
If the image does not appear to be a shopping list or receipt, state that clearly.
|
|
||||||
Example output for a grocery list:
|
**INPUT**
|
||||||
Milk
|
|
||||||
Eggs
|
You will receive a single image (`[Image]`). This image contains a shopping list. It may be:
|
||||||
Bread
|
* Neatly written or very messy.
|
||||||
__Apples__
|
* On lined paper, a whiteboard, a napkin, or a dedicated notepad.
|
||||||
Organic Bananas
|
* Containing doodles, stains, or other visual noise.
|
||||||
|
* Using various formats (bullet points, numbered lists, columns, simple line breaks).
|
||||||
|
* could be in English or in German.
|
||||||
|
|
||||||
|
**CORE TASK: STEP-BY-STEP ANALYSIS**
|
||||||
|
|
||||||
|
Follow these steps precisely:
|
||||||
|
|
||||||
|
1. **Initial Image Analysis & OCR:**
|
||||||
|
* Perform an advanced OCR scan on the entire image to transcribe all visible text.
|
||||||
|
* Pay close attention to the spatial layout. Identify headings, columns, and line items. Note which text elements appear to be grouped together.
|
||||||
|
|
||||||
|
2. **Item Identification & Filtering:**
|
||||||
|
* Differentiate between actual list items and non-item elements.
|
||||||
|
* **INCLUDE:** Items intended for purchase.
|
||||||
|
* **EXCLUDE:** List titles (e.g., "GROCERIES," "Target List"), dates, doodles, unrelated notes, or stray marks. Capture the list title separately if one exists.
|
||||||
|
|
||||||
|
3. **Detailed Extraction for Each Item:**
|
||||||
|
For every single item you identify, extract the following attributes. If an attribute is not present, use `null`.
|
||||||
|
|
||||||
|
* `item_name` (string): The primary name of the product.
|
||||||
|
* **Standardize:** Normalize the name. (e.g., "B. Powder" -> "Baking Powder", "A. Juice" -> "Apple Juice").
|
||||||
|
* **Contextual Guessing:** If a word is poorly written, use the context of a shopping list to make an educated guess. (e.g., "Ciffee" is almost certainly "Coffee").
|
||||||
|
|
||||||
|
* `quantity` (number or string): The amount needed.
|
||||||
|
* If a number is present (e.g., "**2** milks"), extract the number `2`.
|
||||||
|
* If it's a word (e.g., "**a dozen** eggs"), extract the string `"a dozen"`.
|
||||||
|
* If no quantity is specified (e.g., "Bread"), infer a default quantity of `1`.
|
||||||
|
|
||||||
|
* `unit` (string): The unit of measurement or packaging.
|
||||||
|
* Examples: "kg", "lbs", "liters", "gallons", "box", "can", "bag", "bunch".
|
||||||
|
* Infer where possible (e.g., for "2 Milks," the unit could be inferred as "cartons" or "gallons" depending on regional context, but it's safer to leave it `null` if not explicitly stated).
|
||||||
|
|
||||||
|
* `notes` (string): Any additional descriptive text.
|
||||||
|
* Examples: "low-sodium," "organic," "brand name (Tide)," "for the cake," "get the ripe ones."
|
||||||
|
|
||||||
|
* `category` (string): Infer a logical category for the item.
|
||||||
|
* Use common grocery store categories: `Produce`, `Dairy & Eggs`, `Meat & Seafood`, `Pantry`, `Frozen`, `Bakery`, `Beverages`, `Household`, `Personal Care`.
|
||||||
|
* If the list itself has category headings (e.g., a "DAIRY" section), use those first.
|
||||||
|
|
||||||
|
* `original_text` (string): Provide the exact, unaltered text that your OCR transcribed for this entire line item. This is crucial for verification.
|
||||||
|
|
||||||
|
* `is_crossed_out` (boolean): Set to `true` if the item is struck through, crossed out, or clearly marked as completed. Otherwise, set to `false`.
|
||||||
|
|
||||||
|
**HANDLING AMBIGUITIES AND EDGE CASES**
|
||||||
|
|
||||||
|
* **Illegible Text:** If a line or word is completely unreadable, set `item_name` to `"UNREADABLE"` and place the garbled OCR attempt in the `original_text` field.
|
||||||
|
* **Abbreviations:** Expand common shopping list abbreviations (e.g., "OJ" -> "Orange Juice", "TP" -> "Toilet Paper", "AVOs" -> "Avocados", "G. Beef" -> "Ground Beef").
|
||||||
|
* **Implicit Items:** If a line is vague like "Snacks for kids," list it as is. Do not invent specific items.
|
||||||
|
* **Multi-item Lines:** If a line contains multiple items (e.g., "Onions, Garlic, Ginger"), split them into separate item objects.
|
||||||
|
|
||||||
|
**OUTPUT FORMAT**
|
||||||
|
|
||||||
|
Your final output MUST be a single JSON object with the following structure. Do not include any explanatory text before or after the JSON block.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"list_title": "string or null",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"item_name": "string",
|
||||||
|
"quantity": "number or string",
|
||||||
|
"unit": "string or null",
|
||||||
|
"category": "string",
|
||||||
|
"notes": "string or null",
|
||||||
|
"original_text": "string",
|
||||||
|
"is_crossed_out": "boolean"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": {
|
||||||
|
"total_items": "integer",
|
||||||
|
"unread_items": "integer",
|
||||||
|
"crossed_out_items": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**EXAMPLE WALKTHROUGH**
|
||||||
|
|
||||||
|
* **IF THE IMAGE SHOWS:** A crumpled sticky note with the title "Stuff for tonight" and the items:
|
||||||
|
* `2x Chicken Breasts`
|
||||||
|
* `~~Baguette~~` (this item is crossed out)
|
||||||
|
* `Salad mix (bag)`
|
||||||
|
* `Tomatos` (misspelled)
|
||||||
|
* `Choc Ice Cream`
|
||||||
|
|
||||||
|
* **YOUR JSON OUTPUT SHOULD BE:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"list_title": "Stuff for tonight",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"item_name": "Chicken Breasts",
|
||||||
|
"quantity": 2,
|
||||||
|
"unit": null,
|
||||||
|
"category": "Meat & Seafood",
|
||||||
|
"notes": null,
|
||||||
|
"original_text": "2x Chicken Breasts",
|
||||||
|
"is_crossed_out": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"item_name": "Baguette",
|
||||||
|
"quantity": 1,
|
||||||
|
"unit": null,
|
||||||
|
"category": "Bakery",
|
||||||
|
"notes": null,
|
||||||
|
"original_text": "Baguette",
|
||||||
|
"is_crossed_out": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"item_name": "Salad Mix",
|
||||||
|
"quantity": 1,
|
||||||
|
"unit": "bag",
|
||||||
|
"category": "Produce",
|
||||||
|
"notes": null,
|
||||||
|
"original_text": "Salad mix (bag)",
|
||||||
|
"is_crossed_out": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"item_name": "Tomatoes",
|
||||||
|
"quantity": 1,
|
||||||
|
"unit": null,
|
||||||
|
"category": "Produce",
|
||||||
|
"notes": null,
|
||||||
|
"original_text": "Tomatos",
|
||||||
|
"is_crossed_out": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"item_name": "Chocolate Ice Cream",
|
||||||
|
"quantity": 1,
|
||||||
|
"unit": null,
|
||||||
|
"category": "Frozen",
|
||||||
|
"notes": null,
|
||||||
|
"original_text": "Choc Ice Cream",
|
||||||
|
"is_crossed_out": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": {
|
||||||
|
"total_items": 5,
|
||||||
|
"unread_items": 0,
|
||||||
|
"crossed_out_items": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**FINAL INSTRUCTION**
|
||||||
|
|
||||||
|
If the image provided is not a shopping list or is completely blank/unintelligible, respond with a JSON object where the `items` array is empty and add a note in the `list_title` field, such as "Image does not appear to be a shopping list."
|
||||||
|
|
||||||
|
Now, analyze the provided image and generate the JSON output.
|
||||||
"""
|
"""
|
||||||
# --- OCR Error Messages ---
|
# --- OCR Error Messages ---
|
||||||
OCR_SERVICE_UNAVAILABLE: str = "OCR service is currently unavailable. Please try again later."
|
OCR_SERVICE_UNAVAILABLE: str = "OCR service is currently unavailable. Please try again later."
|
||||||
@ -49,7 +199,7 @@ Organic Bananas
|
|||||||
OCR_PROCESSING_ERROR: str = "Error processing image: {detail}"
|
OCR_PROCESSING_ERROR: str = "Error processing image: {detail}"
|
||||||
|
|
||||||
# --- Gemini AI Settings ---
|
# --- Gemini AI Settings ---
|
||||||
GEMINI_MODEL_NAME: str = "gemini-2.0-flash" # The model to use for OCR
|
GEMINI_MODEL_NAME: str = "gemini-2.5-flash-preview-05-20" # The model to use for OCR
|
||||||
GEMINI_SAFETY_SETTINGS: dict = {
|
GEMINI_SAFETY_SETTINGS: dict = {
|
||||||
"HARM_CATEGORY_HATE_SPEECH": "BLOCK_MEDIUM_AND_ABOVE",
|
"HARM_CATEGORY_HATE_SPEECH": "BLOCK_MEDIUM_AND_ABOVE",
|
||||||
"HARM_CATEGORY_DANGEROUS_CONTENT": "BLOCK_MEDIUM_AND_ABOVE",
|
"HARM_CATEGORY_DANGEROUS_CONTENT": "BLOCK_MEDIUM_AND_ABOVE",
|
||||||
|
21
fe/package-lock.json
generated
21
fe/package-lock.json
generated
@ -15,6 +15,7 @@
|
|||||||
"@vueuse/core": "^13.1.0",
|
"@vueuse/core": "^13.1.0",
|
||||||
"axios": "^1.9.0",
|
"axios": "^1.9.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"framer-motion": "^12.16.0",
|
||||||
"motion": "^12.15.0",
|
"motion": "^12.15.0",
|
||||||
"pinia": "^3.0.2",
|
"pinia": "^3.0.2",
|
||||||
"qs": "^6.14.0",
|
"qs": "^6.14.0",
|
||||||
@ -7499,12 +7500,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/framer-motion": {
|
"node_modules/framer-motion": {
|
||||||
"version": "12.15.0",
|
"version": "12.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.16.0.tgz",
|
||||||
"integrity": "sha512-XKg/LnKExdLGugZrDILV7jZjI599785lDIJZLxMiiIFidCsy0a4R2ZEf+Izm67zyOuJgQYTHOmodi7igQsw3vg==",
|
"integrity": "sha512-xryrmD4jSBQrS2IkMdcTmiS4aSKckbS7kLDCuhUn9110SQKG1w3zlq1RTqCblewg+ZYe+m3sdtzQA6cRwo5g8Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"motion-dom": "^12.15.0",
|
"motion-dom": "^12.16.0",
|
||||||
"motion-utils": "^12.12.1",
|
"motion-utils": "^12.12.1",
|
||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
},
|
},
|
||||||
@ -9303,9 +9304,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/motion-dom": {
|
"node_modules/motion-dom": {
|
||||||
"version": "12.15.0",
|
"version": "12.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.16.0.tgz",
|
||||||
"integrity": "sha512-D2ldJgor+2vdcrDtKJw48k3OddXiZN1dDLLWrS8kiHzQdYVruh0IoTwbJBslrnTXIPgFED7PBN2Zbwl7rNqnhA==",
|
"integrity": "sha512-Z2nGwWrrdH4egLEtgYMCEN4V2qQt1qxlKy/uV7w691ztyA41Q5Rbn0KNGbsNVDZr9E8PD2IOQ3hSccRnB6xWzw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"motion-utils": "^12.12.1"
|
"motion-utils": "^12.12.1"
|
||||||
@ -11740,9 +11741,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.13",
|
"version": "0.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
|
||||||
"integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==",
|
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
@ -26,6 +26,7 @@
|
|||||||
"@vueuse/core": "^13.1.0",
|
"@vueuse/core": "^13.1.0",
|
||||||
"axios": "^1.9.0",
|
"axios": "^1.9.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"framer-motion": "^12.16.0",
|
||||||
"motion": "^12.15.0",
|
"motion": "^12.15.0",
|
||||||
"pinia": "^3.0.2",
|
"pinia": "^3.0.2",
|
||||||
"qs": "^6.14.0",
|
"qs": "^6.14.0",
|
||||||
|
102
fe/src/components/CreateGroupModal.vue
Normal file
102
fe/src/components/CreateGroupModal.vue
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
<template>
|
||||||
|
<VModal :model-value="isOpen" @update:model-value="closeModal" title="Create New Group">
|
||||||
|
<template #default>
|
||||||
|
<form @submit.prevent="onSubmit">
|
||||||
|
<VFormField label="Group Name" :error-message="formError ?? undefined">
|
||||||
|
<VInput type="text" v-model="groupName" required ref="groupNameInput" />
|
||||||
|
</VFormField>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<VButton variant="neutral" @click="closeModal" type="button">Cancel</VButton>
|
||||||
|
<VButton type="submit" variant="primary" :disabled="loading" @click="onSubmit" class="ml-2">
|
||||||
|
<VSpinner v-if="loading" size="sm" />
|
||||||
|
Create
|
||||||
|
</VButton>
|
||||||
|
</template>
|
||||||
|
</VModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, nextTick } from 'vue';
|
||||||
|
import { useVModel } from '@vueuse/core';
|
||||||
|
import { apiClient, API_ENDPOINTS } from '@/config/api';
|
||||||
|
import { useNotificationStore } from '@/stores/notifications';
|
||||||
|
import VModal from '@/components/valerie/VModal.vue';
|
||||||
|
import VFormField from '@/components/valerie/VFormField.vue';
|
||||||
|
import VInput from '@/components/valerie/VInput.vue';
|
||||||
|
import VButton from '@/components/valerie/VButton.vue';
|
||||||
|
import VSpinner from '@/components/valerie/VSpinner.vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: boolean): void;
|
||||||
|
(e: 'created', newGroup: any): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const isOpen = useVModel(props, 'modelValue', emit);
|
||||||
|
const groupName = ref('');
|
||||||
|
const loading = ref(false);
|
||||||
|
const formError = ref<string | null>(null);
|
||||||
|
const notificationStore = useNotificationStore();
|
||||||
|
const groupNameInput = ref<InstanceType<typeof VInput> | null>(null);
|
||||||
|
|
||||||
|
watch(isOpen, (newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
groupName.value = '';
|
||||||
|
formError.value = null;
|
||||||
|
nextTick(() => {
|
||||||
|
// groupNameInput.value?.focus?.();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
isOpen.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateForm = () => {
|
||||||
|
formError.value = null;
|
||||||
|
if (!groupName.value.trim()) {
|
||||||
|
formError.value = 'Name is required';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async () => {
|
||||||
|
if (!validateForm()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const payload = { name: groupName.value };
|
||||||
|
const response = await apiClient.post(API_ENDPOINTS.GROUPS.BASE, payload);
|
||||||
|
notificationStore.addNotification({ message: 'Group created successfully', type: 'success' });
|
||||||
|
emit('created', response.data);
|
||||||
|
closeModal();
|
||||||
|
} catch (error: any) {
|
||||||
|
const message = error?.response?.data?.detail || (error instanceof Error ? error.message : 'Failed to create group');
|
||||||
|
formError.value = message;
|
||||||
|
notificationStore.addNotification({ message, type: 'error' });
|
||||||
|
console.error(message, error);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.form-error-text {
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ml-2 {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
@ -1,45 +1,75 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="main-layout">
|
<div class="main-layout">
|
||||||
|
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<div class="toolbar-title">mitlist</div>
|
<div class="toolbar-title">mitlist</div>
|
||||||
|
|
||||||
|
<!-- Group all authenticated controls for cleaner conditional rendering -->
|
||||||
|
<div v-if="authStore.isAuthenticated" class="header-controls">
|
||||||
|
<!-- Add Menu -->
|
||||||
|
<div class="control-item">
|
||||||
|
<button ref="addMenuTrigger" class="icon-button" :class="{ 'is-active': addMenuOpen }"
|
||||||
|
:aria-expanded="addMenuOpen" aria-controls="add-menu-dropdown" aria-label="Add new list or group"
|
||||||
|
@click="toggleAddMenu">
|
||||||
|
<span class="material-icons">add_circle_outline</span>
|
||||||
|
</button>
|
||||||
|
<Transition name="dropdown-fade">
|
||||||
|
<div v-if="addMenuOpen" id="add-menu-dropdown" ref="addMenuDropdown" class="dropdown-menu add-dropdown"
|
||||||
|
role="menu">
|
||||||
|
<div class="dropdown-header">{{ $t('addSelector.title') }}</div>
|
||||||
|
<a href="#" role="menuitem" @click.prevent="handleAddList">{{ $t('addSelector.addList') }}</a>
|
||||||
|
<a href="#" role="menuitem" @click.prevent="handleAddGroup">{{ $t('addSelector.addGroup') }}</a>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Language Menu -->
|
||||||
<div class="flex align-end">
|
<div class="control-item">
|
||||||
<div class="language-selector" v-if="authStore.isAuthenticated">
|
<button ref="languageMenuTrigger" class="icon-button language-button"
|
||||||
<button @click="toggleLanguageMenu" class="language-menu-button">
|
:class="{ 'is-active': languageMenuOpen }" :aria-expanded="languageMenuOpen"
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" height="20px" viewBox="0 0 24 24" width="20px" fill="#ff7b54">
|
aria-controls="language-menu-dropdown"
|
||||||
<path d="M0 0h24v24H0z" fill="none" />
|
:aria-label="`Change language, current: ${currentLanguageCode.toUpperCase()}`" @click="toggleLanguageMenu">
|
||||||
<path
|
<span class="material-icons-outlined">translate</span>
|
||||||
d="m12.87 15.07-2.54-2.51.03-.03c1.74-1.94 2.98-4.17 3.71-6.53H17V4h-7V2H8v2H1v1.99h11.17C11.5 7.92 10.44 9.75 9 11.35 8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5 3.11 3.11.76-2.04zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2l-4.5-12zm-2.62 7l1.62-4.33L19.12 17h-3.24z" />
|
|
||||||
</svg>
|
|
||||||
<span class="current-language">{{ currentLanguageCode.toUpperCase() }}</span>
|
<span class="current-language">{{ currentLanguageCode.toUpperCase() }}</span>
|
||||||
</button>
|
</button>
|
||||||
<div v-if="languageMenuOpen" class="dropdown-menu language-dropdown" ref="languageMenuDropdown">
|
<Transition name="dropdown-fade">
|
||||||
|
<div v-if="languageMenuOpen" id="language-menu-dropdown" ref="languageMenuDropdown"
|
||||||
|
class="dropdown-menu language-dropdown" role="menu">
|
||||||
<div class="dropdown-header">{{ $t('languageSelector.title') }}</div>
|
<div class="dropdown-header">{{ $t('languageSelector.title') }}</div>
|
||||||
<a v-for="(name, code) in availableLanguages" :key="code" href="#" @click.prevent="changeLanguage(code)"
|
<a v-for="(name, code) in availableLanguages" :key="code" href="#" role="menuitem" class="language-option"
|
||||||
class="language-option" :class="{ 'active': currentLanguageCode === code }">
|
:class="{ 'active': currentLanguageCode === code }" @click.prevent="changeLanguage(code)">
|
||||||
{{ name }}
|
{{ name }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="user-menu" v-if="authStore.isAuthenticated">
|
<!-- User Menu -->
|
||||||
<button @click="toggleUserMenu" class="user-menu-button">
|
<div class="control-item">
|
||||||
<!-- Placeholder for user icon -->
|
<button ref="userMenuTrigger" class="icon-button user-menu-button" :class="{ 'is-active': userMenuOpen }"
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#ff7b54">
|
:aria-expanded="userMenuOpen" aria-controls="user-menu-dropdown" aria-label="User menu"
|
||||||
<path d="M0 0h24v24H0z" fill="none" />
|
@click="toggleUserMenu">
|
||||||
<path
|
<!-- Show user avatar if available, otherwise a fallback icon -->
|
||||||
d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
|
<img v-if="authStore.user?.avatarUrl" :src="authStore.user.avatarUrl" alt="User Avatar"
|
||||||
</svg>
|
class="user-avatar" />
|
||||||
|
<span v-else class="material-icons">account_circle</span>
|
||||||
</button>
|
</button>
|
||||||
<div v-if="userMenuOpen" class="dropdown-menu" ref="userMenuDropdown">
|
<Transition name="dropdown-fade">
|
||||||
<a href="#" @click.prevent="handleLogout">Logout</a>
|
<div v-if="userMenuOpen" id="user-menu-dropdown" ref="userMenuDropdown" class="dropdown-menu" role="menu">
|
||||||
|
<div v-if="authStore.user" class="dropdown-user-info">
|
||||||
|
<strong>{{ authStore.user.name }}</strong>
|
||||||
|
<small>{{ authStore.user.email }}</small>
|
||||||
</div>
|
</div>
|
||||||
|
<a href="#" role="menuitem" @click.prevent="handleLogout">Logout</a>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- =================================================================
|
||||||
|
MAIN CONTENT
|
||||||
|
================================================================== -->
|
||||||
<main class="page-container">
|
<main class="page-container">
|
||||||
<router-view v-slot="{ Component, route }">
|
<router-view v-slot="{ Component, route }">
|
||||||
<keep-alive v-if="route.meta.keepAlive">
|
<keep-alive v-if="route.meta.keepAlive">
|
||||||
@ -51,44 +81,50 @@
|
|||||||
|
|
||||||
<OfflineIndicator />
|
<OfflineIndicator />
|
||||||
|
|
||||||
|
<!-- =================================================================
|
||||||
|
FOOTER NAVIGATION
|
||||||
|
Improved with more semantic router-links and better active state styling.
|
||||||
|
================================================================== -->
|
||||||
<footer class="app-footer">
|
<footer class="app-footer">
|
||||||
<nav class="tabs">
|
<nav class="tabs">
|
||||||
<router-link to="/lists" class="tab-item" active-class="active">
|
<router-link to="/lists" class="tab-item" active-class="active">
|
||||||
<span class="material-icons">list</span>
|
<span class="material-icons">list</span>
|
||||||
<span class="tab-text">Lists</span>
|
<span class="tab-text">Lists</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
<a @click.prevent="navigateToGroups" href="/groups" class="tab-item"
|
<!-- Use a RouterLink for semantics and a11y, but keep custom click handler -->
|
||||||
:class="{ 'active': $route.path.startsWith('/groups') }">
|
<router-link to="/groups" class="tab-item" :class="{ 'active': $route.path.startsWith('/groups') }"
|
||||||
|
@click.prevent="navigateToGroups">
|
||||||
<span class="material-icons">group</span>
|
<span class="material-icons">group</span>
|
||||||
<span class="tab-text">Groups</span>
|
<span class="tab-text">Groups</span>
|
||||||
</a>
|
</router-link>
|
||||||
<router-link to="/chores" class="tab-item" active-class="active">
|
<router-link to="/chores" class="tab-item" active-class="active">
|
||||||
<span class="material-icons">person_pin_circle</span>
|
<span class="material-icons">task_alt</span> <!-- More appropriate icon for chores -->
|
||||||
<span class="tab-text">Chores</span>
|
<span class="tab-text">Chores</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
<!-- <router-link to="/account" class="tab-item" active-class="active">
|
|
||||||
<span class="material-icons">person</span>
|
|
||||||
<span class="tab-text">Account</span>
|
|
||||||
</router-link> -->
|
|
||||||
</nav>
|
</nav>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<!-- =================================================================
|
||||||
|
MODALS
|
||||||
|
================================================================== -->
|
||||||
|
<CreateListModal v-model="showCreateListModal" @created="handleListCreated" />
|
||||||
|
<CreateGroupModal v-model="showCreateGroupModal" @created="handleGroupCreated" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, defineComponent, onMounted, computed } from 'vue';
|
import { ref, computed, onMounted } from 'vue';
|
||||||
import { useRouter, useRoute } from 'vue-router';
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import OfflineIndicator from '@/components/OfflineIndicator.vue';
|
import OfflineIndicator from '@/components/OfflineIndicator.vue';
|
||||||
import { onClickOutside } from '@vueuse/core';
|
|
||||||
import { useNotificationStore } from '@/stores/notifications';
|
import { useNotificationStore } from '@/stores/notifications';
|
||||||
import { useGroupStore } from '@/stores/groupStore';
|
import { useGroupStore } from '@/stores/groupStore';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import CreateListModal from '@/components/CreateListModal.vue';
|
||||||
|
import CreateGroupModal from '@/components/CreateGroupModal.vue';
|
||||||
|
import { onClickOutside } from '@vueuse/core';
|
||||||
|
|
||||||
defineComponent({
|
// Store and Router setup
|
||||||
name: 'MainLayout'
|
|
||||||
});
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
@ -96,48 +132,30 @@ const notificationStore = useNotificationStore();
|
|||||||
const groupStore = useGroupStore();
|
const groupStore = useGroupStore();
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
|
|
||||||
// Add initialization logic
|
// --- Dropdown Logic (Re-integrated from composable) ---
|
||||||
const initializeApp = async () => {
|
|
||||||
if (authStore.isAuthenticated) {
|
|
||||||
try {
|
|
||||||
await authStore.fetchCurrentUser();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to initialize app:', error);
|
|
||||||
// Don't automatically logout - let the API interceptor handle token refresh
|
|
||||||
// The response interceptor will handle 401s and refresh tokens automatically
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Call initialization when component is mounted
|
// 1. Add Menu Dropdown
|
||||||
onMounted(() => {
|
const addMenuOpen = ref(false);
|
||||||
initializeApp();
|
const addMenuDropdown = ref<HTMLElement | null>(null);
|
||||||
if (authStore.isAuthenticated) {
|
const addMenuTrigger = ref<HTMLElement | null>(null);
|
||||||
groupStore.fetchGroups();
|
const toggleAddMenu = () => { addMenuOpen.value = !addMenuOpen.value; };
|
||||||
}
|
onClickOutside(addMenuDropdown, () => { addMenuOpen.value = false; }, { ignore: [addMenuTrigger] });
|
||||||
|
|
||||||
// Load saved language from localStorage
|
// 2. Language Menu Dropdown
|
||||||
const savedLanguage = localStorage.getItem('language');
|
|
||||||
if (savedLanguage && ['en', 'de', 'nl', 'fr', 'es'].includes(savedLanguage)) {
|
|
||||||
locale.value = savedLanguage;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const userMenuOpen = ref(false);
|
|
||||||
const userMenuDropdown = ref<HTMLElement | null>(null);
|
|
||||||
|
|
||||||
const toggleUserMenu = () => {
|
|
||||||
userMenuOpen.value = !userMenuOpen.value;
|
|
||||||
};
|
|
||||||
|
|
||||||
onClickOutside(userMenuDropdown, () => {
|
|
||||||
userMenuOpen.value = false;
|
|
||||||
}, { ignore: ['.user-menu-button'] });
|
|
||||||
|
|
||||||
// Language selector state and functions
|
|
||||||
const languageMenuOpen = ref(false);
|
const languageMenuOpen = ref(false);
|
||||||
const languageMenuDropdown = ref<HTMLElement | null>(null);
|
const languageMenuDropdown = ref<HTMLElement | null>(null);
|
||||||
|
const languageMenuTrigger = ref<HTMLElement | null>(null);
|
||||||
|
const toggleLanguageMenu = () => { languageMenuOpen.value = !languageMenuOpen.value; };
|
||||||
|
onClickOutside(languageMenuDropdown, () => { languageMenuOpen.value = false; }, { ignore: [languageMenuTrigger] });
|
||||||
|
|
||||||
|
// 3. User Menu Dropdown
|
||||||
|
const userMenuOpen = ref(false);
|
||||||
|
const userMenuDropdown = ref<HTMLElement | null>(null);
|
||||||
|
const userMenuTrigger = ref<HTMLElement | null>(null);
|
||||||
|
const toggleUserMenu = () => { userMenuOpen.value = !userMenuOpen.value; };
|
||||||
|
onClickOutside(userMenuDropdown, () => { userMenuOpen.value = false; }, { ignore: [userMenuTrigger] });
|
||||||
|
|
||||||
|
// --- Language Selector Logic ---
|
||||||
const availableLanguages = computed(() => ({
|
const availableLanguages = computed(() => ({
|
||||||
en: t('languageSelector.languages.en'),
|
en: t('languageSelector.languages.en'),
|
||||||
de: t('languageSelector.languages.de'),
|
de: t('languageSelector.languages.de'),
|
||||||
@ -145,64 +163,96 @@ const availableLanguages = computed(() => ({
|
|||||||
fr: t('languageSelector.languages.fr'),
|
fr: t('languageSelector.languages.fr'),
|
||||||
es: t('languageSelector.languages.es')
|
es: t('languageSelector.languages.es')
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const currentLanguageCode = computed(() => locale.value);
|
const currentLanguageCode = computed(() => locale.value);
|
||||||
|
|
||||||
const toggleLanguageMenu = () => {
|
|
||||||
languageMenuOpen.value = !languageMenuOpen.value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const changeLanguage = (languageCode: string) => {
|
const changeLanguage = (languageCode: string) => {
|
||||||
locale.value = languageCode;
|
locale.value = languageCode;
|
||||||
localStorage.setItem('language', languageCode);
|
localStorage.setItem('language', languageCode);
|
||||||
languageMenuOpen.value = false;
|
languageMenuOpen.value = false; // Close menu on selection
|
||||||
notificationStore.addNotification({
|
notificationStore.addNotification({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
message: `Language changed to ${availableLanguages.value[languageCode as keyof typeof availableLanguages.value]}`,
|
message: `Language changed to ${availableLanguages.value[languageCode as keyof typeof availableLanguages.value]}`,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
onClickOutside(languageMenuDropdown, () => {
|
// --- Modal Handling ---
|
||||||
languageMenuOpen.value = false;
|
const showCreateListModal = ref(false);
|
||||||
}, { ignore: ['.language-menu-button'] });
|
const showCreateGroupModal = ref(false);
|
||||||
|
|
||||||
|
const handleAddList = () => {
|
||||||
|
addMenuOpen.value = false; // Close menu
|
||||||
|
showCreateListModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddGroup = () => {
|
||||||
|
addMenuOpen.value = false; // Close menu
|
||||||
|
showCreateGroupModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleListCreated = (newList: any) => {
|
||||||
|
notificationStore.addNotification({ message: `List '${newList.name}' created successfully`, type: 'success' });
|
||||||
|
showCreateListModal.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGroupCreated = (newGroup: any) => {
|
||||||
|
notificationStore.addNotification({ message: `Group '${newGroup.name}' created successfully`, type: 'success' });
|
||||||
|
showCreateGroupModal.value = false;
|
||||||
|
groupStore.fetchGroups(); // Refresh groups after creation
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- User and Navigation Logic ---
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
try {
|
try {
|
||||||
authStore.logout(); // Pinia action
|
userMenuOpen.value = false; // Close menu
|
||||||
notificationStore.addNotification({
|
authStore.logout();
|
||||||
type: 'success',
|
notificationStore.addNotification({ type: 'success', message: 'Logged out successfully' });
|
||||||
message: 'Logged out successfully',
|
await router.push('/auth/login');
|
||||||
});
|
|
||||||
await router.push('/auth/login'); // Adjusted path
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
notificationStore.addNotification({
|
notificationStore.addNotification({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
message: error instanceof Error ? error.message : 'Logout failed',
|
message: error instanceof Error ? error.message : 'Logout failed',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
userMenuOpen.value = false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const navigateToGroups = () => {
|
const navigateToGroups = () => {
|
||||||
// The groups should have been fetched on mount, but we can check isLoading
|
if (groupStore.isLoading) return;
|
||||||
if (groupStore.isLoading) {
|
|
||||||
// Maybe show a toast or do nothing
|
|
||||||
console.log('Groups are still loading...');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (groupStore.groupCount === 1 && groupStore.firstGroupId) {
|
if (groupStore.groupCount === 1 && groupStore.firstGroupId) {
|
||||||
router.push(`/groups/${groupStore.firstGroupId}`);
|
router.push(`/groups/${groupStore.firstGroupId}`);
|
||||||
} else {
|
} else {
|
||||||
router.push('/groups');
|
router.push('/groups');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- App Initialization ---
|
||||||
|
onMounted(async () => {
|
||||||
|
// Fetch essential data for authenticated users
|
||||||
|
if (authStore.isAuthenticated) {
|
||||||
|
try {
|
||||||
|
await authStore.fetchCurrentUser();
|
||||||
|
await groupStore.fetchGroups();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize app data:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load saved language preference
|
||||||
|
const savedLanguage = localStorage.getItem('language');
|
||||||
|
if (savedLanguage && Object.keys(availableLanguages.value).includes(savedLanguage)) {
|
||||||
|
locale.value = savedLanguage;
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
// Using Google's outlined icons for a lighter feel
|
||||||
|
@import url('https://fonts.googleapis.com/icon?family=Material+Icons|Material+Icons+Outlined');
|
||||||
|
|
||||||
.main-layout {
|
.main-layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
background-color: #f9f9f9; // A slightly off-white background for the main page
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-header {
|
.app-header {
|
||||||
@ -212,7 +262,7 @@ const navigateToGroups = () => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
@ -225,24 +275,47 @@ const navigateToGroups = () => {
|
|||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.language-selector {
|
.header-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-item {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.language-menu-button {
|
.icon-button {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: 1px solid transparent; // Prevents layout shift on hover
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
border-radius: 8px;
|
border-radius: 50%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.25rem;
|
justify-content: center;
|
||||||
|
transition: background-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: rgba(255, 123, 84, 0.1);
|
background-color: rgba(255, 123, 84, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.is-active {
|
||||||
|
background-color: rgba(255, 123, 84, 0.15);
|
||||||
|
box-shadow: 0 0 0 2px rgba(255, 123, 84, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-icons,
|
||||||
|
.material-icons-outlined {
|
||||||
|
font-size: 26px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-button {
|
||||||
|
border-radius: 20px;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.current-language {
|
.current-language {
|
||||||
@ -251,8 +324,43 @@ const navigateToGroups = () => {
|
|||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.language-dropdown {
|
.user-menu-button {
|
||||||
|
padding: 0; // Remove padding if image is used
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: calc(100% + 8px); // A bit more space
|
||||||
|
background-color: white;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
min-width: 180px;
|
min-width: 180px;
|
||||||
|
z-index: 101;
|
||||||
|
overflow: hidden; // To respect child border-radius
|
||||||
|
|
||||||
|
a {
|
||||||
|
display: block;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
color: var(--text-color);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.dropdown-header {
|
.dropdown-header {
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
@ -265,71 +373,43 @@ const navigateToGroups = () => {
|
|||||||
border-bottom: 1px solid #e0e0e0;
|
border-bottom: 1px solid #e0e0e0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.language-option {
|
.dropdown-user-info {
|
||||||
display: block;
|
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
color: var(--text-color);
|
border-bottom: 1px solid #e0e0e0;
|
||||||
text-decoration: none;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
&:hover {
|
strong {
|
||||||
background-color: #f5f5f5;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
small {
|
||||||
|
font-size: 0.8em;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-option.active {
|
||||||
background-color: rgba(255, 123, 84, 0.1);
|
background-color: rgba(255, 123, 84, 0.1);
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
/* Dropdown transition */
|
||||||
|
.dropdown-fade-enter-active,
|
||||||
|
.dropdown-fade-leave-active {
|
||||||
|
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-menu {
|
.dropdown-fade-enter-from,
|
||||||
position: absolute;
|
.dropdown-fade-leave-to {
|
||||||
right: 0;
|
opacity: 0;
|
||||||
top: calc(100% + 5px);
|
transform: translateY(-10px);
|
||||||
color: var(--primary);
|
|
||||||
background-color: #f3f3f3;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 4px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
|
||||||
min-width: 150px;
|
|
||||||
z-index: 101;
|
|
||||||
|
|
||||||
a {
|
|
||||||
display: block;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
color: var(--text-color);
|
|
||||||
text-decoration: none;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-menu {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-menu-button {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--primary);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0.5rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-container {
|
.page-container {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
padding-bottom: calc(var(--footer-height) + 1rem); // Space for fixed footer
|
padding-bottom: calc(var(--footer-height) + 1rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-footer {
|
.app-footer {
|
||||||
@ -341,6 +421,7 @@ const navigateToGroups = () => {
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
|
box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabs {
|
.tabs {
|
||||||
@ -354,19 +435,43 @@ const navigateToGroups = () => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: var(--text-color);
|
color: #757575; // Softer default color
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 0.8rem;
|
|
||||||
padding: 0.5rem 0;
|
padding: 0.5rem 0;
|
||||||
border-bottom: 2px solid transparent;
|
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
transition: background-color 0.2s ease, color 0.2s ease;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 0;
|
||||||
|
height: 3px;
|
||||||
|
background-color: var(--primary);
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: var(--primary);
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
width: 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(.active) {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
.material-icons {
|
.material-icons {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-text {
|
.tab-text {
|
||||||
display: none;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
@ -374,17 +479,8 @@ const navigateToGroups = () => {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
||||||
.tab-text {
|
.tab-text {
|
||||||
display: inline;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
|
||||||
color: var(--primary);
|
|
||||||
border-bottom-color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: #f0f0f0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
@ -5,36 +5,20 @@ import { BrowserTracing } from '@sentry/tracing'
|
|||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
import { createI18n } from 'vue-i18n'
|
import { createI18n } from 'vue-i18n'
|
||||||
import enMessages from './i18n/en.json' // Import en.json directly
|
import enMessages from './i18n/en.json'
|
||||||
import deMessages from './i18n/de.json'
|
import deMessages from './i18n/de.json'
|
||||||
import frMessages from './i18n/fr.json'
|
import frMessages from './i18n/fr.json'
|
||||||
import esMessages from './i18n/es.json'
|
import esMessages from './i18n/es.json'
|
||||||
import nlMessages from './i18n/nl.json'
|
import nlMessages from './i18n/nl.json'
|
||||||
|
|
||||||
// Global styles
|
|
||||||
import './assets/main.scss'
|
import './assets/main.scss'
|
||||||
|
import { api, globalAxios } from '@/services/api'
|
||||||
// API client (from your axios boot file)
|
|
||||||
import { api, globalAxios } from '@/services/api' // Renamed from boot/axios to services/api
|
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
// Vue I18n setup (from your i18n boot file)
|
|
||||||
// // export type MessageLanguages = keyof typeof messages;
|
|
||||||
// // export type MessageSchema = (typeof messages)['en-US'];
|
|
||||||
// // export type MessageLanguages = keyof typeof messages;
|
|
||||||
// // export type MessageSchema = (typeof messages)['en-US'];
|
|
||||||
|
|
||||||
// // declare module 'vue-i18n' {
|
|
||||||
// // export interface DefineLocaleMessage extends MessageSchema {}
|
|
||||||
// // // eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
||||||
// // export interface DefineDateTimeFormat {}
|
|
||||||
// // // eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
||||||
// // export interface DefineNumberFormat {}
|
|
||||||
// // }
|
|
||||||
const i18n = createI18n({
|
const i18n = createI18n({
|
||||||
legacy: false, // Recommended for Vue 3
|
legacy: false,
|
||||||
locale: 'en', // Default locale
|
locale: 'en',
|
||||||
fallbackLocale: 'en', // Fallback locale
|
fallbackLocale: 'en',
|
||||||
messages: {
|
messages: {
|
||||||
en: enMessages,
|
en: enMessages,
|
||||||
de: deMessages,
|
de: deMessages,
|
||||||
@ -48,7 +32,6 @@ const app = createApp(App)
|
|||||||
const pinia = createPinia()
|
const pinia = createPinia()
|
||||||
app.use(pinia)
|
app.use(pinia)
|
||||||
|
|
||||||
// Initialize Sentry
|
|
||||||
Sentry.init({
|
Sentry.init({
|
||||||
app,
|
app,
|
||||||
dsn: import.meta.env.VITE_SENTRY_DSN,
|
dsn: import.meta.env.VITE_SENTRY_DSN,
|
||||||
@ -58,27 +41,21 @@ Sentry.init({
|
|||||||
tracingOrigins: ['localhost', /^\//],
|
tracingOrigins: ['localhost', /^\//],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
// Set tracesSampleRate to 1.0 to capture 100% of transactions for performance monitoring.
|
|
||||||
// We recommend adjusting this value in production
|
|
||||||
tracesSampleRate: 1.0,
|
tracesSampleRate: 1.0,
|
||||||
// Set environment
|
|
||||||
environment: import.meta.env.MODE,
|
environment: import.meta.env.MODE,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Initialize auth state before mounting the app
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
if (authStore.accessToken) {
|
if (authStore.accessToken) {
|
||||||
authStore.fetchCurrentUser().catch((error) => {
|
authStore.fetchCurrentUser().catch((error) => {
|
||||||
console.error('Failed to initialize current user state:', error)
|
console.error('Failed to initialize current user state:', error)
|
||||||
// The fetchCurrentUser action handles token clearing on failure.
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
app.use(router)
|
app.use(router)
|
||||||
app.use(i18n)
|
app.use(i18n)
|
||||||
|
|
||||||
// Make API instance globally available (optional, prefer provide/inject or store)
|
|
||||||
app.config.globalProperties.$api = api
|
app.config.globalProperties.$api = api
|
||||||
app.config.globalProperties.$axios = globalAxios // The original axios instance if needed
|
app.config.globalProperties.$axios = globalAxios
|
||||||
|
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
@ -10,6 +10,8 @@ import { useStorage } from '@vueuse/core'
|
|||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const props = defineProps<{ groupId?: number | string }>();
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
interface ChoreWithCompletion extends Chore {
|
interface ChoreWithCompletion extends Chore {
|
||||||
current_assignment_id: number | null;
|
current_assignment_id: number | null;
|
||||||
@ -162,10 +164,19 @@ const getChoreSubtext = (chore: ChoreWithCompletion): string => {
|
|||||||
return parts.join(' · ');
|
return parts.join(' · ');
|
||||||
};
|
};
|
||||||
|
|
||||||
const groupedChores = computed(() => {
|
const filteredChores = computed(() => {
|
||||||
if (!chores.value) return []
|
if (props.groupId) {
|
||||||
|
return chores.value.filter(
|
||||||
|
c => c.type === 'group' && String(c.group_id) === String(props.groupId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return chores.value;
|
||||||
|
});
|
||||||
|
|
||||||
const choresByDate = chores.value.reduce((acc, chore) => {
|
const groupedChores = computed(() => {
|
||||||
|
if (!filteredChores.value) return []
|
||||||
|
|
||||||
|
const choresByDate = filteredChores.value.reduce((acc, chore) => {
|
||||||
const dueDate = format(startOfDay(new Date(chore.next_due_date)), 'yyyy-MM-dd')
|
const dueDate = format(startOfDay(new Date(chore.next_due_date)), 'yyyy-MM-dd')
|
||||||
if (!acc[dueDate]) {
|
if (!acc[dueDate]) {
|
||||||
acc[dueDate] = []
|
acc[dueDate] = []
|
||||||
@ -177,7 +188,6 @@ const groupedChores = computed(() => {
|
|||||||
return Object.keys(choresByDate)
|
return Object.keys(choresByDate)
|
||||||
.sort((a, b) => new Date(a).getTime() - new Date(b).getTime())
|
.sort((a, b) => new Date(a).getTime() - new Date(b).getTime())
|
||||||
.map(dateStr => {
|
.map(dateStr => {
|
||||||
// Create a new Date object and ensure it's interpreted as local time, not UTC
|
|
||||||
const dateParts = dateStr.split('-').map(Number);
|
const dateParts = dateStr.split('-').map(Number);
|
||||||
const date = new Date(dateParts[0], dateParts[1] - 1, dateParts[2]);
|
const date = new Date(dateParts[0], dateParts[1] - 1, dateParts[2]);
|
||||||
return {
|
return {
|
||||||
@ -190,7 +200,7 @@ const groupedChores = computed(() => {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
const formatDateHeader = (date: Date) => {
|
const formatDateHeader = (date: Date) => {
|
||||||
const today = startOfDay(new Date())
|
const today = startOfDay(new Date())
|
||||||
@ -210,6 +220,10 @@ const resetChoreForm = () => {
|
|||||||
|
|
||||||
const openCreateChoreModal = () => {
|
const openCreateChoreModal = () => {
|
||||||
resetChoreForm()
|
resetChoreForm()
|
||||||
|
if (props.groupId) {
|
||||||
|
choreForm.value.type = 'group';
|
||||||
|
choreForm.value.group_id = typeof props.groupId === 'string' ? parseInt(props.groupId) : props.groupId;
|
||||||
|
}
|
||||||
showChoreModal.value = true
|
showChoreModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -402,7 +416,7 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<header class="flex justify-between items-center">
|
<header v-if="!props.groupId" class="flex justify-between items-center">
|
||||||
<h1 style="margin-block-start: 0;">{{ t('choresPage.title') }}</h1>
|
<h1 style="margin-block-start: 0;">{{ t('choresPage.title') }}</h1>
|
||||||
<button class="btn btn-primary" @click="openCreateChoreModal">
|
<button class="btn btn-primary" @click="openCreateChoreModal">
|
||||||
{{ t('choresPage.addChore', '+') }}
|
{{ t('choresPage.addChore', '+') }}
|
||||||
|
@ -71,104 +71,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="neo-section-container">
|
<div class="neo-section-cntainer">
|
||||||
<!-- Lists Section -->
|
<!-- Lists Section -->
|
||||||
<div class="neo-section">
|
<div class="neo-section">
|
||||||
<VHeading :level="3" class="neo-section-header">{{ t('groupDetailPage.lists.title') }}</VHeading>
|
<ChoresPage :group-id="groupId" />
|
||||||
<ListsPage :group-id="groupId" />
|
<ListsPage :group-id="groupId" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Chores Section -->
|
|
||||||
<div class="mt-4 neo-section">
|
|
||||||
<div class="flex justify-between items-center w-full mb-2">
|
|
||||||
<VHeading :level="3" class="neo-section-header">{{ t('groupDetailPage.chores.title') }}</VHeading>
|
|
||||||
<VButton @click="showGenerateScheduleModal = true">{{ t('groupDetailPage.chores.generateScheduleButton')
|
|
||||||
}}
|
|
||||||
</VButton>
|
|
||||||
</div>
|
|
||||||
<div v-if="upcomingChores.length > 0" class="enhanced-chores-list">
|
|
||||||
<div v-for="chore in upcomingChores" :key="chore.id" class="enhanced-chore-item"
|
|
||||||
:class="`status-${getDueDateStatus(chore)} ${getChoreStatusInfo(chore).isCompleted ? 'completed' : ''}`"
|
|
||||||
@click="openChoreDetailModal(chore)">
|
|
||||||
<div class="chore-main-content">
|
|
||||||
<div class="chore-icon-container">
|
|
||||||
<div class="chore-status-indicator" :class="{
|
|
||||||
'overdue': getDueDateStatus(chore) === 'overdue',
|
|
||||||
'due-today': getDueDateStatus(chore) === 'due-today',
|
|
||||||
'completed': getChoreStatusInfo(chore).isCompleted
|
|
||||||
}">
|
|
||||||
{{ getChoreStatusInfo(chore).isCompleted ? '✅' :
|
|
||||||
getDueDateStatus(chore) === 'overdue' ? '⚠️' :
|
|
||||||
getDueDateStatus(chore) === 'due-today' ? '📅' : '📋' }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="chore-text-content">
|
|
||||||
<div class="chore-header">
|
|
||||||
<span class="neo-chore-name" :class="{ completed: getChoreStatusInfo(chore).isCompleted }">
|
|
||||||
{{ chore.name }}
|
|
||||||
</span>
|
|
||||||
<div class="chore-badges">
|
|
||||||
<VBadge :text="formatFrequency(chore.frequency)"
|
|
||||||
:variant="getFrequencyBadgeVariant(chore.frequency)" />
|
|
||||||
<VBadge v-if="getDueDateStatus(chore) === 'overdue'" text="Overdue" variant="danger" />
|
|
||||||
<VBadge v-if="getDueDateStatus(chore) === 'due-today'" text="Due Today" variant="warning" />
|
|
||||||
<VBadge v-if="getChoreStatusInfo(chore).isCompleted" text="Completed" variant="success" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="chore-details">
|
|
||||||
<div class="chore-due-info">
|
|
||||||
<span class="due-label">Due:</span>
|
|
||||||
<span class="due-date" :class="getDueDateStatus(chore)">
|
|
||||||
{{ formatDate(chore.next_due_date) }}
|
|
||||||
<span v-if="getDueDateStatus(chore) === 'due-today'" class="today-indicator">(Today)</span>
|
|
||||||
<span v-if="getDueDateStatus(chore) === 'overdue'" class="overdue-indicator">
|
|
||||||
({{ formatDistanceToNow(new Date(chore.next_due_date), { addSuffix: true }) }})
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="chore-assignment-info">
|
|
||||||
<span class="assignment-label">Assigned to:</span>
|
|
||||||
<span class="assigned-user">{{ getChoreStatusInfo(chore).assignedUserName }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="chore.description" class="chore-description">
|
|
||||||
{{ chore.description }}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="getChoreStatusInfo(chore).isCompleted && getChoreStatusInfo(chore).currentAssignment?.completed_at"
|
|
||||||
class="completion-info">
|
|
||||||
Completed {{ formatDistanceToNow(new
|
|
||||||
Date(getChoreStatusInfo(chore).currentAssignment!.completed_at!),
|
|
||||||
{ addSuffix: true }) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="chore-actions">
|
|
||||||
<VButton size="sm" variant="neutral" @click.stop="openChoreDetailModal(chore)" title="View Details">
|
|
||||||
👁️
|
|
||||||
</VButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else class="text-center py-4">
|
|
||||||
<VIcon name="cleaning_services" size="lg" class="opacity-50 mb-2" />
|
|
||||||
<p>{{ t('groupDetailPage.chores.emptyState') }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Group Activity Log Section -->
|
|
||||||
<div class="mt-4 neo-section">
|
|
||||||
<VHeading :level="3" class="neo-section-header">{{ t('groupDetailPage.activityLog.title') }}</VHeading>
|
|
||||||
<div v-if="groupHistoryLoading" class="text-center">
|
|
||||||
<VSpinner />
|
|
||||||
</div>
|
|
||||||
<ul v-else-if="groupChoreHistory.length > 0" class="activity-log-list">
|
|
||||||
<li v-for="entry in groupChoreHistory" :key="entry.id" class="activity-log-item">
|
|
||||||
{{ formatHistoryEntry(entry) }}
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<p v-else>{{ t('groupDetailPage.activityLog.emptyState') }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Expenses Section -->
|
<!-- Expenses Section -->
|
||||||
<div class="mt-4 neo-section">
|
<div class="mt-4 neo-section">
|
||||||
@ -269,6 +178,22 @@
|
|||||||
<p>{{ t('groupDetailPage.expenses.emptyState') }}</p>
|
<p>{{ t('groupDetailPage.expenses.emptyState') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Group Activity Log Section -->
|
||||||
|
<div class="mt-4 neo-section">
|
||||||
|
<VHeading :level="3" class="neo-section-header">{{ t('groupDetailPage.activityLog.title') }}</VHeading>
|
||||||
|
<div v-if="groupHistoryLoading" class="text-center">
|
||||||
|
<VSpinner />
|
||||||
|
</div>
|
||||||
|
<ul v-else-if="groupChoreHistory.length > 0" class="activity-log-list">
|
||||||
|
<li v-for="entry in groupChoreHistory" :key="entry.id" class="activity-log-item">
|
||||||
|
{{ formatHistoryEntry(entry) }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p v-else>{{ t('groupDetailPage.activityLog.emptyState') }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -499,6 +424,7 @@ import VModal from '@/components/valerie/VModal.vue';
|
|||||||
import VSelect from '@/components/valerie/VSelect.vue';
|
import VSelect from '@/components/valerie/VSelect.vue';
|
||||||
import { onClickOutside } from '@vueuse/core'
|
import { onClickOutside } from '@vueuse/core'
|
||||||
import { groupService } from '../services/groupService'; // New service
|
import { groupService } from '../services/groupService'; // New service
|
||||||
|
import ChoresPage from './ChoresPage.vue';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user