Merge pull request 'ph5' (#64) from ph5 into prod

Reviewed-on: #64
This commit is contained in:
mo 2025-06-09 14:04:57 +02:00
commit a7a01b90cf
11 changed files with 640 additions and 335 deletions

View File

@ -1,11 +1,12 @@
from fastapi import APIRouter, Depends, Request
from fastapi.responses import RedirectResponse
from fastapi import APIRouter, Depends, Request, HTTPException, status
from fastapi.responses import RedirectResponse, JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_transactional_session
from app.models import User
from app.auth import oauth, fastapi_users, auth_backend, get_jwt_strategy, get_refresh_jwt_strategy
from app.config import settings
from fastapi.security import OAuth2PasswordRequestForm
router = APIRouter()
@ -92,4 +93,30 @@ async def apple_callback(request: Request, db: AsyncSession = Depends(get_transa
# Redirect to frontend with tokens
redirect_url = f"{settings.FRONTEND_URL}/auth/callback?access_token={access_token}&refresh_token={refresh_token}"
return RedirectResponse(url=redirect_url)
return RedirectResponse(url=redirect_url)
@router.post('/jwt/refresh')
async def refresh_jwt_token(request: Request):
data = await request.json()
refresh_token = data.get('refresh_token')
if not refresh_token:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Missing refresh token")
refresh_strategy = get_refresh_jwt_strategy()
try:
user = await refresh_strategy.read_token(refresh_token, None)
except Exception:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token")
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token")
access_strategy = get_jwt_strategy()
access_token = await access_strategy.write_token(user)
# Optionally, issue a new refresh token (rotation)
new_refresh_token = await refresh_strategy.write_token(user)
return JSONResponse({
"access_token": access_token,
"refresh_token": new_refresh_token,
"token_type": "bearer"
})

View File

@ -9,6 +9,7 @@ from app.api.v1.endpoints import ocr
from app.api.v1.endpoints import costs
from app.api.v1.endpoints import financials
from app.api.v1.endpoints import chores
from app.api.auth import oauth
api_router_v1 = APIRouter()
@ -21,5 +22,6 @@ api_router_v1.include_router(ocr.router, prefix="/ocr", tags=["OCR"])
api_router_v1.include_router(costs.router, prefix="/costs", tags=["Costs"])
api_router_v1.include_router(financials.router, prefix="/financials", tags=["Financials"])
api_router_v1.include_router(chores.router, prefix="/chores", tags=["Chores"])
api_router_v1.include_router(oauth.router, prefix="/auth", tags=["Auth"])
# Add other v1 endpoint routers here later
# e.g., api_router_v1.include_router(users.router, prefix="/users", tags=["Users"])

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

29
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",
@ -34,6 +35,7 @@
"@types/date-fns": "^2.5.3",
"@types/jsdom": "^21.1.7",
"@types/node": "^22.15.17",
"@types/qs": "^6.14.0",
"@vitejs/plugin-vue": "^5.2.3",
"@vitest/eslint-plugin": "^1.1.39",
"@vue/eslint-config-prettier": "^10.2.0",
@ -4291,6 +4293,13 @@
"integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==",
"license": "MIT"
},
"node_modules/@types/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.1.6",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.6.tgz",
@ -7491,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"
},
@ -9295,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"
@ -11732,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",
@ -45,6 +46,7 @@
"@types/date-fns": "^2.5.3",
"@types/jsdom": "^21.1.7",
"@types/node": "^22.15.17",
"@types/qs": "^6.14.0",
"@vitejs/plugin-vue": "^5.2.3",
"@vitest/eslint-plugin": "^1.1.39",
"@vue/eslint-config-prettier": "^10.2.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();

View File

@ -58,10 +58,10 @@ api.interceptors.response.use(
return Promise.reject(error)
}
// Send refresh token in Authorization header as expected by backend
const response = await api.post(API_ENDPOINTS.AUTH.REFRESH, {}, {
// Send refresh token in request body as expected by backend
const response = await api.post(API_ENDPOINTS.AUTH.REFRESH, { refresh_token: refreshTokenValue }, {
headers: {
Authorization: `Bearer ${refreshTokenValue}`
'Content-Type': 'application/json',
}
})