commit i guess

This commit is contained in:
mohamad 2025-05-13 20:33:02 +02:00
parent 227a3d6186
commit cacfb2a5e8
14 changed files with 581 additions and 306 deletions

View File

@ -105,7 +105,7 @@ async def refresh_token(
""" """
Handles access token refresh. Handles access token refresh.
- Verifies the provided refresh token. - Verifies the provided refresh token.
- If valid, generates and returns a new JWT access token and the same refresh token. - If valid, generates and returns a new JWT access token and a new refresh token.
""" """
logger.info("Access token refresh attempt") logger.info("Access token refresh attempt")
payload = verify_refresh_token(refresh_token_str) payload = verify_refresh_token(refresh_token_str)
@ -127,9 +127,10 @@ async def refresh_token(
) )
new_access_token = create_access_token(subject=user_email) new_access_token = create_access_token(subject=user_email)
logger.info(f"Access token refreshed for user: {user_email}") new_refresh_token = create_refresh_token(subject=user_email)
logger.info(f"Access token refreshed and new refresh token issued for user: {user_email}")
return Token( return Token(
access_token=new_access_token, access_token=new_access_token,
refresh_token=refresh_token_str, refresh_token=new_refresh_token,
token_type=settings.TOKEN_TYPE token_type=settings.TOKEN_TYPE
) )

View File

@ -11,8 +11,10 @@ from app.models import User as UserModel, UserRoleEnum # Import model and enum
from app.schemas.group import GroupCreate, GroupPublic from app.schemas.group import GroupCreate, GroupPublic
from app.schemas.invite import InviteCodePublic from app.schemas.invite import InviteCodePublic
from app.schemas.message import Message # For simple responses from app.schemas.message import Message # For simple responses
from app.schemas.list import ListPublic
from app.crud import group as crud_group from app.crud import group as crud_group
from app.crud import invite as crud_invite from app.crud import invite as crud_invite
from app.crud import list as crud_list
from app.core.exceptions import ( from app.core.exceptions import (
GroupNotFoundError, GroupNotFoundError,
GroupPermissionError, GroupPermissionError,
@ -198,3 +200,29 @@ async def remove_group_member(
logger.info(f"Owner {current_user.email} successfully removed user {user_id_to_remove} from group {group_id}") logger.info(f"Owner {current_user.email} successfully removed user {user_id_to_remove} from group {group_id}")
return Message(detail="Successfully removed member from the group") return Message(detail="Successfully removed member from the group")
@router.get(
"/{group_id}/lists",
response_model=List[ListPublic],
summary="Get Group Lists",
tags=["Groups", "Lists"]
)
async def read_group_lists(
group_id: int,
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user),
):
"""Retrieves all lists belonging to a specific group, if the user is a member."""
logger.info(f"User {current_user.email} requesting lists for group ID: {group_id}")
# Check if user is a member first
is_member = await crud_group.is_user_member(db=db, group_id=group_id, user_id=current_user.id)
if not is_member:
logger.warning(f"Access denied: User {current_user.email} not member of group {group_id}")
raise GroupMembershipError(group_id, "view group lists")
# Get all lists for the user and filter by group_id
lists = await crud_list.get_lists_for_user(db=db, user_id=current_user.id)
group_lists = [list for list in lists if list.group_id == group_id]
return group_lists

View File

@ -23,7 +23,9 @@ from app.core.exceptions import (
async def create_list(db: AsyncSession, list_in: ListCreate, creator_id: int) -> ListModel: async def create_list(db: AsyncSession, list_in: ListCreate, creator_id: int) -> ListModel:
"""Creates a new list record.""" """Creates a new list record."""
try: try:
async with db.begin(): # Check if we're already in a transaction
if db.in_transaction():
# If we're already in a transaction, just create the list
db_list = ListModel( db_list = ListModel(
name=list_in.name, name=list_in.name,
description=list_in.description, description=list_in.description,
@ -35,6 +37,20 @@ async def create_list(db: AsyncSession, list_in: ListCreate, creator_id: int) ->
await db.flush() await db.flush()
await db.refresh(db_list) await db.refresh(db_list)
return db_list return db_list
else:
# If no transaction is active, start one
async with db.begin():
db_list = ListModel(
name=list_in.name,
description=list_in.description,
group_id=list_in.group_id,
created_by_id=creator_id,
is_complete=False
)
db.add(db_list)
await db.flush()
await db.refresh(db_list)
return db_list
except IntegrityError as e: except IntegrityError as e:
raise DatabaseIntegrityError(f"Failed to create list: {str(e)}") raise DatabaseIntegrityError(f"Failed to create list: {str(e)}")
except OperationalError as e: except OperationalError as e:

View File

@ -38,8 +38,8 @@ async def get_db() -> AsyncSession: # type: ignore
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
try: try:
yield session yield session
# Optionally commit if your endpoints modify data directly # Commit the transaction if no errors occurred
# await session.commit() # Usually commit happens within endpoint logic await session.commit()
except Exception: except Exception:
await session.rollback() await session.rollback()
raise raise

View File

@ -1,5 +1,5 @@
# app/schemas/group.py # app/schemas/group.py
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict, computed_field
from datetime import datetime from datetime import datetime
from typing import Optional, List from typing import Optional, List
@ -15,7 +15,25 @@ class GroupPublic(BaseModel):
name: str name: str
created_by_id: int created_by_id: int
created_at: datetime created_at: datetime
members: Optional[List[UserPublic]] = None # Include members only in detailed view member_associations: Optional[List["UserGroupPublic"]] = None
@computed_field
@property
def members(self) -> Optional[List[UserPublic]]:
if not self.member_associations:
return None
return [assoc.user for assoc in self.member_associations if assoc.user]
model_config = ConfigDict(from_attributes=True)
# Properties for UserGroup association
class UserGroupPublic(BaseModel):
id: int
user_id: int
group_id: int
role: str
joined_at: datetime
user: Optional[UserPublic] = None
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)

View File

@ -4,32 +4,22 @@
<div class="modal-header"> <div class="modal-header">
<h3 id="createListModalTitle">Create New List</h3> <h3 id="createListModalTitle">Create New List</h3>
<button class="close-button" @click="closeModal" aria-label="Close modal"> <button class="close-button" @click="closeModal" aria-label="Close modal">
<svg class="icon" aria-hidden="true"><use xlink:href="#icon-close" /></svg> <svg class="icon" aria-hidden="true">
<use xlink:href="#icon-close" />
</svg>
</button> </button>
</div> </div>
<form @submit.prevent="onSubmit"> <form @submit.prevent="onSubmit">
<div class="modal-body"> <div class="modal-body">
<div class="form-group"> <div class="form-group">
<label for="listName" class="form-label">List Name</label> <label for="listName" class="form-label">List Name</label>
<input <input type="text" id="listName" v-model="listName" class="form-input" required ref="listNameInput" />
type="text"
id="listName"
v-model="listName"
class="form-input"
required
ref="listNameInput"
/>
<p v-if="formErrors.listName" class="form-error-text">{{ formErrors.listName }}</p> <p v-if="formErrors.listName" class="form-error-text">{{ formErrors.listName }}</p>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="description" class="form-label">Description</label> <label for="description" class="form-label">Description</label>
<textarea <textarea id="description" v-model="description" class="form-input" rows="3"></textarea>
id="description"
v-model="description"
class="form-input"
rows="3"
></textarea>
</div> </div>
<div class="form-group" v-if="groups && groups.length > 0"> <div class="form-group" v-if="groups && groups.length > 0">
@ -45,7 +35,7 @@
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-neutral" @click="closeModal">Cancel</button> <button type="button" class="btn btn-neutral" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary ml-2" :disabled="loading"> <button type="submit" class="btn btn-primary ml-2" :disabled="loading">
<span v-if="loading" class="spinner-dots-sm" role="status"><span/><span/><span/></span> <span v-if="loading" class="spinner-dots-sm" role="status"><span /><span /><span /></span>
Create Create
</button> </button>
</div> </div>
@ -144,7 +134,9 @@ const onSubmit = async () => {
font-size: 0.85rem; font-size: 0.85rem;
margin-top: 0.25rem; margin-top: 0.25rem;
} }
.ml-2 { .ml-2 {
margin-left: 0.5rem; /* from Valerie UI utilities */ margin-left: 0.5rem;
/* from Valerie UI utilities */
} }
</style> </style>

View File

@ -4,13 +4,13 @@
<div <div
v-for="notification in store.notifications" v-for="notification in store.notifications"
:key="notification.id" :key="notification.id"
:class="['notification-item', `notification-${notification.type}`]" :class="['alert', `alert-${notification.type}`]"
role="alert" role="alert"
aria-live="assertive" aria-live="assertive"
aria-atomic="true" aria-atomic="true"
> >
<div class="notification-content"> <div class="alert-content">
<span class="notification-icon" v-if="getIcon(notification.type)"> <span class="icon" v-if="getIcon(notification.type)">
<!-- Basic SVG Icons - replace with your preferred icon set or library --> <!-- Basic SVG Icons - replace with your preferred icon set or library -->
<svg v-if="notification.type === 'success'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="20" height="20"><path d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg> <svg v-if="notification.type === 'success'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="20" height="20"><path d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
<svg v-if="notification.type === 'error'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="20" height="20"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/></svg> <svg v-if="notification.type === 'error'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="20" height="20"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/></svg>
@ -21,10 +21,10 @@
</div> </div>
<button <button
@click="store.removeNotification(notification.id)" @click="store.removeNotification(notification.id)"
class="notification-close-button" class="alert-close-btn"
aria-label="Close notification" aria-label="Close notification"
> >
× <!-- Simple 'x' close button --> <span class="icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="18" height="18"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></span>
</button> </button>
</div> </div>
</transition-group> </transition-group>
@ -37,105 +37,107 @@ import { useNotificationStore, type Notification } from '@/stores/notifications'
const store = useNotificationStore(); const store = useNotificationStore();
const getIcon = (type: Notification['type']) => { const getIcon = (type: Notification['type']) => {
// You can extend this or use a more sophisticated icon system // This function now primarily determines if an icon should be shown,
const icons = { // as valerie-ui might handle specific icons via CSS or a global icon system.
success: 'check_circle', // Material icon names or SVG paths // For this example, we're still providing SVGs directly.
error: 'error', // valerie-ui .icon class will style the SVG size/alignment.
warning: 'warning', const iconMap = {
info: 'info', success: true,
error: true,
warning: true,
info: true,
}; };
return icons[type]; // For the SVG example, we just check the type return iconMap[type];
}; };
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
// Overriding valerie-ui defaults or adding custom positioning
.notification-container { .notification-container {
position: fixed; position: fixed;
top: 20px; top: 20px;
right: 20px; right: 20px;
z-index: 9999; z-index: 9999; // Ensure it's above other valerie-ui components if necessary
width: 320px; // Or max-width width: 350px; // Adjusted width to better fit valerie-ui alert style
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; // Spacing between notifications gap: 12px;
} }
.notification-item { // Use valerie-ui's .alert class for base styling.
background-color: #fff; // Specific type styles (alert-success, alert-error, etc.) are handled by valerie-ui.scss
color: #333; // We can add overrides here if needed.
padding: 12px 16px; .alert {
border-radius: 6px; // valerie-ui .alert already has padding, border, shadow, etc.
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); // We might want to adjust margin if the gap from .notification-container isn't enough
display: flex; // or if we want to override something specific from valerie-ui's .alert
align-items: center; // For example, if valerie-ui doesn't set margin-bottom on .alert:
justify-content: space-between; // margin-bottom: 0; // Reset if valerie-ui adds margin, rely on gap.
border-left-width: 5px;
border-left-style: solid;
overflow: hidden; // For smooth animation
&.notification-success { // Override icon color if valerie-ui doesn't color them by alert type,
border-left-color: var(--positive-color, #4caf50); // or if our SVGs need specific coloring not handled by `fill="currentColor"` and parent color.
.notification-icon { color: var(--positive-color, #4caf50); } // valerie-ui .alert-<type> should handle border-left-color.
// valerie-ui .icon class is generic, we might need to scope it.
.alert-content > .icon {
// valerie-ui uses .icon class. Check valerie-ui.scss for its styling.
// Assuming valerie-ui's .icon class handles sizing and alignment.
// We need to ensure our SVG icons get the correct color based on notification type.
// valerie-ui .alert-<type> typically sets text color, which currentColor should pick up.
// If not, add specific color rules:
// Example:
// &.alert-success .icon { color: var(--success); } // If --success is defined in valerie or globally
// &.alert-error .icon { color: var(--danger); }
// &.alert-warning .icon { color: var(--warning); }
// &.alert-info .icon { color: var(--secondary-accent); } // Match valerie-ui's alert-info
// The SVGs provided use fill="currentColor", so they should inherit from the parent.
// The .alert-<type> classes in valerie-ui.scss set border-left-color but not necessarily text or icon color.
// Let's ensure icons match the left border color for consistency, if not already handled.
} }
&.notification-error {
border-left-color: var(--negative-color, #f44336); // If valerie-ui .alert-content needs adjustment
.notification-icon { color: var(--negative-color, #f44336); } .alert-content {
// display: flex; align-items: center; flex-grow: 1; is usually in valerie's .alert-content
.notification-message {
font-size: 0.95rem; // Match valerie-ui's typical text size or adjust
line-height: 1.5;
margin-left: 0.5em; // Space between icon and message if icon exists
}
} }
&.notification-warning {
border-left-color: var(--warning-color, #ff9800); // Style for the close button based on valerie-ui's .alert-close-btn
.notification-icon { color: var(--warning-color, #ff9800); } .alert-close-btn {
} // valerie-ui's .alert-close-btn should handle most styling.
&.notification-info { // We might need to adjust padding or alignment if it doesn't look right
border-left-color: var(--info-color, #2196f3); // with our custom notification container.
.notification-icon { color: var(--info-color, #2196f3); } // The provided SVG for close button is wrapped in a span.icon, let's style that.
.icon {
display: inline-flex; // Ensure icon is aligned
align-items: center;
justify-content: center;
// valerie-ui's .alert-close-btn might already style the icon color on hover.
}
} }
} }
.notification-content {
display: flex;
align-items: center;
flex-grow: 1;
}
.notification-icon { // Transitions for the list - these should still work fine.
margin-right: 10px; // Ensure the transitions target the .alert class now.
display: flex; // To align SVG properly
align-items: center;
// SVGs inside will inherit the color from the parent rule
}
.notification-message {
font-size: 0.9rem;
line-height: 1.4;
}
.notification-close-button {
background: none;
border: none;
color: #aaa;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
padding: 0 0 0 10px; // Add some space to the left
margin-left: auto; // Pushes it to the right within flex
&:hover {
color: #777;
}
}
// Transitions for the list
.notification-list-enter-active, .notification-list-enter-active,
.notification-list-leave-active { .notification-list-leave-active {
transition: all 0.4s cubic-bezier(0.68, -0.55, 0.27, 1.55); // A bit bouncy transition: all 0.4s cubic-bezier(0.68, -0.55, 0.27, 1.55);
} }
.notification-list-enter-from, .notification-list-enter-from,
.notification-list-leave-to { .notification-list-leave-to {
opacity: 0; opacity: 0;
transform: translateX(100%); // Slide in from right transform: translateX(100%);
} }
.notification-list-leave-active { .notification-list-leave-active {
position: absolute; // Important for smooth leave transitions in a list position: absolute;
width: calc(100% - 32px); // Adjust based on padding if item width is not fixed // Ensure width calculation is correct if items have variable width or valerie-ui adds padding/margin
// This might need to be calc(100%) if .alert is full width of its slot in notification-container.
// Or if valerie-ui .alert has its own padding that affects its outer width.
// For now, assuming .alert takes the width of the slot provided by transition-group.
width: 100%; // Re-evaluate if needed after valerie styling.
} }
</style> </style>

View File

@ -1,11 +1,11 @@
<template> <template>
<div class="main-layout"> <div class="main-layout">
<header class="app-header"> <header class="app-header">
<div class="toolbar-title">Mooo</div> <div class="toolbar-title">mitlist</div>
<div class="user-menu" v-if="authStore.isAuthenticated"> <div class="user-menu" v-if="authStore.isAuthenticated">
<button @click="toggleUserMenu" class="user-menu-button"> <button @click="toggleUserMenu" class="user-menu-button">
<!-- Placeholder for user icon --> <!-- Placeholder for user icon -->
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#FFFFFF"><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> <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>
</button> </button>
<div v-if="userMenuOpen" class="dropdown-menu" ref="userMenuDropdown"> <div v-if="userMenuOpen" class="dropdown-menu" ref="userMenuDropdown">
<a href="#" @click.prevent="handleLogout">Logout</a> <a href="#" @click.prevent="handleLogout">Logout</a>
@ -93,8 +93,10 @@ const handleLogout = async () => {
} }
.toolbar-title { .toolbar-title {
font-size: 1.25rem; font-size: 1.5rem;
font-weight: 500; font-weight: 500;
letter-spacing: 0.1ch;
color: var(--primary);
} }
.user-menu { .user-menu {
@ -104,7 +106,7 @@ const handleLogout = async () => {
.user-menu-button { .user-menu-button {
background: none; background: none;
border: none; border: none;
color: white; color: var(--primary);
cursor: pointer; cursor: pointer;
padding: 0.5rem; padding: 0.5rem;
border-radius: 50%; border-radius: 50%;
@ -120,7 +122,8 @@ const handleLogout = async () => {
position: absolute; position: absolute;
right: 0; right: 0;
top: calc(100% + 5px); top: calc(100% + 5px);
background-color: white; color: var(--primary);
background-color: #f3f3f3;
border: 1px solid #ddd; border: 1px solid #ddd;
border-radius: 4px; border-radius: 4px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15); box-shadow: 0 2px 8px rgba(0,0,0,0.15);

View File

@ -10,6 +10,7 @@ import './assets/main.scss';
// API client (from your axios boot file) // 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'; // Renamed from boot/axios to services/api
import { useAuthStore } from '@/stores/auth';
// Vue I18n setup (from your i18n boot file) // Vue I18n setup (from your i18n boot file)
// export type MessageLanguages = keyof typeof messages; // export type MessageLanguages = keyof typeof messages;
@ -29,8 +30,18 @@ import { api, globalAxios } from '@/services/api'; // Renamed from boot/axios to
// }); // });
const app = createApp(App); const app = createApp(App);
const pinia = createPinia();
app.use(pinia);
// 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(createPinia());
app.use(router); app.use(router);
// app.use(i18n); // app.use(i18n);

View File

@ -1,44 +1,70 @@
<template> <template>
<main class="container page-padding"> <main class="container page-padding">
<div v-if="loading" class="text-center"> <div v-if="loading" class="text-center">
<div class="spinner-dots" role="status"><span/><span/><span/></div> <div class="spinner-dots" role="status"><span /><span /><span /></div>
<p>Loading group details...</p> <p>Loading group details...</p>
</div> </div>
<div v-else-if="error" class="alert alert-error" role="alert"> <div v-else-if="error" class="alert alert-error" role="alert">
<div class="alert-content"> <div class="alert-content">
<svg class="icon" aria-hidden="true"><use xlink:href="#icon-alert-triangle" /></svg> <svg class="icon" aria-hidden="true">
<use xlink:href="#icon-alert-triangle" />
</svg>
{{ error }} {{ error }}
</div> </div>
</div> </div>
<div v-else-if="group"> <div v-else-if="group">
<h1 class="mb-3">Group: {{ group.name }}</h1> <h1 class="mb-3">Group: {{ group.name }}</h1>
<!-- Group Members Section -->
<div class="card mt-3">
<div class="card-header">
<h3>Group Members</h3>
</div>
<div class="card-body">
<div v-if="group.members && group.members.length > 0" class="members-list">
<div v-for="member in group.members" :key="member.id" class="member-item">
<div class="member-info">
<span class="member-name">{{ member.email }}</span>
<span class="member-role" :class="member.role?.toLowerCase()">{{ member.role || 'Member' }}</span>
</div>
<button v-if="canRemoveMember(member)" class="btn btn-danger btn-sm" @click="removeMember(member.id)"
:disabled="removingMember === member.id">
<span v-if="removingMember === member.id" class="spinner-dots-sm"
role="status"><span /><span /><span /></span>
Remove
</button>
</div>
</div>
<div v-else class="text-muted">
No members found.
</div>
</div>
</div>
<!-- Placeholder for lists related to this group -->
<div class="mt-4">
<ListsPage :group-id="groupId" />
</div>
<!-- Invite Members Section --> <!-- Invite Members Section -->
<div class="card mt-3"> <div class="card mt-3">
<div class="card-header"> <div class="card-header">
<h3>Invite Members</h3> <h3>Invite Members</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
<button <button class="btn btn-secondary" @click="generateInviteCode" :disabled="generatingInvite">
class="btn btn-secondary" <span v-if="generatingInvite" class="spinner-dots-sm" role="status"><span /><span /><span /></span>
@click="generateInviteCode"
:disabled="generatingInvite"
>
<span v-if="generatingInvite" class="spinner-dots-sm" role="status"><span/><span/><span/></span>
Generate Invite Code Generate Invite Code
</button> </button>
<div v-if="inviteCode" class="form-group mt-2"> <div v-if="inviteCode" class="form-group mt-2">
<label for="inviteCodeInput" class="form-label">Invite Code:</label> <label for="inviteCodeInput" class="form-label">Invite Code:</label>
<div class="flex items-center"> <div class="flex items-center">
<input <input id="inviteCodeInput" type="text" :value="inviteCode" class="form-input flex-grow" readonly />
id="inviteCodeInput" <button class="btn btn-neutral btn-icon-only ml-1" @click="copyInviteCodeHandler"
type="text" aria-label="Copy invite code">
:value="inviteCode" <svg class="icon">
class="form-input flex-grow" <use xlink:href="#icon-clipboard"></use>
readonly </svg> <!-- Assuming #icon-clipboard is 'content_copy' -->
/>
<button class="btn btn-neutral btn-icon-only ml-1" @click="copyInviteCodeHandler" aria-label="Copy invite code">
<svg class="icon"><use xlink:href="#icon-clipboard"></use></svg> <!-- Assuming #icon-clipboard is 'content_copy' -->
</button> </button>
</div> </div>
<p v-if="copySuccess" class="form-success-text mt-1">Invite code copied to clipboard!</p> <p v-if="copySuccess" class="form-success-text mt-1">Invite code copied to clipboard!</p>
@ -46,13 +72,8 @@
</div> </div>
</div> </div>
<!-- Placeholder for lists related to this group -->
<div class="mt-4">
<h2>Lists in this Group</h2>
<ListsPage :group-id="groupId" />
</div>
</div> </div>
<div v-else class="alert alert-info" role="status"> <div v-else class="alert alert-info" role="status">
<div class="alert-content">Group not found or an error occurred.</div> <div class="alert-content">Group not found or an error occurred.</div>
</div> </div>
@ -68,13 +89,19 @@ import ListsPage from './ListsPage.vue'; // Import ListsPage
import { useNotificationStore } from '@/stores/notifications'; import { useNotificationStore } from '@/stores/notifications';
interface Group { interface Group {
id: string | number; // API might return number id: string | number;
name: string; name: string;
// other properties if needed members?: GroupMember[];
}
interface GroupMember {
id: number;
email: string;
role?: string;
} }
const props = defineProps<{ const props = defineProps<{
id: string; // From router param, always string id: string;
}>(); }>();
// const route = useRoute(); // const route = useRoute();
@ -87,11 +114,14 @@ const error = ref<string | null>(null);
const inviteCode = ref<string | null>(null); const inviteCode = ref<string | null>(null);
const generatingInvite = ref(false); const generatingInvite = ref(false);
const copySuccess = ref(false); const copySuccess = ref(false);
const removingMember = ref<number | null>(null);
// groupId is directly from props.id now, which comes from the route path param // groupId is directly from props.id now, which comes from the route path param
const groupId = computed(() => props.id); const groupId = computed(() => props.id);
const { copy, copied, isSupported: clipboardIsSupported } = useClipboard({ source: inviteCode }); const { copy, copied, isSupported: clipboardIsSupported } = useClipboard({
source: computed(() => inviteCode.value || '')
});
const fetchGroupDetails = async () => { const fetchGroupDetails = async () => {
if (!groupId.value) return; if (!groupId.value) return;
@ -146,6 +176,33 @@ const copyInviteCodeHandler = async () => {
} }
}; };
const canRemoveMember = (member: GroupMember): boolean => {
// Only allow removing members if the current user is the owner
// and the member is not the owner themselves
return group.value?.members?.some(m => m.role === 'owner' && m.id === member.id) === false;
};
const removeMember = async (memberId: number) => {
if (!groupId.value) return;
removingMember.value = memberId;
try {
await apiClient.delete(API_ENDPOINTS.GROUPS.MEMBER(String(groupId.value), String(memberId)));
// Refresh group details to update the members list
await fetchGroupDetails();
notificationStore.addNotification({
message: 'Member removed successfully',
type: 'success'
});
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to remove member';
console.error('Error removing member:', err);
notificationStore.addNotification({ message, type: 'error' });
} finally {
removingMember.value = null;
}
};
onMounted(() => { onMounted(() => {
fetchGroupDetails(); fetchGroupDetails();
}); });
@ -155,17 +212,89 @@ onMounted(() => {
.page-padding { .page-padding {
padding: 1rem; padding: 1rem;
} }
.mt-1 { margin-top: 0.5rem; }
.mt-2 { margin-top: 1rem; } .mt-1 {
.mt-3 { margin-top: 1.5rem; } margin-top: 0.5rem;
.mt-4 { margin-top: 2rem; } }
.mb-3 { margin-bottom: 1.5rem; }
.ml-1 { margin-left: 0.25rem; } /* Adjusted from Valerie UI for tighter fit */ .mt-2 {
margin-top: 1rem;
}
.mt-3 {
margin-top: 1.5rem;
}
.mt-4 {
margin-top: 2rem;
}
.mb-3 {
margin-bottom: 1.5rem;
}
.ml-1 {
margin-left: 0.25rem;
}
/* Adjusted from Valerie UI for tighter fit */
.form-success-text { .form-success-text {
color: var(--success); /* Or a darker green for text */ color: var(--success);
/* Or a darker green for text */
font-size: 0.9rem; font-size: 0.9rem;
font-weight: bold; font-weight: bold;
} }
.flex-grow { flex-grow: 1; }
.flex-grow {
flex-grow: 1;
}
/* Members list styles */
.members-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.member-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
border-radius: 0.25rem;
background-color: var(--surface-2);
}
.member-info {
display: flex;
align-items: center;
gap: 0.75rem;
}
.member-name {
font-weight: 500;
}
.member-role {
font-size: 0.875rem;
padding: 0.25rem 0.5rem;
border-radius: 1rem;
background-color: var(--surface-3);
}
.member-role.owner {
background-color: var(--primary);
color: white;
}
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
}
.text-muted {
color: var(--text-2);
font-style: italic;
}
</style> </style>

View File

@ -3,16 +3,51 @@
<div class="flex justify-between items-center mb-3"> <div class="flex justify-between items-center mb-3">
<h1>Your Groups</h1> <h1>Your Groups</h1>
<button class="btn btn-primary" @click="openCreateGroupDialog"> <button class="btn btn-primary" @click="openCreateGroupDialog">
<svg class="icon" aria-hidden="true"><use xlink:href="#icon-plus" /></svg> <svg class="icon" aria-hidden="true">
<use xlink:href="#icon-plus" />
</svg>
Create Group Create Group
</button> </button>
</div> </div>
<!-- Join Group Section (using details/summary for expansion) -->
<div v-if="loading" class="text-center">
<div class="spinner-dots" role="status"><span /><span /><span /></div>
<p>Loading groups...</p>
</div>
<div v-else-if="fetchError" class="alert alert-error" role="alert">
<div class="alert-content">
<svg class="icon" aria-hidden="true">
<use xlink:href="#icon-alert-triangle" />
</svg>
{{ fetchError }}
</div>
</div>
<ul v-else-if="groups.length" class="item-list">
<li v-for="group in groups" :key="group.id" class="list-item interactive-list-item" @click="selectGroup(group)"
@keydown.enter="selectGroup(group)" tabindex="0">
<div class="list-item-content">
<span class="item-text">{{ group.name }}</span>
<!-- Could add more details here if needed -->
</div>
</li>
</ul>
<div v-else class="card empty-state-card">
<svg class="icon icon-lg" aria-hidden="true">
<use xlink:href="#icon-clipboard" />
</svg>
<h3>No Groups Yet!</h3>
<p>You are not a member of any groups yet. Create one or join using an invite code.</p>
</div>
<details class="card mb-3"> <details class="card mb-3">
<summary class="card-header flex items-center cursor-pointer" style="display: flex; justify-content: space-between;"> <summary class="card-header flex items-center cursor-pointer"
style="display: flex; justify-content: space-between;">
<h3> <h3>
<svg class="icon" aria-hidden="true"><use xlink:href="#icon-user" /></svg> <!-- Placeholder icon --> <svg class="icon" aria-hidden="true">
<use xlink:href="#icon-user" />
</svg> <!-- Placeholder icon -->
Join a Group with Invite Code Join a Group with Invite Code
</h3> </h3>
<span class="expand-icon" aria-hidden="true"></span> <!-- Basic expand indicator --> <span class="expand-icon" aria-hidden="true"></span> <!-- Basic expand indicator -->
@ -21,18 +56,11 @@
<form @submit.prevent="handleJoinGroup" class="flex items-center" style="gap: 0.5rem;"> <form @submit.prevent="handleJoinGroup" class="flex items-center" style="gap: 0.5rem;">
<div class="form-group flex-grow" style="margin-bottom: 0;"> <div class="form-group flex-grow" style="margin-bottom: 0;">
<label for="joinInviteCodeInput" class="sr-only">Enter Invite Code</label> <label for="joinInviteCodeInput" class="sr-only">Enter Invite Code</label>
<input <input type="text" id="joinInviteCodeInput" v-model="inviteCodeToJoin" class="form-input"
type="text" placeholder="Enter Invite Code" required ref="joinInviteCodeInputRef" />
id="joinInviteCodeInput"
v-model="inviteCodeToJoin"
class="form-input"
placeholder="Enter Invite Code"
required
ref="joinInviteCodeInputRef"
/>
</div> </div>
<button type="submit" class="btn btn-secondary" :disabled="joiningGroup"> <button type="submit" class="btn btn-secondary" :disabled="joiningGroup">
<span v-if="joiningGroup" class="spinner-dots-sm" role="status"><span/><span/><span/></span> <span v-if="joiningGroup" class="spinner-dots-sm" role="status"><span /><span /><span /></span>
Join Join
</button> </button>
</form> </form>
@ -40,65 +68,31 @@
</div> </div>
</details> </details>
<div v-if="loading" class="text-center">
<div class="spinner-dots" role="status"><span/><span/><span/></div>
<p>Loading groups...</p>
</div>
<div v-else-if="fetchError" class="alert alert-error" role="alert">
<div class="alert-content">
<svg class="icon" aria-hidden="true"><use xlink:href="#icon-alert-triangle" /></svg>
{{ fetchError }}
</div>
</div>
<ul v-else-if="groups.length" class="item-list">
<li
v-for="group in groups"
:key="group.id"
class="list-item interactive-list-item"
@click="selectGroup(group)"
@keydown.enter="selectGroup(group)"
tabindex="0"
>
<div class="list-item-content">
<span class="item-text">{{ group.name }}</span>
<!-- Could add more details here if needed -->
</div>
</li>
</ul>
<div v-else class="card empty-state-card">
<svg class="icon icon-lg" aria-hidden="true"><use xlink:href="#icon-clipboard" /></svg>
<h3>No Groups Yet!</h3>
<p>You are not a member of any groups yet. Create one or join using an invite code.</p>
</div>
<!-- Create Group Dialog --> <!-- Create Group Dialog -->
<div v-if="showCreateGroupDialog" class="modal-backdrop open" @click.self="closeCreateGroupDialog"> <div v-if="showCreateGroupDialog" class="modal-backdrop open" @click.self="closeCreateGroupDialog">
<div class="modal-container" ref="createGroupModalRef" role="dialog" aria-modal="true" aria-labelledby="createGroupTitle"> <div class="modal-container" ref="createGroupModalRef" role="dialog" aria-modal="true"
aria-labelledby="createGroupTitle">
<div class="modal-header"> <div class="modal-header">
<h3 id="createGroupTitle">Create New Group</h3> <h3 id="createGroupTitle">Create New Group</h3>
<button class="close-button" @click="closeCreateGroupDialog" aria-label="Close"> <button class="close-button" @click="closeCreateGroupDialog" aria-label="Close">
<svg class="icon" aria-hidden="true"><use xlink:href="#icon-close" /></svg> <svg class="icon" aria-hidden="true">
<use xlink:href="#icon-close" />
</svg>
</button> </button>
</div> </div>
<form @submit.prevent="handleCreateGroup"> <form @submit.prevent="handleCreateGroup">
<div class="modal-body"> <div class="modal-body">
<div class="form-group"> <div class="form-group">
<label for="newGroupNameInput" class="form-label">Group Name</label> <label for="newGroupNameInput" class="form-label">Group Name</label>
<input <input type="text" id="newGroupNameInput" v-model="newGroupName" class="form-input" required
type="text" ref="newGroupNameInputRef" />
id="newGroupNameInput"
v-model="newGroupName"
class="form-input"
required
ref="newGroupNameInputRef"
/>
<p v-if="createGroupFormError" class="form-error-text">{{ createGroupFormError }}</p> <p v-if="createGroupFormError" class="form-error-text">{{ createGroupFormError }}</p>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-neutral" @click="closeCreateGroupDialog">Cancel</button> <button type="button" class="btn btn-neutral" @click="closeCreateGroupDialog">Cancel</button>
<button type="submit" class="btn btn-primary ml-2" :disabled="creatingGroup"> <button type="submit" class="btn btn-primary ml-2" :disabled="creatingGroup">
<span v-if="creatingGroup" class="spinner-dots-sm" role="status"><span/><span/><span/></span> <span v-if="creatingGroup" class="spinner-dots-sm" role="status"><span /><span /><span /></span>
Create Create
</button> </button>
</div> </div>
@ -212,18 +206,18 @@ const handleJoinGroup = async () => {
try { try {
const response = await apiClient.post(API_ENDPOINTS.INVITES.ACCEPT(inviteCodeToJoin.value)); const response = await apiClient.post(API_ENDPOINTS.INVITES.ACCEPT(inviteCodeToJoin.value));
const joinedGroup = response.data as Group; // Adjust based on actual API response for joined group const joinedGroup = response.data as Group; // Adjust based on actual API response for joined group
if (joinedGroup && joinedGroup.id && joinedGroup.name) { if (joinedGroup && joinedGroup.id && joinedGroup.name) {
// Check if group already in list to prevent duplicates if API returns the group info // Check if group already in list to prevent duplicates if API returns the group info
if (!groups.value.find(g => g.id === joinedGroup.id)) { if (!groups.value.find(g => g.id === joinedGroup.id)) {
groups.value.push(joinedGroup); groups.value.push(joinedGroup);
} }
inviteCodeToJoin.value = ''; inviteCodeToJoin.value = '';
notificationStore.addNotification({ message: `Successfully joined group '${joinedGroup.name}'.`, type: 'success' }); notificationStore.addNotification({ message: `Successfully joined group '${joinedGroup.name}'.`, type: 'success' });
} else { } else {
// If API returns only success message, re-fetch groups // If API returns only success message, re-fetch groups
await fetchGroups(); // Refresh the list of groups await fetchGroups(); // Refresh the list of groups
inviteCodeToJoin.value = ''; inviteCodeToJoin.value = '';
notificationStore.addNotification({ message: `Successfully joined group.`, type: 'success' }); notificationStore.addNotification({ message: `Successfully joined group.`, type: 'success' });
} }
} catch (error: unknown) { } catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Failed to join group. Please check the invite code and try again.'; const message = error instanceof Error ? error.message : 'Failed to join group. Please check the invite code and try again.';
@ -245,39 +239,63 @@ onMounted(() => {
</script> </script>
<style scoped> <style scoped>
.page-padding { padding: 1rem; } .page-padding {
.mb-3 { margin-bottom: 1.5rem; } padding: 1rem;
.mt-1 { margin-top: 0.5rem; } }
.ml-2 { margin-left: 0.5rem; }
.mb-3 {
margin-bottom: 1.5rem;
}
.mt-1 {
margin-top: 0.5rem;
}
.ml-2 {
margin-left: 0.5rem;
}
.interactive-list-item { .interactive-list-item {
cursor: pointer; cursor: pointer;
transition: background-color var(--transition-speed) var(--transition-ease-out); transition: background-color var(--transition-speed) var(--transition-ease-out);
} }
.interactive-list-item:hover, .interactive-list-item:hover,
.interactive-list-item:focus-visible { .interactive-list-item:focus-visible {
background-color: rgba(0,0,0,0.03); background-color: rgba(0, 0, 0, 0.03);
outline: var(--focus-outline); outline: var(--focus-outline);
outline-offset: -3px; /* Adjust to be inside the border */ outline-offset: -3px;
/* Adjust to be inside the border */
} }
.form-error-text { .form-error-text {
color: var(--danger); color: var(--danger);
font-size: 0.85rem; font-size: 0.85rem;
} }
.flex-grow { flex-grow: 1; }
details > summary { .flex-grow {
list-style: none; /* Hide default marker */ flex-grow: 1;
} }
details > summary::-webkit-details-marker {
display: none; /* Hide default marker for Chrome */ details>summary {
list-style: none;
/* Hide default marker */
} }
details>summary::-webkit-details-marker {
display: none;
/* Hide default marker for Chrome */
}
.expand-icon { .expand-icon {
transition: transform 0.2s ease-in-out; transition: transform 0.2s ease-in-out;
} }
details[open] .expand-icon { details[open] .expand-icon {
transform: rotate(180deg); transform: rotate(180deg);
} }
.cursor-pointer { cursor: pointer; }
.cursor-pointer {
cursor: pointer;
}
</style> </style>

View File

@ -3,48 +3,52 @@
<h1 class="mb-3">{{ pageTitle }}</h1> <h1 class="mb-3">{{ pageTitle }}</h1>
<div v-if="loading" class="text-center"> <div v-if="loading" class="text-center">
<div class="spinner-dots" role="status"><span/><span/><span/></div> <div class="spinner-dots" role="status"><span /><span /><span /></div>
<p>Loading lists...</p> <p>Loading lists...</p>
</div> </div>
<div v-else-if="error" class="alert alert-error mb-3" role="alert"> <div v-else-if="error" class="alert alert-error mb-3" role="alert">
<div class="alert-content"> <div class="alert-content">
<svg class="icon" aria-hidden="true"><use xlink:href="#icon-alert-triangle" /></svg> <svg class="icon" aria-hidden="true">
<use xlink:href="#icon-alert-triangle" />
</svg>
{{ error }} {{ error }}
</div> </div>
<button type="button" class="btn btn-sm btn-danger" @click="fetchListsAndGroups">Retry</button> <button type="button" class="btn btn-sm btn-danger" @click="fetchListsAndGroups">Retry</button>
</div> </div>
<div v-else-if="filteredLists.length === 0" class="card empty-state-card"> <div v-else-if="lists.length === 0" class="card empty-state-card">
<svg class="icon icon-lg" aria-hidden="true"><use xlink:href="#icon-clipboard" /></svg> <svg class="icon icon-lg" aria-hidden="true">
<use xlink:href="#icon-clipboard" />
</svg>
<h3>{{ noListsMessage }}</h3> <h3>{{ noListsMessage }}</h3>
<p v-if="!currentGroupId">Create a personal list or join a group to see shared lists.</p> <p v-if="!currentGroupId">Create a personal list or join a group to see shared lists.</p>
<p v-else>This group doesn't have any lists yet.</p> <p v-else>This group doesn't have any lists yet.</p>
<button class="btn btn-primary mt-2" @click="showCreateModal = true"> <button class="btn btn-primary mt-2" @click="showCreateModal = true">
<svg class="icon" aria-hidden="true"><use xlink:href="#icon-plus" /></svg> <svg class="icon" aria-hidden="true">
<use xlink:href="#icon-plus" />
</svg>
Create New List Create New List
</button> </button>
</div> </div>
<ul v-else class="item-list"> <ul v-else class="item-list">
<li <li v-for="list in lists" :key="list.id" class="list-item interactive-list-item" tabindex="0"
v-for="list in filteredLists" @click="navigateToList(list.id)" @keydown.enter="navigateToList(list.id)">
:key="list.id"
class="list-item interactive-list-item"
tabindex="0"
@click="navigateToList(list.id)"
@keydown.enter="navigateToList(list.id)"
>
<div class="list-item-content"> <div class="list-item-content">
<div class="list-item-main" style="flex-direction: column; align-items: flex-start;"> <div class="list-item-main" style="flex-direction: column; align-items: flex-start;">
<span class="item-text" style="font-size: 1.1rem; font-weight: bold;">{{ list.name }}</span> <span class="item-text" style="font-size: 1.1rem; font-weight: bold;">{{ list.name }}</span>
<small class="item-caption">{{ list.description || 'No description' }}</small> <small class="item-caption">{{ list.description || 'No description' }}</small>
<small v-if="!list.group_id && !props.groupId" class="item-caption icon-caption"> <small v-if="!list.group_id && !props.groupId" class="item-caption icon-caption">
<svg class="icon icon-sm"><use xlink:href="#icon-user"/></svg> Personal List <svg class="icon icon-sm">
<use xlink:href="#icon-user" />
</svg> Personal List
</small> </small>
<small v-if="list.group_id && !props.groupId" class="item-caption icon-caption"> <small v-if="list.group_id && !props.groupId" class="item-caption icon-caption">
<svg class="icon icon-sm"><use xlink:href="#icon-user"/></svg> <!-- Placeholder, group icon not in Valerie --> <svg class="icon icon-sm">
Group List ({{ getGroupName(list.group_id) || `ID: ${list.group_id}`}}) <use xlink:href="#icon-user" />
</svg> <!-- Placeholder, group icon not in Valerie -->
Group List ({{ getGroupName(list.group_id) || `ID: ${list.group_id}` }})
</small> </small>
</div> </div>
<div class="list-item-details" style="flex-direction: column; align-items: flex-end;"> <div class="list-item-details" style="flex-direction: column; align-items: flex-end;">
@ -60,24 +64,18 @@
</ul> </ul>
<div class="page-sticky-bottom-right"> <div class="page-sticky-bottom-right">
<button <button class="btn btn-primary btn-icon-only" style="width: 56px; height: 56px; border-radius: 50%; padding: 0;"
class="btn btn-primary btn-icon-only" @click="showCreateModal = true" :aria-label="currentGroupId ? 'Create Group List' : 'Create List'"
style="width: 56px; height: 56px; border-radius: 50%; padding: 0;" data-tooltip="Create New List">
@click="showCreateModal = true" <svg class="icon icon-lg" style="margin-right:0;">
:aria-label="currentGroupId ? 'Create Group List' : 'Create List'" <use xlink:href="#icon-plus" />
data-tooltip="Create New List" </svg>
>
<svg class="icon icon-lg" style="margin-right:0;"><use xlink:href="#icon-plus" /></svg>
</button> </button>
<!-- Basic Tooltip (requires JS from Valerie UI example to function on hover/focus) --> <!-- Basic Tooltip (requires JS from Valerie UI example to function on hover/focus) -->
<!-- <span class="tooltip-text" role="tooltip">{{ currentGroupId ? 'Create Group List' : 'Create List' }}</span> --> <!-- <span class="tooltip-text" role="tooltip">{{ currentGroupId ? 'Create Group List' : 'Create List' }}</span> -->
</div> </div>
<CreateListModal <CreateListModal v-model="showCreateModal" :groups="availableGroupsForModal" @created="onListCreated" />
v-model="showCreateModal"
:groups="availableGroupsForModal"
@created="onListCreated"
/>
</main> </main>
</template> </template>
@ -139,8 +137,8 @@ const fetchCurrentViewGroupName = async () => {
// Try to find in already fetched groups first // Try to find in already fetched groups first
const found = allFetchedGroups.value.find(g => g.id === currentGroupId.value); const found = allFetchedGroups.value.find(g => g.id === currentGroupId.value);
if (found) { if (found) {
currentViewedGroup.value = found; currentViewedGroup.value = found;
return; return;
} }
// If not found, fetch it specifically (might happen if navigating directly) // If not found, fetch it specifically (might happen if navigating directly)
try { try {
@ -158,7 +156,7 @@ const pageTitle = computed(() => {
? `Lists for ${currentViewedGroup.value.name}` ? `Lists for ${currentViewedGroup.value.name}`
: `Lists for Group ${currentGroupId.value}`; : `Lists for Group ${currentGroupId.value}`;
} }
return 'All My Lists'; return 'My Lists';
}); });
const noListsMessage = computed(() => { const noListsMessage = computed(() => {
@ -197,13 +195,13 @@ const fetchLists = async () => {
}; };
const fetchListsAndGroups = async () => { const fetchListsAndGroups = async () => {
loading.value = true; loading.value = true;
await Promise.all([ await Promise.all([
fetchLists(), fetchLists(),
fetchAllAccessibleGroups() fetchAllAccessibleGroups()
]); ]);
await fetchCurrentViewGroupName(); // Depends on allFetchedGroups await fetchCurrentViewGroupName(); // Depends on allFetchedGroups
loading.value = false; loading.value = false;
}; };
@ -215,8 +213,8 @@ const availableGroupsForModal = computed(() => {
}); });
const getGroupName = (groupId?: number | null): string | undefined => { const getGroupName = (groupId?: number | null): string | undefined => {
if (!groupId) return undefined; if (!groupId) return undefined;
return allFetchedGroups.value.find(g => g.id === groupId)?.name; return allFetchedGroups.value.find(g => g.id === groupId)?.name;
} }
const onListCreated = () => { const onListCreated = () => {
@ -233,24 +231,36 @@ onMounted(() => {
// Watch for changes in groupId (e.g., if used as a component and prop changes) // Watch for changes in groupId (e.g., if used as a component and prop changes)
watch(currentGroupId, () => { watch(currentGroupId, () => {
fetchListsAndGroups(); fetchListsAndGroups();
}); });
</script> </script>
<style scoped> <style scoped>
.page-padding { padding: 1rem; } .page-padding {
.mb-3 { margin-bottom: 1.5rem; } padding: 1rem;
.mt-1 { margin-top: 0.5rem; } }
.mt-2 { margin-top: 1rem; }
.mb-3 {
margin-bottom: 1.5rem;
}
.mt-1 {
margin-top: 0.5rem;
}
.mt-2 {
margin-top: 1rem;
}
.interactive-list-item { .interactive-list-item {
cursor: pointer; cursor: pointer;
transition: background-color var(--transition-speed) var(--transition-ease-out); transition: background-color var(--transition-speed) var(--transition-ease-out);
} }
.interactive-list-item:hover, .interactive-list-item:hover,
.interactive-list-item:focus-visible { .interactive-list-item:focus-visible {
background-color: rgba(0,0,0,0.03); background-color: rgba(0, 0, 0, 0.03);
outline: var(--focus-outline); outline: var(--focus-outline);
outline-offset: -3px; outline-offset: -3px;
} }
@ -261,33 +271,43 @@ watch(currentGroupId, () => {
opacity: 0.7; opacity: 0.7;
margin-top: 0.25rem; margin-top: 0.25rem;
} }
.icon-caption .icon { .icon-caption .icon {
vertical-align: -0.1em; /* Align icon better with text */ vertical-align: -0.1em;
/* Align icon better with text */
} }
.page-sticky-bottom-right { .page-sticky-bottom-right {
position: fixed; position: fixed;
bottom: 1.5rem; bottom: 5rem;
right: 1.5rem; right: 1.5rem;
z-index: 999; /* Below modals */ z-index: 999;
/* Below modals */
} }
.page-sticky-bottom-right .btn { .page-sticky-bottom-right .btn {
box-shadow: var(--shadow-lg); /* Make it pop more */ box-shadow: var(--shadow-lg);
/* Make it pop more */
} }
/* Ensure list item content uses full width for proper layout */ /* Ensure list item content uses full width for proper layout */
.list-item-content { .list-item-content {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
width: 100%; width: 100%;
align-items: flex-start; /* Align items to top if they wrap */ align-items: flex-start;
/* Align items to top if they wrap */
} }
.list-item-main { .list-item-main {
flex-grow: 1; flex-grow: 1;
margin-right: 1rem; /* Space before details */ margin-right: 1rem;
/* Space before details */
} }
.list-item-details { .list-item-details {
flex-shrink: 0; /* Prevent badges from shrinking */ flex-shrink: 0;
text-align: right; /* Prevent badges from shrinking */
text-align: right;
} }
</style> </style>

View File

@ -1,5 +1,7 @@
import axios from 'axios'; import axios from 'axios';
import { API_BASE_URL } from '@/config/api-config'; // api-config.ts can be moved to src/config/ import { API_BASE_URL } from '@/config/api-config'; // api-config.ts can be moved to src/config/
import router from '@/router'; // Import the router instance
import { useAuthStore } from '@/stores/auth'; // Import the auth store
// Create axios instance // Create axios instance
const api = axios.create({ const api = axios.create({
@ -28,35 +30,36 @@ api.interceptors.response.use(
(response) => response, (response) => response,
async (error) => { async (error) => {
const originalRequest = error.config; const originalRequest = error.config;
const authStore = useAuthStore(); // Get auth store instance
if (error.response?.status === 401 && !originalRequest._retry) { if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true; originalRequest._retry = true;
try { try {
const refreshToken = localStorage.getItem('refreshToken'); const refreshTokenValue = authStore.refreshToken; // Get from store for consistency
if (!refreshToken) { if (!refreshTokenValue) {
// No refresh token, logout or redirect
// This logic might be better handled in the auth store or router
console.error('No refresh token, redirecting to login'); console.error('No refresh token, redirecting to login');
// Consider emitting an event or calling an auth store action authStore.clearTokens(); // Clear tokens in store and localStorage
// window.location.href = '/auth/login'; // Avoid direct manipulation if possible await router.push('/auth/login');
return Promise.reject(error); return Promise.reject(error);
} }
const response = await api.post('/api/v1/auth/refresh-token', { // Ensure this path is correct // Use the store's refresh mechanism if it already handles API call and token setting
refresh_token: refreshToken, // However, the interceptor is specifically for retrying requests, so direct call is fine here
// as long as it correctly updates tokens for the subsequent retry.
const response = await api.post('/api/v1/auth/refresh', {
refresh_token: refreshTokenValue,
}); });
const { access_token: newAccessToken, refresh_token: newRefreshToken } = response.data; const { access_token: newAccessToken, refresh_token: newRefreshToken } = response.data;
localStorage.setItem('token', newAccessToken); // The authStore.setTokens will update localStorage as well.
if (newRefreshToken) localStorage.setItem('refreshToken', newRefreshToken); // Backend might not always send a new one authStore.setTokens({ access_token: newAccessToken, refresh_token: newRefreshToken });
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
return api(originalRequest); return api(originalRequest);
} catch (refreshError) { } catch (refreshError) {
console.error('Refresh token failed:', refreshError); console.error('Refresh token failed:', refreshError);
localStorage.removeItem('token'); authStore.clearTokens(); // Clear tokens in store and localStorage
localStorage.removeItem('refreshToken'); await router.push('/auth/login');
// window.location.href = '/auth/login'; // Avoid direct manipulation
return Promise.reject(refreshError); return Promise.reject(refreshError);
} }
} }
@ -74,18 +77,18 @@ export { api, globalAxios };
import { API_VERSION, API_ENDPOINTS } from '@/config/api-config'; import { API_VERSION, API_ENDPOINTS } from '@/config/api-config';
export const getApiUrl = (endpoint: string): string => { export const getApiUrl = (endpoint: string): string => {
// Assuming API_BASE_URL already includes http://localhost:8000 // Assuming API_BASE_URL already includes http://localhost:8000
// and endpoint starts with / // and endpoint starts with /
// The original `getApiUrl` added /api/v1, ensure this is correct for your setup // The original `getApiUrl` added /api/v1, ensure this is correct for your setup
return `${API_BASE_URL}/api/${API_VERSION}${endpoint}`; return `${API_BASE_URL}/api/${API_VERSION}${endpoint}`;
}; };
export const apiClient = { export const apiClient = {
get: (endpoint: string, config = {}) => api.get(getApiUrl(endpoint), config), get: (endpoint: string, config = {}) => api.get(getApiUrl(endpoint), config),
post: (endpoint: string, data = {}, config = {}) => api.post(getApiUrl(endpoint), data, config), post: (endpoint: string, data = {}, config = {}) => api.post(getApiUrl(endpoint), data, config),
put: (endpoint: string, data = {}, config = {}) => api.put(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), patch: (endpoint: string, data = {}, config = {}) => api.patch(getApiUrl(endpoint), data, config),
delete: (endpoint: string, config = {}) => api.delete(getApiUrl(endpoint), config), delete: (endpoint: string, config = {}) => api.delete(getApiUrl(endpoint), config),
}; };
export { API_ENDPOINTS }; // Also re-export for convenience export { API_ENDPOINTS }; // Also re-export for convenience

View File

@ -2,6 +2,7 @@ import { API_ENDPOINTS } from '@/config/api-config';
import { apiClient } from '@/services/api'; import { apiClient } from '@/services/api';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import router from '@/router';
interface AuthState { interface AuthState {
@ -10,6 +11,7 @@ interface AuthState {
user: { user: {
email: string; email: string;
name: string; name: string;
id?: string | number;
} | null; } | null;
} }
@ -43,6 +45,34 @@ export const useAuthStore = defineStore('auth', () => {
user.value = userData; user.value = userData;
}; };
const fetchCurrentUser = async () => {
if (!accessToken.value) {
clearTokens();
return null;
}
try {
const response = await apiClient.get(API_ENDPOINTS.USERS.PROFILE);
setUser(response.data);
return response.data;
} catch (error: any) {
console.error('AuthStore: Failed to fetch current user:', error);
if (error.response?.status === 401) {
try {
await refreshAccessToken();
const response = await apiClient.get(API_ENDPOINTS.USERS.PROFILE);
setUser(response.data);
return response.data;
} catch (refreshOrRetryError) {
console.error('AuthStore: Failed to refresh token or fetch user after refresh:', refreshOrRetryError);
return null;
}
} else {
clearTokens();
return null;
}
}
};
const login = async (email: string, password: string) => { const login = async (email: string, password: string) => {
const formData = new FormData(); const formData = new FormData();
formData.append('username', email); formData.append('username', email);
@ -56,6 +86,7 @@ export const useAuthStore = defineStore('auth', () => {
const { access_token, refresh_token } = response.data; const { access_token, refresh_token } = response.data;
setTokens({ access_token, refresh_token }); setTokens({ access_token, refresh_token });
await fetchCurrentUser();
return response.data; return response.data;
}; };
@ -66,6 +97,8 @@ export const useAuthStore = defineStore('auth', () => {
const refreshAccessToken = async () => { const refreshAccessToken = async () => {
if (!refreshToken.value) { if (!refreshToken.value) {
clearTokens();
await router.push('/auth/login');
throw new Error('No refresh token available'); throw new Error('No refresh token available');
} }
@ -74,31 +107,32 @@ export const useAuthStore = defineStore('auth', () => {
refresh_token: refreshToken.value, refresh_token: refreshToken.value,
}); });
const { access_token, refresh_token } = response.data; const { access_token, refresh_token: newRefreshToken } = response.data;
setTokens({ access_token, refresh_token }); setTokens({ access_token, refresh_token: newRefreshToken });
return response.data; return response.data;
} catch (error) { } catch (error) {
console.error('AuthStore: Refresh token failed:', error);
clearTokens(); clearTokens();
await router.push('/auth/login');
throw error; throw error;
} }
}; };
const logout = () => { const logout = async () => {
clearTokens(); clearTokens();
await router.push('/auth/login');
}; };
return { return {
// State
accessToken, accessToken,
refreshToken, refreshToken,
user, user,
// Getters
isAuthenticated, isAuthenticated,
getUser, getUser,
// Actions
setTokens, setTokens,
clearTokens, clearTokens,
setUser, setUser,
fetchCurrentUser,
login, login,
signup, signup,
refreshAccessToken, refreshAccessToken,