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:
parent
7836672f64
commit
262505c898
102
be/app/core/api_config.py
Normal file
102
be/app/core/api_config.py
Normal 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}"
|
@ -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 ---
|
||||
|
@ -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(error);
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(error.response?.data?.detail || error.message));
|
||||
},
|
||||
);
|
||||
|
||||
export default boot(({ app }) => {
|
||||
app.config.globalProperties.$axios = api;
|
||||
app.config.globalProperties.$axios = axios;
|
||||
app.config.globalProperties.$api = api;
|
||||
});
|
||||
|
||||
export { api };
|
||||
|
@ -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
106
fe/src/config/api-config.ts
Normal 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
18
fe/src/config/api.ts
Normal 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 };
|
@ -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>
|
||||
|
@ -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',
|
||||
|
@ -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') {
|
||||
|
@ -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, {
|
||||
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);
|
||||
|
@ -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) {
|
||||
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user