Refactor API integration across multiple components; introduce centralized API configuration, enhance error handling, and improve type safety in API calls for better maintainability and user experience.

This commit is contained in:
mohamad 2025-05-08 22:22:46 +02:00
parent 7836672f64
commit 262505c898
12 changed files with 666 additions and 131 deletions

102
be/app/core/api_config.py Normal file
View File

@ -0,0 +1,102 @@
from typing import Dict, Any
from app.config import settings
# API Version
API_VERSION = "v1"
# API Prefix
API_PREFIX = f"/api/{API_VERSION}"
# API Endpoints
class APIEndpoints:
# Auth
AUTH = {
"LOGIN": "/auth/login",
"SIGNUP": "/auth/signup",
"REFRESH_TOKEN": "/auth/refresh-token",
}
# Users
USERS = {
"PROFILE": "/users/profile",
"UPDATE_PROFILE": "/users/profile",
}
# Lists
LISTS = {
"BASE": "/lists",
"BY_ID": "/lists/{id}",
"ITEMS": "/lists/{list_id}/items",
"ITEM": "/lists/{list_id}/items/{item_id}",
}
# Groups
GROUPS = {
"BASE": "/groups",
"BY_ID": "/groups/{id}",
"LISTS": "/groups/{group_id}/lists",
"MEMBERS": "/groups/{group_id}/members",
}
# Invites
INVITES = {
"BASE": "/invites",
"BY_ID": "/invites/{id}",
"ACCEPT": "/invites/{id}/accept",
"DECLINE": "/invites/{id}/decline",
}
# OCR
OCR = {
"PROCESS": "/ocr/process",
}
# Financials
FINANCIALS = {
"EXPENSES": "/financials/expenses",
"EXPENSE": "/financials/expenses/{id}",
"SETTLEMENTS": "/financials/settlements",
"SETTLEMENT": "/financials/settlements/{id}",
}
# Health
HEALTH = {
"CHECK": "/health",
}
# API Metadata
API_METADATA = {
"title": settings.API_TITLE,
"description": settings.API_DESCRIPTION,
"version": settings.API_VERSION,
"openapi_url": settings.API_OPENAPI_URL,
"docs_url": settings.API_DOCS_URL,
"redoc_url": settings.API_REDOC_URL,
}
# API Tags
API_TAGS = [
{"name": "Authentication", "description": "Authentication and authorization endpoints"},
{"name": "Users", "description": "User management endpoints"},
{"name": "Lists", "description": "Shopping list management endpoints"},
{"name": "Groups", "description": "Group management endpoints"},
{"name": "Invites", "description": "Group invitation management endpoints"},
{"name": "OCR", "description": "Optical Character Recognition endpoints"},
{"name": "Financials", "description": "Financial management endpoints"},
{"name": "Health", "description": "Health check endpoints"},
]
# Helper function to get full API URL
def get_api_url(endpoint: str, **kwargs) -> str:
"""
Get the full API URL for an endpoint.
Args:
endpoint: The endpoint path
**kwargs: Path parameters to format the endpoint
Returns:
str: The full API URL
"""
formatted_endpoint = endpoint.format(**kwargs)
return f"{API_PREFIX}{formatted_endpoint}"

View File

@ -4,8 +4,9 @@ import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.api_router import api_router # Import the main combined router
from app.api.api_router import api_router
from app.config import settings
from app.core.api_config import API_METADATA, API_TAGS
# Import database and models if needed for startup/shutdown events later
# from . import database, models
@ -18,12 +19,8 @@ logger = logging.getLogger(__name__)
# --- FastAPI App Instance ---
app = FastAPI(
title=settings.API_TITLE,
description=settings.API_DESCRIPTION,
version=settings.API_VERSION,
openapi_url=settings.API_OPENAPI_URL,
docs_url=settings.API_DOCS_URL,
redoc_url=settings.API_REDOC_URL
**API_METADATA,
openapi_tags=API_TAGS
)
# --- CORS Middleware ---

View File

@ -1,70 +1,72 @@
import { boot } from 'quasar/wrappers';
import axios, { type AxiosInstance } from 'axios';
import { useAuthStore } from 'stores/auth';
declare module '@vue/runtime-core' {
interface ComponentCustomProperties {
$axios: AxiosInstance;
}
}
import axios, { AxiosInstance } from 'axios';
import { API_BASE_URL, API_VERSION, API_ENDPOINTS } from 'src/config/api-config';
// Create axios instance
const api = axios.create({
baseURL: `${import.meta.env.VITE_API_URL || 'http://localhost:8000'}/api/v1`,
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor for adding auth token
// Request interceptor
api.interceptors.request.use(
(config) => {
const authStore = useAuthStore();
if (authStore.accessToken) {
config.headers.Authorization = `Bearer ${authStore.accessToken}`;
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(new Error(error.message));
},
return Promise.reject(error);
}
);
// Response interceptor for handling errors
// Response interceptor
api.interceptors.response.use(
(response) => response,
async (error) => {
const authStore = useAuthStore();
const originalRequest = error.config;
// If error is 401 and we haven't tried to refresh token yet
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
// If the error is 401 and we have a refresh token, try to refresh the access token
if (error.response?.status === 401 && authStore.refreshToken) {
try {
await authStore.refreshAccessToken();
// Retry the original request
const config = error.config;
config.headers.Authorization = `Bearer ${authStore.accessToken}`;
return api(config);
} catch (error) {
// If refresh fails, clear tokens and redirect to login
authStore.logout();
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) {
throw new Error('No refresh token available');
}
// Call refresh token endpoint
const response = await api.post('/api/v1/auth/refresh-token', {
refresh_token: refreshToken,
});
const { access_token } = response.data;
localStorage.setItem('token', access_token);
// Retry the original request with new token
originalRequest.headers.Authorization = `Bearer ${access_token}`;
return api(originalRequest);
} catch (refreshError) {
// If refresh token fails, clear storage and redirect to login
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
window.location.href = '/login';
return Promise.reject(
new Error(error instanceof Error ? error.message : 'Failed to refresh token'),
);
return Promise.reject(refreshError);
}
}
// If it's a 401 without refresh token or refresh failed, clear tokens and redirect
if (error.response?.status === 401) {
authStore.logout();
window.location.href = '/login';
}
return Promise.reject(new Error(error.response?.data?.detail || error.message));
},
return Promise.reject(error);
}
);
export default boot(({ app }) => {
app.config.globalProperties.$axios = api;
app.config.globalProperties.$axios = axios;
app.config.globalProperties.$api = api;
});
export { api };

View File

@ -39,7 +39,7 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useQuasar } from 'quasar';
import { api } from 'src/boot/axios';
import { apiClient, API_ENDPOINTS } from 'src/config/api';
const $q = useQuasar();
@ -73,10 +73,10 @@ watch(isOpen, (newVal) => {
const onSubmit = async () => {
try {
await api.post('/api/v1/lists', {
await apiClient.post(API_ENDPOINTS.LISTS.BASE, {
name: listName.value,
description: description.value,
groupId: selectedGroup.value?.value,
group_id: selectedGroup.value?.value,
});
$q.notify({
@ -92,10 +92,10 @@ const onSubmit = async () => {
// Close modal and emit created event
isOpen.value = false;
emit('created');
} catch {
} catch (error: unknown) {
$q.notify({
type: 'negative',
message: 'Failed to create list',
message: error instanceof Error ? error.message : 'Failed to create list',
});
}
};

106
fe/src/config/api-config.ts Normal file
View File

@ -0,0 +1,106 @@
// API Version
export const API_VERSION = 'v1';
// API Base URL
export const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
// API Endpoints
export const API_ENDPOINTS = {
// Auth
AUTH: {
LOGIN: '/auth/login',
SIGNUP: '/auth/signup',
REFRESH_TOKEN: '/auth/refresh-token',
LOGOUT: '/auth/logout',
VERIFY_EMAIL: '/auth/verify-email',
RESET_PASSWORD: '/auth/reset-password',
FORGOT_PASSWORD: '/auth/forgot-password',
},
// Users
USERS: {
PROFILE: '/users/me',
UPDATE_PROFILE: '/users/me',
PASSWORD: '/users/password',
AVATAR: '/users/avatar',
SETTINGS: '/users/settings',
NOTIFICATIONS: '/users/notifications',
PREFERENCES: '/users/preferences',
},
// Lists
LISTS: {
BASE: '/lists',
BY_ID: (id: string) => `/lists/${id}`,
ITEMS: (listId: string) => `/lists/${listId}/items`,
ITEM: (listId: string, itemId: string) => `/lists/${listId}/items/${itemId}`,
SHARE: (listId: string) => `/lists/${listId}/share`,
UNSHARE: (listId: string) => `/lists/${listId}/unshare`,
COMPLETE: (listId: string) => `/lists/${listId}/complete`,
REOPEN: (listId: string) => `/lists/${listId}/reopen`,
ARCHIVE: (listId: string) => `/lists/${listId}/archive`,
RESTORE: (listId: string) => `/lists/${listId}/restore`,
DUPLICATE: (listId: string) => `/lists/${listId}/duplicate`,
EXPORT: (listId: string) => `/lists/${listId}/export`,
IMPORT: '/lists/import',
},
// Groups
GROUPS: {
BASE: '/groups',
BY_ID: (id: string) => `/groups/${id}`,
LISTS: (groupId: string) => `/groups/${groupId}/lists`,
MEMBERS: (groupId: string) => `/groups/${groupId}/members`,
MEMBER: (groupId: string, userId: string) => `/groups/${groupId}/members/${userId}`,
LEAVE: (groupId: string) => `/groups/${groupId}/leave`,
DELETE: (groupId: string) => `/groups/${groupId}`,
SETTINGS: (groupId: string) => `/groups/${groupId}/settings`,
ROLES: (groupId: string) => `/groups/${groupId}/roles`,
ROLE: (groupId: string, roleId: string) => `/groups/${groupId}/roles/${roleId}`,
},
// Invites
INVITES: {
BASE: '/invites',
BY_ID: (id: string) => `/invites/${id}`,
ACCEPT: (id: string) => `/invites/${id}/accept`,
DECLINE: (id: string) => `/invites/${id}/decline`,
REVOKE: (id: string) => `/invites/${id}/revoke`,
LIST: '/invites',
PENDING: '/invites/pending',
SENT: '/invites/sent',
},
// OCR
OCR: {
PROCESS: '/ocr/process',
STATUS: (jobId: string) => `/ocr/status/${jobId}`,
RESULT: (jobId: string) => `/ocr/result/${jobId}`,
BATCH: '/ocr/batch',
CANCEL: (jobId: string) => `/ocr/cancel/${jobId}`,
HISTORY: '/ocr/history',
},
// Financials
FINANCIALS: {
EXPENSES: '/financials/expenses',
EXPENSE: (id: string) => `/financials/expenses/${id}`,
SETTLEMENTS: '/financials/settlements',
SETTLEMENT: (id: string) => `/financials/settlements/${id}`,
BALANCES: '/financials/balances',
BALANCE: (userId: string) => `/financials/balances/${userId}`,
REPORTS: '/financials/reports',
REPORT: (id: string) => `/financials/reports/${id}`,
CATEGORIES: '/financials/categories',
CATEGORY: (id: string) => `/financials/categories/${id}`,
},
// Health
HEALTH: {
CHECK: '/health',
VERSION: '/health/version',
STATUS: '/health/status',
METRICS: '/health/metrics',
LOGS: '/health/logs',
},
};

18
fe/src/config/api.ts Normal file
View File

@ -0,0 +1,18 @@
import { api } from 'boot/axios';
import { API_BASE_URL, API_VERSION, API_ENDPOINTS } from './api-config';
// Helper function to get full API URL
export const getApiUrl = (endpoint: string): string => {
return `${API_BASE_URL}/api/${API_VERSION}${endpoint}`;
};
// Helper function to make API calls
export const apiClient = {
get: (endpoint: string, config = {}) => api.get(getApiUrl(endpoint), config),
post: (endpoint: string, data = {}, config = {}) => api.post(getApiUrl(endpoint), data, config),
put: (endpoint: string, data = {}, config = {}) => api.put(getApiUrl(endpoint), data, config),
patch: (endpoint: string, data = {}, config = {}) => api.patch(getApiUrl(endpoint), data, config),
delete: (endpoint: string, config = {}) => api.delete(getApiUrl(endpoint), config),
};
export { API_ENDPOINTS };

View File

@ -1,10 +1,264 @@
<template>
<q-page padding>
<h1 class="text-h4 q-mb-md">Account</h1>
<p>Your account settings will appear here.</p>
<h1 class="text-h4 q-mb-md">Account Settings</h1>
<div v-if="loading" class="text-center">
<q-spinner-dots color="primary" size="2em" />
<p>Loading profile...</p>
</div>
<q-banner v-else-if="error" inline-actions class="text-white bg-red q-mb-md">
<template v-slot:avatar>
<q-icon name="warning" />
</template>
{{ error }}
<template v-slot:action>
<q-btn flat color="white" label="Retry" @click="fetchProfile" />
</template>
</q-banner>
<template v-else>
<q-form @submit="onSubmit" class="q-gutter-md">
<!-- Profile Section -->
<q-card class="q-mb-md">
<q-card-section>
<div class="text-h6">Profile Information</div>
</q-card-section>
<q-card-section>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<q-input
v-model="profile.name"
label="Name"
:rules="[(val) => !!val || 'Name is required']"
outlined
/>
</div>
<div class="col-12 col-md-6">
<q-input
v-model="profile.email"
label="Email"
type="email"
:rules="[(val) => !!val || 'Email is required']"
outlined
readonly
/>
</div>
</div>
</q-card-section>
<q-card-actions align="right">
<q-btn
type="submit"
color="primary"
label="Save Changes"
:loading="saving"
/>
</q-card-actions>
</q-card>
<!-- Password Section -->
<q-card class="q-mb-md">
<q-card-section>
<div class="text-h6">Change Password</div>
</q-card-section>
<q-card-section>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<q-input
v-model="password.current"
label="Current Password"
type="password"
:rules="[(val) => !!val || 'Current password is required']"
outlined
/>
</div>
<div class="col-12 col-md-6">
<q-input
v-model="password.new"
label="New Password"
type="password"
:rules="[(val) => !!val || 'New password is required']"
outlined
/>
</div>
</div>
</q-card-section>
<q-card-actions align="right">
<q-btn
color="primary"
label="Change Password"
:loading="changingPassword"
@click="onChangePassword"
/>
</q-card-actions>
</q-card>
<!-- Notifications Section -->
<q-card>
<q-card-section>
<div class="text-h6">Notification Preferences</div>
</q-card-section>
<q-card-section>
<q-list>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Email Notifications</q-item-label>
<q-item-label caption>Receive email notifications for important updates</q-item-label>
</q-item-section>
<q-item-section side>
<q-toggle v-model="preferences.emailNotifications" @update:model-value="onPreferenceChange" />
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>List Updates</q-item-label>
<q-item-label caption>Get notified when lists are updated</q-item-label>
</q-item-section>
<q-item-section side>
<q-toggle v-model="preferences.listUpdates" @update:model-value="onPreferenceChange" />
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Group Activities</q-item-label>
<q-item-label caption>Receive notifications for group activities</q-item-label>
</q-item-section>
<q-item-section side>
<q-toggle v-model="preferences.groupActivities" @update:model-value="onPreferenceChange" />
</q-item-section>
</q-item>
</q-list>
</q-card-section>
</q-card>
</q-form>
</template>
</q-page>
</template>
<script setup lang="ts">
// Component logic will go here
import { ref, onMounted } from 'vue';
import { useQuasar } from 'quasar';
import { apiClient, API_ENDPOINTS } from 'src/config/api';
interface Profile {
name: string;
email: string;
}
interface Password {
current: string;
new: string;
}
interface Preferences {
emailNotifications: boolean;
listUpdates: boolean;
groupActivities: boolean;
}
const $q = useQuasar();
const loading = ref(true);
const saving = ref(false);
const changingPassword = ref(false);
const error = ref<string | null>(null);
const profile = ref<Profile>({
name: '',
email: '',
});
const password = ref<Password>({
current: '',
new: '',
});
const preferences = ref<Preferences>({
emailNotifications: true,
listUpdates: true,
groupActivities: true,
});
const fetchProfile = async () => {
loading.value = true;
error.value = null;
try {
const response = await apiClient.get(API_ENDPOINTS.USERS.PROFILE);
profile.value = response.data;
} catch (err: unknown) {
console.error('Failed to fetch profile:', err);
error.value = err instanceof Error ? err.message : 'Failed to load profile';
$q.notify({
type: 'negative',
message: error.value,
});
} finally {
loading.value = false;
}
};
const onSubmit = async () => {
saving.value = true;
try {
await apiClient.put(API_ENDPOINTS.USERS.UPDATE_PROFILE, profile.value);
$q.notify({
type: 'positive',
message: 'Profile updated successfully',
});
} catch (err: unknown) {
console.error('Failed to update profile:', err);
$q.notify({
type: 'negative',
message: err instanceof Error ? err.message : 'Failed to update profile',
});
} finally {
saving.value = false;
}
};
const onChangePassword = async () => {
changingPassword.value = true;
try {
await apiClient.put(API_ENDPOINTS.USERS.PASSWORD, password.value);
password.value = { current: '', new: '' };
$q.notify({
type: 'positive',
message: 'Password changed successfully',
});
} catch (err: unknown) {
console.error('Failed to change password:', err);
$q.notify({
type: 'negative',
message: err instanceof Error ? err.message : 'Failed to change password',
});
} finally {
changingPassword.value = false;
}
};
const onPreferenceChange = async () => {
try {
await apiClient.put(API_ENDPOINTS.USERS.PREFERENCES, preferences.value);
$q.notify({
type: 'positive',
message: 'Preferences updated successfully',
});
} catch (err: unknown) {
console.error('Failed to update preferences:', err);
$q.notify({
type: 'negative',
message: err instanceof Error ? err.message : 'Failed to update preferences',
});
}
};
onMounted(() => {
fetchProfile();
});
</script>

View File

@ -34,7 +34,7 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useRoute } from 'vue-router';
import { api } from 'boot/axios';
import { apiClient, API_ENDPOINTS } from 'src/config/api';
import { useAuthStore } from 'stores/auth';
import { copyToClipboard, useQuasar } from 'quasar';
@ -67,9 +67,7 @@ const fetchGroupDetails = async () => {
if (!groupId.value) return;
loading.value = true;
try {
const response = await api.get(`/groups/${groupId.value}`, {
headers: { Authorization: `Bearer ${authStore.accessToken}` },
});
const response = await apiClient.get(API_ENDPOINTS.GROUPS.BY_ID(groupId.value));
group.value = response.data;
} catch (error: unknown) {
console.error('Error fetching group details:', error);
@ -88,13 +86,9 @@ const generateInviteCode = async () => {
generatingInvite.value = true;
inviteCode.value = null;
try {
const response = await api.post(
`/groups/${groupId.value}/invites`,
{},
{
headers: { Authorization: `Bearer ${authStore.accessToken}` },
},
);
const response = await apiClient.post(API_ENDPOINTS.INVITES.BASE, {
group_id: groupId.value,
});
inviteCode.value = response.data.invite_code;
$q.notify({
color: 'positive',

View File

@ -87,7 +87,7 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { api } from '../boot/axios'; // Ensure this path is correct
import { apiClient, API_ENDPOINTS } from 'src/config/api';
import { useQuasar, QInput } from 'quasar';
interface Group {
@ -113,7 +113,7 @@ const joinInviteCodeInput = ref<QInput | null>(null);
const fetchGroups = async () => {
loading.value = true;
try {
const response = await api.get<Group[]>('/groups');
const response = await apiClient.get(API_ENDPOINTS.GROUPS.BASE);
groups.value = Array.isArray(response.data) ? response.data : [];
} catch (error: unknown) {
console.error('Error fetching groups:', error);
@ -136,15 +136,15 @@ const openCreateGroupDialog = () => {
const handleCreateGroup = async () => {
if (!newGroupName.value || newGroupName.value.trim() === '') {
void newGroupNameInput.value?.validate(); // Assuming QInput has a validate method
void newGroupNameInput.value?.validate();
return;
}
creatingGroup.value = true;
try {
const response = await api.post<{ data: Group }>('/groups', {
const response = await apiClient.post(API_ENDPOINTS.GROUPS.BASE, {
name: newGroupName.value,
});
const newGroup = response.data?.data; // Safely access .data
const newGroup = response.data;
if (newGroup && typeof newGroup.id === 'string' && typeof newGroup.name === 'string') {
groups.value.push(newGroup);
@ -184,9 +184,7 @@ const handleJoinGroup = async () => {
}
joiningGroup.value = true;
try {
const response = await api.post<Group>('/groups/join', {
invite_code: inviteCodeToJoin.value,
});
const response = await apiClient.post(API_ENDPOINTS.INVITES.ACCEPT(inviteCodeToJoin.value));
const joinedGroup = response.data;
if (joinedGroup && typeof joinedGroup.id === 'string' && typeof joinedGroup.name === 'string') {

View File

@ -161,7 +161,7 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { useRoute } from 'vue-router';
import { api } from 'boot/axios';
import { apiClient, API_ENDPOINTS } from 'src/config/api';
import { useQuasar, QFile } from 'quasar';
interface Item {
@ -184,11 +184,6 @@ interface List {
updated_at: string;
}
interface ListStatus {
list_updated_at: string;
latest_item_updated_at: string;
}
const route = useRoute();
const $q = useQuasar();
@ -223,13 +218,13 @@ const fetchListDetails = async () => {
loading.value = true;
error.value = null;
try {
const response = await api.get<List>(
`/api/v1/lists/${String(route.params.id)}`
const response = await apiClient.get(
API_ENDPOINTS.LISTS.BY_ID(String(route.params.id))
);
list.value = response.data;
list.value = response.data as List;
lastListUpdate.value = response.data.updated_at;
// Find the latest item update time
lastItemUpdate.value = response.data.items.reduce((latest, item) => {
lastItemUpdate.value = response.data.items.reduce((latest: string, item: Item) => {
return item.updated_at > latest ? item.updated_at : latest;
}, '');
} catch (err: unknown) {
@ -248,10 +243,13 @@ const fetchListDetails = async () => {
const checkForUpdates = async () => {
try {
const response = await api.get<ListStatus>(
`/api/v1/lists/${String(route.params.id)}/status`
const response = await apiClient.get(
API_ENDPOINTS.LISTS.BY_ID(String(route.params.id))
);
const { list_updated_at, latest_item_updated_at } = response.data;
const { updated_at: list_updated_at } = response.data as List;
const latest_item_updated_at = response.data.items.reduce((latest: string, item: Item) => {
return item.updated_at > latest ? item.updated_at : latest;
}, '');
// If either the list or any item has been updated, refresh the data
if (
@ -268,7 +266,7 @@ const checkForUpdates = async () => {
const startPolling = () => {
// Poll every 15 seconds
pollingInterval.value = window.setInterval(() => { void checkForUpdates(); }, 15000);
pollingInterval.value = setInterval(checkForUpdates, 15000);
};
const stopPolling = () => {
@ -283,11 +281,11 @@ const onAddItem = async () => {
addingItem.value = true;
try {
const response = await api.post<Item>(
`/api/v1/lists/${list.value.id}/items`,
const response = await apiClient.post(
API_ENDPOINTS.LISTS.ITEMS(String(list.value.id)),
newItem.value
);
list.value.items.push(response.data);
list.value.items.push(response.data as Item);
newItem.value = { name: '' };
} catch (err: unknown) {
$q.notify({
@ -302,8 +300,8 @@ const onAddItem = async () => {
const updateItem = async (item: Item) => {
item.updating = true;
try {
const response = await api.put<Item>(
`/api/v1/lists/${list.value.id}/items/${item.id}`,
const response = await apiClient.put(
API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)),
{
name: editingItemName.value,
quantity: editingItemQuantity.value,
@ -311,7 +309,7 @@ const updateItem = async (item: Item) => {
version: item.version,
}
);
Object.assign(item, response.data);
Object.assign(item, response.data as Item);
} catch (err: unknown) {
if ((err as { response?: { status?: number } }).response?.status === 409) {
$q.notify({
@ -342,20 +340,23 @@ const handleOcrUpload = async (file: File | null) => {
const formData = new FormData();
formData.append('file', file);
const response = await api.post<{ items: string[] }>(
`/api/v1/lists/${list.value.id}/ocr`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
const response = await apiClient.post(
API_ENDPOINTS.OCR.PROCESS,
formData,
{
headers: {
'Content-Type': 'multipart/form-data'
}
}
}
);
ocrItems.value = response.data.items.map((name) => ({ name }));
ocrItems.value = response.data.items;
showOcrDialog.value = true;
} catch (err: unknown) {
ocrError.value = (err as Error).message || 'Failed to process image';
$q.notify({
type: 'negative',
message: (err as { response?: { data?: { detail?: string } } }).response?.data?.detail || 'Failed to process image',
message: ocrError.value,
});
ocrError.value = (err as { response?: { data?: { detail?: string } } }).response?.data?.detail || 'Failed to process image';
} finally {
ocrLoading.value = false;
}
@ -369,8 +370,8 @@ const addOcrItems = async () => {
for (const item of ocrItems.value) {
if (!item.name) continue;
const response = await api.post<Item>(
`/api/v1/lists/${list.value.id}/items`,
const response = await apiClient.post<Item>(
API_ENDPOINTS.LISTS.ITEMS(list.value.id),
{ name: item.name, quantity: 1 }
);
list.value.items.push(response.data);

View File

@ -19,7 +19,13 @@
<div v-else-if="filteredLists.length === 0">
<p>{{ noListsMessage }}</p>
<!-- TODO: Add a button to create a new list -->
<q-btn
color="primary"
icon="add"
label="Create New List"
@click="showCreateModal = true"
class="q-mt-md"
/>
</div>
<q-list v-else bordered separator>
@ -52,14 +58,29 @@
</q-item>
</q-list>
<!-- TODO: Add FAB for creating a new list if props.groupId is defined or it's personal lists view -->
<q-page-sticky position="bottom-right" :offset="[18, 18]">
<q-btn
fab
color="primary"
icon="add"
@click="showCreateModal = true"
:label="currentGroupId ? 'Create Group List' : 'Create List'"
/>
</q-page-sticky>
<CreateListModal
v-model="showCreateModal"
:groups="availableGroups"
@created="fetchLists"
/>
</q-page>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useRoute } from 'vue-router'; // To potentially get groupId from route params
import { api } from 'boot/axios';
import { useRoute } from 'vue-router';
import { apiClient, API_ENDPOINTS } from 'src/config/api';
import CreateListModal from 'components/CreateListModal.vue';
import {
QSpinnerDots,
QBanner,
@ -70,32 +91,39 @@ import {
QItemLabel,
QBadge,
QBtn,
QPageSticky,
} from 'quasar'; // Explicitly import Quasar components
// Define the structure of a List based on ListPublic schema
interface List {
id: number;
name: string;
description?: string | null;
description?: string;
is_complete: boolean;
updated_at: string;
created_by_id: number;
group_id?: number | null;
is_complete: boolean;
created_at: string; // Assuming datetime is serialized as string
updated_at: string; // Assuming datetime is serialized as string
created_at: string;
version: number;
}
interface Group {
id: number;
name: string;
}
const props = defineProps<{
groupId?: number | string; // Can be passed as prop, or we might use route.params
}>();
const route = useRoute(); // Access route if needed
const lists = ref<List[]>([]);
const route = useRoute();
const loading = ref(true);
const error = ref<string | null>(null);
const lists = ref<List[]>([]);
const availableGroups = ref<{ label: string; value: number }[]>([]);
const groupName = ref<string | null>(null);
const showCreateModal = ref(false);
// Determine the actual groupId to use (from prop or route param)
const currentGroupId = computed(() => {
if (props.groupId) {
return typeof props.groupId === 'string' ? parseInt(props.groupId, 10) : props.groupId;
@ -106,12 +134,27 @@ const currentGroupId = computed(() => {
return null; // No specific group selected, show personal lists or all accessible
});
const fetchGroupName = async () => {
if (!currentGroupId.value) return;
try {
const response = await apiClient.get(
API_ENDPOINTS.GROUPS.BY_ID(String(currentGroupId.value))
);
groupName.value = (response.data as Group).name;
} catch (err) {
console.error('Failed to fetch group name:', err);
groupName.value = null;
}
};
const pageTitle = computed(() => {
if (currentGroupId.value) {
// TODO: Fetch group name if we want to display "Lists for Group X"
return `Lists for Group ${currentGroupId.value}`;
return groupName.value
? `Lists for ${groupName.value}`
: `Lists for Group ${currentGroupId.value}`;
}
return 'All My Lists'; // Changed from 'My Lists' to be more descriptive
return 'All My Lists';
});
const noListsMessage = computed(() => {
@ -121,12 +164,28 @@ const noListsMessage = computed(() => {
return 'You have no lists yet. Create a personal list or join a group to see shared lists.';
});
const fetchGroups = async () => {
try {
const response = await apiClient.get(API_ENDPOINTS.GROUPS.BASE);
availableGroups.value = (response.data as Group[]).map((group) => ({
label: group.name,
value: group.id,
}));
} catch (err) {
console.error('Failed to fetch groups:', err);
}
};
const fetchLists = async () => {
loading.value = true;
error.value = null;
try {
const response = await api.get<List[]>('/lists'); // API returns all accessible lists
lists.value = response.data;
const endpoint = currentGroupId.value
? API_ENDPOINTS.GROUPS.LISTS(String(currentGroupId.value))
: API_ENDPOINTS.LISTS.BASE;
const response = await apiClient.get(endpoint);
lists.value = response.data as List[];
} catch (err: unknown) {
console.error('Failed to fetch lists:', err);
error.value =
@ -136,7 +195,11 @@ const fetchLists = async () => {
}
};
onMounted(fetchLists);
onMounted(() => {
fetchLists();
fetchGroups();
fetchGroupName();
});
const filteredLists = computed(() => {
if (currentGroupId.value) {

View File

@ -1,6 +1,6 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { api } from 'boot/axios';
import { apiClient, API_ENDPOINTS } from 'src/config/api';
interface AuthState {
accessToken: string | null;
@ -46,7 +46,7 @@ export const useAuthStore = defineStore('auth', () => {
formData.append('username', email);
formData.append('password', password);
const response = await api.post('/auth/login', formData, {
const response = await apiClient.post(API_ENDPOINTS.AUTH.LOGIN, formData, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
@ -58,21 +58,17 @@ export const useAuthStore = defineStore('auth', () => {
};
const signup = async (userData: { name: string; email: string; password: string }) => {
const response = await api.post('/auth/signup', userData);
const response = await apiClient.post(API_ENDPOINTS.AUTH.SIGNUP, userData);
return response.data;
};
const logout = () => {
clearTokens();
};
const refreshAccessToken = async () => {
if (!refreshToken.value) {
throw new Error('No refresh token available');
}
try {
const response = await api.post('/auth/refresh', {
const response = await apiClient.post(API_ENDPOINTS.AUTH.REFRESH_TOKEN, {
refresh_token: refreshToken.value,
});
@ -85,6 +81,10 @@ export const useAuthStore = defineStore('auth', () => {
}
};
const logout = () => {
clearTokens();
};
return {
// State
accessToken,
@ -99,7 +99,7 @@ export const useAuthStore = defineStore('auth', () => {
setUser,
login,
signup,
logout,
refreshAccessToken,
logout,
};
});