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
|
||||
ALLOWED_IMAGE_TYPES: list[str] = ["image/jpeg", "image/png", "image/webp"] # Supported image formats
|
||||
OCR_ITEM_EXTRACTION_PROMPT: str = """
|
||||
Extract the shopping list items from this image.
|
||||
List each distinct item on a new line.
|
||||
Ignore prices, quantities, store names, discounts, taxes, totals, and other non-item text.
|
||||
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.
|
||||
If the image does not appear to be a shopping list or receipt, state that clearly.
|
||||
Example output for a grocery list:
|
||||
Milk
|
||||
Eggs
|
||||
Bread
|
||||
__Apples__
|
||||
Organic Bananas
|
||||
**ROLE & GOAL**
|
||||
|
||||
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."
|
||||
|
||||
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.
|
||||
|
||||
**INPUT**
|
||||
|
||||
You will receive a single image (`[Image]`). This image contains a shopping list. It may be:
|
||||
* Neatly written or very messy.
|
||||
* On lined paper, a whiteboard, a napkin, or a dedicated notepad.
|
||||
* 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_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}"
|
||||
|
||||
# --- 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 = {
|
||||
"HARM_CATEGORY_HATE_SPEECH": "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",
|
||||
"axios": "^1.9.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"framer-motion": "^12.16.0",
|
||||
"motion": "^12.15.0",
|
||||
"pinia": "^3.0.2",
|
||||
"qs": "^6.14.0",
|
||||
@ -7499,12 +7500,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "12.15.0",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.15.0.tgz",
|
||||
"integrity": "sha512-XKg/LnKExdLGugZrDILV7jZjI599785lDIJZLxMiiIFidCsy0a4R2ZEf+Izm67zyOuJgQYTHOmodi7igQsw3vg==",
|
||||
"version": "12.16.0",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.16.0.tgz",
|
||||
"integrity": "sha512-xryrmD4jSBQrS2IkMdcTmiS4aSKckbS7kLDCuhUn9110SQKG1w3zlq1RTqCblewg+ZYe+m3sdtzQA6cRwo5g8Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-dom": "^12.15.0",
|
||||
"motion-dom": "^12.16.0",
|
||||
"motion-utils": "^12.12.1",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
@ -9303,9 +9304,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "12.15.0",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.15.0.tgz",
|
||||
"integrity": "sha512-D2ldJgor+2vdcrDtKJw48k3OddXiZN1dDLLWrS8kiHzQdYVruh0IoTwbJBslrnTXIPgFED7PBN2Zbwl7rNqnhA==",
|
||||
"version": "12.16.0",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.16.0.tgz",
|
||||
"integrity": "sha512-Z2nGwWrrdH4egLEtgYMCEN4V2qQt1qxlKy/uV7w691ztyA41Q5Rbn0KNGbsNVDZr9E8PD2IOQ3hSccRnB6xWzw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-utils": "^12.12.1"
|
||||
@ -11740,9 +11741,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.13",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz",
|
||||
"integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==",
|
||||
"version": "0.2.14",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
|
||||
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
@ -26,6 +26,7 @@
|
||||
"@vueuse/core": "^13.1.0",
|
||||
"axios": "^1.9.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"framer-motion": "^12.16.0",
|
||||
"motion": "^12.15.0",
|
||||
"pinia": "^3.0.2",
|
||||
"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>
|
||||
<div class="main-layout">
|
||||
|
||||
<header class="app-header">
|
||||
<div class="toolbar-title">mitlist</div>
|
||||
|
||||
|
||||
|
||||
<div class="flex align-end">
|
||||
<div class="language-selector" v-if="authStore.isAuthenticated">
|
||||
<button @click="toggleLanguageMenu" class="language-menu-button">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="20px" viewBox="0 0 24 24" width="20px" fill="#ff7b54">
|
||||
<path d="M0 0h24v24H0z" fill="none" />
|
||||
<path
|
||||
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>
|
||||
<!-- 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>
|
||||
<div v-if="languageMenuOpen" class="dropdown-menu language-dropdown" ref="languageMenuDropdown">
|
||||
<div class="dropdown-header">{{ $t('languageSelector.title') }}</div>
|
||||
<a v-for="(name, code) in availableLanguages" :key="code" href="#" @click.prevent="changeLanguage(code)"
|
||||
class="language-option" :class="{ 'active': currentLanguageCode === code }">
|
||||
{{ name }}
|
||||
</a>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<div class="user-menu" v-if="authStore.isAuthenticated">
|
||||
<button @click="toggleUserMenu" class="user-menu-button">
|
||||
<!-- Placeholder for user icon -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#ff7b54">
|
||||
<path d="M0 0h24v24H0z" fill="none" />
|
||||
<path
|
||||
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" />
|
||||
</svg>
|
||||
<!-- Language Menu -->
|
||||
<div class="control-item">
|
||||
<button ref="languageMenuTrigger" class="icon-button language-button"
|
||||
:class="{ 'is-active': languageMenuOpen }" :aria-expanded="languageMenuOpen"
|
||||
aria-controls="language-menu-dropdown"
|
||||
:aria-label="`Change language, current: ${currentLanguageCode.toUpperCase()}`" @click="toggleLanguageMenu">
|
||||
<span class="material-icons-outlined">translate</span>
|
||||
<span class="current-language">{{ currentLanguageCode.toUpperCase() }}</span>
|
||||
</button>
|
||||
<div v-if="userMenuOpen" class="dropdown-menu" ref="userMenuDropdown">
|
||||
<a href="#" @click.prevent="handleLogout">Logout</a>
|
||||
</div>
|
||||
<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>
|
||||
<a v-for="(name, code) in availableLanguages" :key="code" href="#" role="menuitem" class="language-option"
|
||||
:class="{ 'active': currentLanguageCode === code }" @click.prevent="changeLanguage(code)">
|
||||
{{ name }}
|
||||
</a>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- User Menu -->
|
||||
<div class="control-item">
|
||||
<button ref="userMenuTrigger" class="icon-button user-menu-button" :class="{ 'is-active': userMenuOpen }"
|
||||
:aria-expanded="userMenuOpen" aria-controls="user-menu-dropdown" aria-label="User menu"
|
||||
@click="toggleUserMenu">
|
||||
<!-- Show user avatar if available, otherwise a fallback icon -->
|
||||
<img v-if="authStore.user?.avatarUrl" :src="authStore.user.avatarUrl" alt="User Avatar"
|
||||
class="user-avatar" />
|
||||
<span v-else class="material-icons">account_circle</span>
|
||||
</button>
|
||||
<Transition name="dropdown-fade">
|
||||
<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>
|
||||
<a href="#" role="menuitem" @click.prevent="handleLogout">Logout</a>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- =================================================================
|
||||
MAIN CONTENT
|
||||
================================================================== -->
|
||||
<main class="page-container">
|
||||
<router-view v-slot="{ Component, route }">
|
||||
<keep-alive v-if="route.meta.keepAlive">
|
||||
@ -51,44 +81,50 @@
|
||||
|
||||
<OfflineIndicator />
|
||||
|
||||
<!-- =================================================================
|
||||
FOOTER NAVIGATION
|
||||
Improved with more semantic router-links and better active state styling.
|
||||
================================================================== -->
|
||||
<footer class="app-footer">
|
||||
<nav class="tabs">
|
||||
<router-link to="/lists" class="tab-item" active-class="active">
|
||||
<span class="material-icons">list</span>
|
||||
<span class="tab-text">Lists</span>
|
||||
</router-link>
|
||||
<a @click.prevent="navigateToGroups" href="/groups" class="tab-item"
|
||||
:class="{ 'active': $route.path.startsWith('/groups') }">
|
||||
<!-- Use a RouterLink for semantics and a11y, but keep custom click handler -->
|
||||
<router-link to="/groups" class="tab-item" :class="{ 'active': $route.path.startsWith('/groups') }"
|
||||
@click.prevent="navigateToGroups">
|
||||
<span class="material-icons">group</span>
|
||||
<span class="tab-text">Groups</span>
|
||||
</a>
|
||||
</router-link>
|
||||
<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>
|
||||
</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>
|
||||
</footer>
|
||||
|
||||
<!-- =================================================================
|
||||
MODALS
|
||||
================================================================== -->
|
||||
<CreateListModal v-model="showCreateListModal" @created="handleListCreated" />
|
||||
<CreateGroupModal v-model="showCreateGroupModal" @created="handleGroupCreated" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, defineComponent, onMounted, computed } from 'vue';
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import OfflineIndicator from '@/components/OfflineIndicator.vue';
|
||||
import { onClickOutside } from '@vueuse/core';
|
||||
import { useNotificationStore } from '@/stores/notifications';
|
||||
import { useGroupStore } from '@/stores/groupStore';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import CreateListModal from '@/components/CreateListModal.vue';
|
||||
import CreateGroupModal from '@/components/CreateGroupModal.vue';
|
||||
import { onClickOutside } from '@vueuse/core';
|
||||
|
||||
defineComponent({
|
||||
name: 'MainLayout'
|
||||
});
|
||||
|
||||
// Store and Router setup
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const authStore = useAuthStore();
|
||||
@ -96,48 +132,30 @@ const notificationStore = useNotificationStore();
|
||||
const groupStore = useGroupStore();
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
// Add initialization logic
|
||||
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
|
||||
}
|
||||
}
|
||||
};
|
||||
// --- Dropdown Logic (Re-integrated from composable) ---
|
||||
|
||||
// Call initialization when component is mounted
|
||||
onMounted(() => {
|
||||
initializeApp();
|
||||
if (authStore.isAuthenticated) {
|
||||
groupStore.fetchGroups();
|
||||
}
|
||||
// 1. Add Menu Dropdown
|
||||
const addMenuOpen = ref(false);
|
||||
const addMenuDropdown = ref<HTMLElement | null>(null);
|
||||
const addMenuTrigger = ref<HTMLElement | null>(null);
|
||||
const toggleAddMenu = () => { addMenuOpen.value = !addMenuOpen.value; };
|
||||
onClickOutside(addMenuDropdown, () => { addMenuOpen.value = false; }, { ignore: [addMenuTrigger] });
|
||||
|
||||
// Load saved language from localStorage
|
||||
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
|
||||
// 2. Language Menu Dropdown
|
||||
const languageMenuOpen = ref(false);
|
||||
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(() => ({
|
||||
en: t('languageSelector.languages.en'),
|
||||
de: t('languageSelector.languages.de'),
|
||||
@ -145,64 +163,96 @@ const availableLanguages = computed(() => ({
|
||||
fr: t('languageSelector.languages.fr'),
|
||||
es: t('languageSelector.languages.es')
|
||||
}));
|
||||
|
||||
const currentLanguageCode = computed(() => locale.value);
|
||||
|
||||
const toggleLanguageMenu = () => {
|
||||
languageMenuOpen.value = !languageMenuOpen.value;
|
||||
};
|
||||
|
||||
const changeLanguage = (languageCode: string) => {
|
||||
locale.value = languageCode;
|
||||
localStorage.setItem('language', languageCode);
|
||||
languageMenuOpen.value = false;
|
||||
languageMenuOpen.value = false; // Close menu on selection
|
||||
notificationStore.addNotification({
|
||||
type: 'success',
|
||||
message: `Language changed to ${availableLanguages.value[languageCode as keyof typeof availableLanguages.value]}`,
|
||||
});
|
||||
};
|
||||
|
||||
onClickOutside(languageMenuDropdown, () => {
|
||||
languageMenuOpen.value = false;
|
||||
}, { ignore: ['.language-menu-button'] });
|
||||
// --- Modal Handling ---
|
||||
const showCreateListModal = ref(false);
|
||||
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 () => {
|
||||
try {
|
||||
authStore.logout(); // Pinia action
|
||||
notificationStore.addNotification({
|
||||
type: 'success',
|
||||
message: 'Logged out successfully',
|
||||
});
|
||||
await router.push('/auth/login'); // Adjusted path
|
||||
userMenuOpen.value = false; // Close menu
|
||||
authStore.logout();
|
||||
notificationStore.addNotification({ type: 'success', message: 'Logged out successfully' });
|
||||
await router.push('/auth/login');
|
||||
} catch (error: unknown) {
|
||||
notificationStore.addNotification({
|
||||
type: 'error',
|
||||
message: error instanceof Error ? error.message : 'Logout failed',
|
||||
});
|
||||
}
|
||||
userMenuOpen.value = false;
|
||||
};
|
||||
|
||||
const navigateToGroups = () => {
|
||||
// The groups should have been fetched on mount, but we can check isLoading
|
||||
if (groupStore.isLoading) {
|
||||
// Maybe show a toast or do nothing
|
||||
console.log('Groups are still loading...');
|
||||
return;
|
||||
}
|
||||
if (groupStore.isLoading) return;
|
||||
if (groupStore.groupCount === 1 && groupStore.firstGroupId) {
|
||||
router.push(`/groups/${groupStore.firstGroupId}`);
|
||||
} else {
|
||||
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>
|
||||
|
||||
<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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
background-color: #f9f9f9; // A slightly off-white background for the main page
|
||||
}
|
||||
|
||||
.app-header {
|
||||
@ -212,7 +262,7 @@ const navigateToGroups = () => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
@ -225,24 +275,47 @@ const navigateToGroups = () => {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.language-selector {
|
||||
.header-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.control-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.language-menu-button {
|
||||
.icon-button {
|
||||
background: none;
|
||||
border: none;
|
||||
border: 1px solid transparent; // Prevents layout shift on hover
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
border-radius: 8px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
justify-content: center;
|
||||
transition: background-color 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
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 {
|
||||
@ -251,55 +324,37 @@ const navigateToGroups = () => {
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.language-dropdown {
|
||||
min-width: 180px;
|
||||
.user-menu-button {
|
||||
padding: 0; // Remove padding if image is used
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
overflow: hidden;
|
||||
|
||||
.dropdown-header {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.language-option {
|
||||
display: block;
|
||||
padding: 0.75rem 1rem;
|
||||
color: var(--text-color);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: rgba(255, 123, 84, 0.1);
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
.user-avatar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 5px);
|
||||
color: var(--primary);
|
||||
background-color: #f3f3f3;
|
||||
top: calc(100% + 8px); // A bit more space
|
||||
background-color: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
min-width: 150px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
min-width: 180px;
|
||||
z-index: 101;
|
||||
overflow: hidden; // To respect child border-radius
|
||||
|
||||
a {
|
||||
display: block;
|
||||
padding: 0.5rem 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
color: var(--text-color);
|
||||
text-decoration: none;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f5f5;
|
||||
@ -307,29 +362,54 @@ const navigateToGroups = () => {
|
||||
}
|
||||
}
|
||||
|
||||
.user-menu {
|
||||
position: relative;
|
||||
.dropdown-header {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.user-menu-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
border-radius: 50%;
|
||||
.dropdown-user-info {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
strong {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: 0.8em;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.language-option.active {
|
||||
background-color: rgba(255, 123, 84, 0.1);
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Dropdown transition */
|
||||
.dropdown-fade-enter-active,
|
||||
.dropdown-fade-leave-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.dropdown-fade-enter-from,
|
||||
.dropdown-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
.page-container {
|
||||
flex-grow: 1;
|
||||
padding-bottom: calc(var(--footer-height) + 1rem); // Space for fixed footer
|
||||
padding-bottom: calc(var(--footer-height) + 1rem);
|
||||
}
|
||||
|
||||
.app-footer {
|
||||
@ -341,6 +421,7 @@ const navigateToGroups = () => {
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
@ -354,19 +435,43 @@ const navigateToGroups = () => {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-color);
|
||||
color: #757575; // Softer default color
|
||||
text-decoration: none;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
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 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.tab-text {
|
||||
display: none;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
@ -374,17 +479,8 @@ const navigateToGroups = () => {
|
||||
gap: 8px;
|
||||
|
||||
.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 router from './router'
|
||||
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 frMessages from './i18n/fr.json'
|
||||
import esMessages from './i18n/es.json'
|
||||
import nlMessages from './i18n/nl.json'
|
||||
|
||||
// Global styles
|
||||
import './assets/main.scss'
|
||||
|
||||
// API client (from your axios boot file)
|
||||
import { api, globalAxios } from '@/services/api' // Renamed from boot/axios to services/api
|
||||
import { api, globalAxios } from '@/services/api'
|
||||
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({
|
||||
legacy: false, // Recommended for Vue 3
|
||||
locale: 'en', // Default locale
|
||||
fallbackLocale: 'en', // Fallback locale
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
fallbackLocale: 'en',
|
||||
messages: {
|
||||
en: enMessages,
|
||||
de: deMessages,
|
||||
@ -48,7 +32,6 @@ const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
app.use(pinia)
|
||||
|
||||
// Initialize Sentry
|
||||
Sentry.init({
|
||||
app,
|
||||
dsn: import.meta.env.VITE_SENTRY_DSN,
|
||||
@ -58,27 +41,21 @@ Sentry.init({
|
||||
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,
|
||||
// Set environment
|
||||
environment: import.meta.env.MODE,
|
||||
})
|
||||
|
||||
// Initialize auth state before mounting the app
|
||||
const authStore = useAuthStore()
|
||||
if (authStore.accessToken) {
|
||||
authStore.fetchCurrentUser().catch((error) => {
|
||||
console.error('Failed to initialize current user state:', error)
|
||||
// The fetchCurrentUser action handles token clearing on failure.
|
||||
})
|
||||
}
|
||||
|
||||
app.use(router)
|
||||
app.use(i18n)
|
||||
|
||||
// Make API instance globally available (optional, prefer provide/inject or store)
|
||||
app.config.globalProperties.$api = api
|
||||
app.config.globalProperties.$axios = globalAxios // The original axios instance if needed
|
||||
app.config.globalProperties.$axios = globalAxios
|
||||
|
||||
app.mount('#app')
|
||||
|
@ -10,6 +10,8 @@ import { useStorage } from '@vueuse/core'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{ groupId?: number | string }>();
|
||||
|
||||
// Types
|
||||
interface ChoreWithCompletion extends Chore {
|
||||
current_assignment_id: number | null;
|
||||
@ -162,10 +164,19 @@ const getChoreSubtext = (chore: ChoreWithCompletion): string => {
|
||||
return parts.join(' · ');
|
||||
};
|
||||
|
||||
const groupedChores = computed(() => {
|
||||
if (!chores.value) return []
|
||||
const filteredChores = computed(() => {
|
||||
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')
|
||||
if (!acc[dueDate]) {
|
||||
acc[dueDate] = []
|
||||
@ -177,7 +188,6 @@ const groupedChores = computed(() => {
|
||||
return Object.keys(choresByDate)
|
||||
.sort((a, b) => new Date(a).getTime() - new Date(b).getTime())
|
||||
.map(dateStr => {
|
||||
// Create a new Date object and ensure it's interpreted as local time, not UTC
|
||||
const dateParts = dateStr.split('-').map(Number);
|
||||
const date = new Date(dateParts[0], dateParts[1] - 1, dateParts[2]);
|
||||
return {
|
||||
@ -190,7 +200,7 @@ const groupedChores = computed(() => {
|
||||
}))
|
||||
}
|
||||
});
|
||||
})
|
||||
});
|
||||
|
||||
const formatDateHeader = (date: Date) => {
|
||||
const today = startOfDay(new Date())
|
||||
@ -210,6 +220,10 @@ const resetChoreForm = () => {
|
||||
|
||||
const openCreateChoreModal = () => {
|
||||
resetChoreForm()
|
||||
if (props.groupId) {
|
||||
choreForm.value.type = 'group';
|
||||
choreForm.value.group_id = typeof props.groupId === 'string' ? parseInt(props.groupId) : props.groupId;
|
||||
}
|
||||
showChoreModal.value = true
|
||||
}
|
||||
|
||||
@ -402,7 +416,7 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
|
||||
|
||||
<template>
|
||||
<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>
|
||||
<button class="btn btn-primary" @click="openCreateChoreModal">
|
||||
{{ t('choresPage.addChore', '+') }}
|
||||
@ -509,7 +523,7 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
|
||||
</div>
|
||||
<div v-if="choreForm.frequency === 'custom'" class="form-group">
|
||||
<label class="form-label" for="chore-interval">{{ t('choresPage.form.interval', 'Interval (days)')
|
||||
}}</label>
|
||||
}}</label>
|
||||
<input id="chore-interval" type="number" v-model.number="choreForm.custom_interval_days"
|
||||
class="form-input" :placeholder="t('choresPage.form.intervalPlaceholder')" min="1">
|
||||
</div>
|
||||
@ -530,7 +544,7 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
|
||||
</div>
|
||||
<div v-if="choreForm.type === 'group'" class="form-group">
|
||||
<label class="form-label" for="chore-group">{{ t('choresPage.form.assignGroup', 'Assign to Group')
|
||||
}}</label>
|
||||
}}</label>
|
||||
<select id="chore-group" v-model="choreForm.group_id" class="form-input">
|
||||
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
|
||||
</select>
|
||||
@ -539,7 +553,7 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-neutral" @click="showChoreModal = false">{{
|
||||
t('choresPage.form.cancel', 'Cancel')
|
||||
}}</button>
|
||||
}}</button>
|
||||
<button type="submit" class="btn btn-primary">{{ isEditing ? t('choresPage.form.save', 'Save Changes') :
|
||||
t('choresPage.form.create', 'Create') }}</button>
|
||||
</div>
|
||||
@ -564,7 +578,7 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
|
||||
t('choresPage.deleteConfirm.cancel', 'Cancel') }}</button>
|
||||
<button type="button" class="btn btn-danger" @click="deleteChore">{{
|
||||
t('choresPage.deleteConfirm.delete', 'Delete')
|
||||
}}</button>
|
||||
}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -589,7 +603,7 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
|
||||
<div class="detail-item">
|
||||
<span class="label">Created by:</span>
|
||||
<span class="value">{{ selectedChore.creator?.name || selectedChore.creator?.email || 'Unknown'
|
||||
}}</span>
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="label">Due date:</span>
|
||||
@ -621,7 +635,7 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
|
||||
<div v-for="assignment in selectedChoreAssignments" :key="assignment.id" class="assignment-item">
|
||||
<div class="assignment-main">
|
||||
<span class="assigned-user">{{ assignment.assigned_user?.name || assignment.assigned_user?.email
|
||||
}}</span>
|
||||
}}</span>
|
||||
<span class="assignment-status" :class="{ completed: assignment.is_complete }">
|
||||
{{ assignment.is_complete ? '✅ Completed' : '⏳ Pending' }}
|
||||
</span>
|
||||
|
@ -71,104 +71,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="neo-section-container">
|
||||
<div class="neo-section-cntainer">
|
||||
<!-- Lists 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" />
|
||||
</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 -->
|
||||
<div class="mt-4 neo-section">
|
||||
@ -269,6 +178,22 @@
|
||||
<p>{{ t('groupDetailPage.expenses.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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -499,6 +424,7 @@ import VModal from '@/components/valerie/VModal.vue';
|
||||
import VSelect from '@/components/valerie/VSelect.vue';
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import { groupService } from '../services/groupService'; // New service
|
||||
import ChoresPage from './ChoresPage.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user