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

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

View File

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

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

View File

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

View File

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

View File

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