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

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:
mohamad 2025-06-09 14:04:30 +02:00
parent dccd7bb300
commit 10845d2e5f
8 changed files with 596 additions and 329 deletions

View File

@ -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
View File

@ -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": {

View File

@ -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",

View 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>

View File

@ -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>

View File

@ -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')

View File

@ -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', '+') }}

View File

@ -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();