commit i guess
This commit is contained in:
parent
227a3d6186
commit
cacfb2a5e8
@ -105,7 +105,7 @@ async def refresh_token(
|
||||
"""
|
||||
Handles access token refresh.
|
||||
- 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")
|
||||
payload = verify_refresh_token(refresh_token_str)
|
||||
@ -127,9 +127,10 @@ async def refresh_token(
|
||||
)
|
||||
|
||||
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(
|
||||
access_token=new_access_token,
|
||||
refresh_token=refresh_token_str,
|
||||
refresh_token=new_refresh_token,
|
||||
token_type=settings.TOKEN_TYPE
|
||||
)
|
@ -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.invite import InviteCodePublic
|
||||
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 invite as crud_invite
|
||||
from app.crud import list as crud_list
|
||||
from app.core.exceptions import (
|
||||
GroupNotFoundError,
|
||||
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}")
|
||||
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
|
@ -23,7 +23,9 @@ from app.core.exceptions import (
|
||||
async def create_list(db: AsyncSession, list_in: ListCreate, creator_id: int) -> ListModel:
|
||||
"""Creates a new list record."""
|
||||
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(
|
||||
name=list_in.name,
|
||||
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.refresh(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:
|
||||
raise DatabaseIntegrityError(f"Failed to create list: {str(e)}")
|
||||
except OperationalError as e:
|
||||
|
@ -38,8 +38,8 @@ async def get_db() -> AsyncSession: # type: ignore
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
# Optionally commit if your endpoints modify data directly
|
||||
# await session.commit() # Usually commit happens within endpoint logic
|
||||
# Commit the transaction if no errors occurred
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
|
@ -1,5 +1,5 @@
|
||||
# app/schemas/group.py
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic import BaseModel, ConfigDict, computed_field
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
|
||||
@ -15,7 +15,25 @@ class GroupPublic(BaseModel):
|
||||
name: str
|
||||
created_by_id: int
|
||||
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)
|
||||
|
||||
|
@ -4,32 +4,22 @@
|
||||
<div class="modal-header">
|
||||
<h3 id="createListModalTitle">Create New List</h3>
|
||||
<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>
|
||||
</div>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="listName" class="form-label">List Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="listName"
|
||||
v-model="listName"
|
||||
class="form-input"
|
||||
required
|
||||
ref="listNameInput"
|
||||
/>
|
||||
<input 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>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description" class="form-label">Description</label>
|
||||
<textarea
|
||||
id="description"
|
||||
v-model="description"
|
||||
class="form-input"
|
||||
rows="3"
|
||||
></textarea>
|
||||
<textarea id="description" v-model="description" class="form-input" rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group" v-if="groups && groups.length > 0">
|
||||
@ -45,7 +35,7 @@
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-neutral" @click="closeModal">Cancel</button>
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
@ -144,7 +134,9 @@ const onSubmit = async () => {
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.ml-2 {
|
||||
margin-left: 0.5rem; /* from Valerie UI utilities */
|
||||
margin-left: 0.5rem;
|
||||
/* from Valerie UI utilities */
|
||||
}
|
||||
</style>
|
@ -4,13 +4,13 @@
|
||||
<div
|
||||
v-for="notification in store.notifications"
|
||||
:key="notification.id"
|
||||
:class="['notification-item', `notification-${notification.type}`]"
|
||||
:class="['alert', `alert-${notification.type}`]"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
aria-atomic="true"
|
||||
>
|
||||
<div class="notification-content">
|
||||
<span class="notification-icon" v-if="getIcon(notification.type)">
|
||||
<div class="alert-content">
|
||||
<span class="icon" v-if="getIcon(notification.type)">
|
||||
<!-- 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 === '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>
|
||||
<button
|
||||
@click="store.removeNotification(notification.id)"
|
||||
class="notification-close-button"
|
||||
class="alert-close-btn"
|
||||
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>
|
||||
</div>
|
||||
</transition-group>
|
||||
@ -37,105 +37,107 @@ import { useNotificationStore, type Notification } from '@/stores/notifications'
|
||||
const store = useNotificationStore();
|
||||
|
||||
const getIcon = (type: Notification['type']) => {
|
||||
// You can extend this or use a more sophisticated icon system
|
||||
const icons = {
|
||||
success: 'check_circle', // Material icon names or SVG paths
|
||||
error: 'error',
|
||||
warning: 'warning',
|
||||
info: 'info',
|
||||
// This function now primarily determines if an icon should be shown,
|
||||
// as valerie-ui might handle specific icons via CSS or a global icon system.
|
||||
// For this example, we're still providing SVGs directly.
|
||||
// valerie-ui .icon class will style the SVG size/alignment.
|
||||
const iconMap = {
|
||||
success: true,
|
||||
error: true,
|
||||
warning: true,
|
||||
info: true,
|
||||
};
|
||||
return icons[type]; // For the SVG example, we just check the type
|
||||
return iconMap[type];
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// Overriding valerie-ui defaults or adding custom positioning
|
||||
.notification-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
width: 320px; // Or max-width
|
||||
z-index: 9999; // Ensure it's above other valerie-ui components if necessary
|
||||
width: 350px; // Adjusted width to better fit valerie-ui alert style
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px; // Spacing between notifications
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
background-color: #fff;
|
||||
color: #333;
|
||||
padding: 12px 16px;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-left-width: 5px;
|
||||
border-left-style: solid;
|
||||
overflow: hidden; // For smooth animation
|
||||
// Use valerie-ui's .alert class for base styling.
|
||||
// Specific type styles (alert-success, alert-error, etc.) are handled by valerie-ui.scss
|
||||
// We can add overrides here if needed.
|
||||
.alert {
|
||||
// valerie-ui .alert already has padding, border, shadow, etc.
|
||||
// We might want to adjust margin if the gap from .notification-container isn't enough
|
||||
// or if we want to override something specific from valerie-ui's .alert
|
||||
// For example, if valerie-ui doesn't set margin-bottom on .alert:
|
||||
// margin-bottom: 0; // Reset if valerie-ui adds margin, rely on gap.
|
||||
|
||||
&.notification-success {
|
||||
border-left-color: var(--positive-color, #4caf50);
|
||||
.notification-icon { color: var(--positive-color, #4caf50); }
|
||||
// Override icon color if valerie-ui doesn't color them by alert type,
|
||||
// or if our SVGs need specific coloring not handled by `fill="currentColor"` and parent color.
|
||||
// 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);
|
||||
.notification-icon { color: var(--negative-color, #f44336); }
|
||||
|
||||
// If valerie-ui .alert-content needs adjustment
|
||||
.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);
|
||||
.notification-icon { color: var(--warning-color, #ff9800); }
|
||||
}
|
||||
&.notification-info {
|
||||
border-left-color: var(--info-color, #2196f3);
|
||||
.notification-icon { color: var(--info-color, #2196f3); }
|
||||
|
||||
// Style for the close button based on valerie-ui's .alert-close-btn
|
||||
.alert-close-btn {
|
||||
// valerie-ui's .alert-close-btn should handle most styling.
|
||||
// We might need to adjust padding or alignment if it doesn't look right
|
||||
// with our custom notification container.
|
||||
// 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 {
|
||||
margin-right: 10px;
|
||||
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
|
||||
// Transitions for the list - these should still work fine.
|
||||
// Ensure the transitions target the .alert class now.
|
||||
.notification-list-enter-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-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(100%); // Slide in from right
|
||||
transform: translateX(100%);
|
||||
}
|
||||
.notification-list-leave-active {
|
||||
position: absolute; // Important for smooth leave transitions in a list
|
||||
width: calc(100% - 32px); // Adjust based on padding if item width is not fixed
|
||||
position: absolute;
|
||||
// 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>
|
@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<div class="main-layout">
|
||||
<header class="app-header">
|
||||
<div class="toolbar-title">Mooo</div>
|
||||
<div class="toolbar-title">mitlist</div>
|
||||
<div class="user-menu" v-if="authStore.isAuthenticated">
|
||||
<button @click="toggleUserMenu" class="user-menu-button">
|
||||
<!-- Placeholder for user icon -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#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>
|
||||
<div v-if="userMenuOpen" class="dropdown-menu" ref="userMenuDropdown">
|
||||
<a href="#" @click.prevent="handleLogout">Logout</a>
|
||||
@ -93,8 +93,10 @@ const handleLogout = async () => {
|
||||
}
|
||||
|
||||
.toolbar-title {
|
||||
font-size: 1.25rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.1ch;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.user-menu {
|
||||
@ -104,7 +106,7 @@ const handleLogout = async () => {
|
||||
.user-menu-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
border-radius: 50%;
|
||||
@ -120,7 +122,8 @@ const handleLogout = async () => {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 5px);
|
||||
background-color: white;
|
||||
color: var(--primary);
|
||||
background-color: #f3f3f3;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
|
@ -10,6 +10,7 @@ import './assets/main.scss';
|
||||
|
||||
// API client (from your axios boot file)
|
||||
import { api, globalAxios } from '@/services/api'; // Renamed from boot/axios to services/api
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
// Vue I18n setup (from your i18n boot file)
|
||||
// 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 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(i18n);
|
||||
|
||||
|
@ -1,44 +1,70 @@
|
||||
<template>
|
||||
<main class="container page-padding">
|
||||
<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>
|
||||
</div>
|
||||
<div v-else-if="error" class="alert alert-error" role="alert">
|
||||
<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 }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="group">
|
||||
<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 -->
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">
|
||||
<h3>Invite Members</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
@click="generateInviteCode"
|
||||
:disabled="generatingInvite"
|
||||
>
|
||||
<span v-if="generatingInvite" class="spinner-dots-sm" role="status"><span/><span/><span/></span>
|
||||
<button class="btn btn-secondary" @click="generateInviteCode" :disabled="generatingInvite">
|
||||
<span v-if="generatingInvite" class="spinner-dots-sm" role="status"><span /><span /><span /></span>
|
||||
Generate Invite Code
|
||||
</button>
|
||||
<div v-if="inviteCode" class="form-group mt-2">
|
||||
<label for="inviteCodeInput" class="form-label">Invite Code:</label>
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
id="inviteCodeInput"
|
||||
type="text"
|
||||
:value="inviteCode"
|
||||
class="form-input flex-grow"
|
||||
readonly
|
||||
/>
|
||||
<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' -->
|
||||
<input id="inviteCodeInput" type="text" :value="inviteCode" class="form-input flex-grow" readonly />
|
||||
<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>
|
||||
</div>
|
||||
<p v-if="copySuccess" class="form-success-text mt-1">Invite code copied to clipboard!</p>
|
||||
@ -46,13 +72,8 @@
|
||||
</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 v-else class="alert alert-info" role="status">
|
||||
<div class="alert-content">Group not found or an error occurred.</div>
|
||||
</div>
|
||||
@ -68,13 +89,19 @@ import ListsPage from './ListsPage.vue'; // Import ListsPage
|
||||
import { useNotificationStore } from '@/stores/notifications';
|
||||
|
||||
interface Group {
|
||||
id: string | number; // API might return number
|
||||
id: string | number;
|
||||
name: string;
|
||||
// other properties if needed
|
||||
members?: GroupMember[];
|
||||
}
|
||||
|
||||
interface GroupMember {
|
||||
id: number;
|
||||
email: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
id: string; // From router param, always string
|
||||
id: string;
|
||||
}>();
|
||||
|
||||
// const route = useRoute();
|
||||
@ -87,11 +114,14 @@ const error = ref<string | null>(null);
|
||||
const inviteCode = ref<string | null>(null);
|
||||
const generatingInvite = 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
|
||||
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 () => {
|
||||
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(() => {
|
||||
fetchGroupDetails();
|
||||
});
|
||||
@ -155,17 +212,89 @@ onMounted(() => {
|
||||
.page-padding {
|
||||
padding: 1rem;
|
||||
}
|
||||
.mt-1 { margin-top: 0.5rem; }
|
||||
.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 */
|
||||
|
||||
.mt-1 {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.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 {
|
||||
color: var(--success); /* Or a darker green for text */
|
||||
color: var(--success);
|
||||
/* Or a darker green for text */
|
||||
font-size: 0.9rem;
|
||||
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>
|
@ -3,16 +3,51 @@
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<h1>Your Groups</h1>
|
||||
<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
|
||||
</button>
|
||||
</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">
|
||||
<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>
|
||||
<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
|
||||
</h3>
|
||||
<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;">
|
||||
<div class="form-group flex-grow" style="margin-bottom: 0;">
|
||||
<label for="joinInviteCodeInput" class="sr-only">Enter Invite Code</label>
|
||||
<input
|
||||
type="text"
|
||||
id="joinInviteCodeInput"
|
||||
v-model="inviteCodeToJoin"
|
||||
class="form-input"
|
||||
placeholder="Enter Invite Code"
|
||||
required
|
||||
ref="joinInviteCodeInputRef"
|
||||
/>
|
||||
<input type="text" id="joinInviteCodeInput" v-model="inviteCodeToJoin" class="form-input"
|
||||
placeholder="Enter Invite Code" required ref="joinInviteCodeInputRef" />
|
||||
</div>
|
||||
<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
|
||||
</button>
|
||||
</form>
|
||||
@ -40,65 +68,31 @@
|
||||
</div>
|
||||
</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 -->
|
||||
<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">
|
||||
<h3 id="createGroupTitle">Create New Group</h3>
|
||||
<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>
|
||||
</div>
|
||||
<form @submit.prevent="handleCreateGroup">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="newGroupNameInput" class="form-label">Group Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="newGroupNameInput"
|
||||
v-model="newGroupName"
|
||||
class="form-input"
|
||||
required
|
||||
ref="newGroupNameInputRef"
|
||||
/>
|
||||
<input type="text" id="newGroupNameInput" v-model="newGroupName" class="form-input" required
|
||||
ref="newGroupNameInputRef" />
|
||||
<p v-if="createGroupFormError" class="form-error-text">{{ createGroupFormError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-neutral" @click="closeCreateGroupDialog">Cancel</button>
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
@ -212,18 +206,18 @@ const handleJoinGroup = async () => {
|
||||
try {
|
||||
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
|
||||
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
|
||||
if (!groups.value.find(g => g.id === joinedGroup.id)) {
|
||||
groups.value.push(joinedGroup);
|
||||
groups.value.push(joinedGroup);
|
||||
}
|
||||
inviteCodeToJoin.value = '';
|
||||
notificationStore.addNotification({ message: `Successfully joined group '${joinedGroup.name}'.`, type: 'success' });
|
||||
} else {
|
||||
// If API returns only success message, re-fetch groups
|
||||
await fetchGroups(); // Refresh the list of groups
|
||||
inviteCodeToJoin.value = '';
|
||||
notificationStore.addNotification({ message: `Successfully joined group.`, type: 'success' });
|
||||
// If API returns only success message, re-fetch groups
|
||||
await fetchGroups(); // Refresh the list of groups
|
||||
inviteCodeToJoin.value = '';
|
||||
notificationStore.addNotification({ message: `Successfully joined group.`, type: 'success' });
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
.page-padding { padding: 1rem; }
|
||||
.mb-3 { margin-bottom: 1.5rem; }
|
||||
.mt-1 { margin-top: 0.5rem; }
|
||||
.ml-2 { margin-left: 0.5rem; }
|
||||
.page-padding {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.mb-3 {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.mt-1 {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.ml-2 {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.interactive-list-item {
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-speed) var(--transition-ease-out);
|
||||
}
|
||||
|
||||
.interactive-list-item:hover,
|
||||
.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-offset: -3px; /* Adjust to be inside the border */
|
||||
outline-offset: -3px;
|
||||
/* Adjust to be inside the border */
|
||||
}
|
||||
|
||||
.form-error-text {
|
||||
color: var(--danger);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.flex-grow { flex-grow: 1; }
|
||||
|
||||
details > summary {
|
||||
list-style: none; /* Hide default marker */
|
||||
.flex-grow {
|
||||
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 {
|
||||
transition: transform 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
details[open] .expand-icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.cursor-pointer { cursor: pointer; }
|
||||
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
@ -3,48 +3,52 @@
|
||||
<h1 class="mb-3">{{ pageTitle }}</h1>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="alert alert-error mb-3" role="alert">
|
||||
<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 }}
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-danger" @click="fetchListsAndGroups">Retry</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredLists.length === 0" class="card empty-state-card">
|
||||
<svg class="icon icon-lg" aria-hidden="true"><use xlink:href="#icon-clipboard" /></svg>
|
||||
<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>
|
||||
<h3>{{ noListsMessage }}</h3>
|
||||
<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>
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul v-else class="item-list">
|
||||
<li
|
||||
v-for="list in filteredLists"
|
||||
:key="list.id"
|
||||
class="list-item interactive-list-item"
|
||||
tabindex="0"
|
||||
@click="navigateToList(list.id)"
|
||||
@keydown.enter="navigateToList(list.id)"
|
||||
>
|
||||
<li v-for="list in lists" :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-main" style="flex-direction: column; align-items: flex-start;">
|
||||
<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 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 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 -->
|
||||
Group List ({{ getGroupName(list.group_id) || `ID: ${list.group_id}`}})
|
||||
<svg class="icon icon-sm">
|
||||
<use xlink:href="#icon-user" />
|
||||
</svg> <!-- Placeholder, group icon not in Valerie -->
|
||||
Group List ({{ getGroupName(list.group_id) || `ID: ${list.group_id}` }})
|
||||
</small>
|
||||
</div>
|
||||
<div class="list-item-details" style="flex-direction: column; align-items: flex-end;">
|
||||
@ -60,24 +64,18 @@
|
||||
</ul>
|
||||
|
||||
<div class="page-sticky-bottom-right">
|
||||
<button
|
||||
class="btn btn-primary btn-icon-only"
|
||||
style="width: 56px; height: 56px; border-radius: 50%; padding: 0;"
|
||||
@click="showCreateModal = true"
|
||||
:aria-label="currentGroupId ? 'Create Group List' : 'Create List'"
|
||||
data-tooltip="Create New List"
|
||||
>
|
||||
<svg class="icon icon-lg" style="margin-right:0;"><use xlink:href="#icon-plus" /></svg>
|
||||
<button class="btn btn-primary btn-icon-only" style="width: 56px; height: 56px; border-radius: 50%; padding: 0;"
|
||||
@click="showCreateModal = true" :aria-label="currentGroupId ? 'Create Group List' : 'Create List'"
|
||||
data-tooltip="Create New List">
|
||||
<svg class="icon icon-lg" style="margin-right:0;">
|
||||
<use xlink:href="#icon-plus" />
|
||||
</svg>
|
||||
</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> -->
|
||||
</div>
|
||||
|
||||
<CreateListModal
|
||||
v-model="showCreateModal"
|
||||
:groups="availableGroupsForModal"
|
||||
@created="onListCreated"
|
||||
/>
|
||||
<CreateListModal v-model="showCreateModal" :groups="availableGroupsForModal" @created="onListCreated" />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@ -139,8 +137,8 @@ const fetchCurrentViewGroupName = async () => {
|
||||
// Try to find in already fetched groups first
|
||||
const found = allFetchedGroups.value.find(g => g.id === currentGroupId.value);
|
||||
if (found) {
|
||||
currentViewedGroup.value = found;
|
||||
return;
|
||||
currentViewedGroup.value = found;
|
||||
return;
|
||||
}
|
||||
// If not found, fetch it specifically (might happen if navigating directly)
|
||||
try {
|
||||
@ -158,7 +156,7 @@ const pageTitle = computed(() => {
|
||||
? `Lists for ${currentViewedGroup.value.name}`
|
||||
: `Lists for Group ${currentGroupId.value}`;
|
||||
}
|
||||
return 'All My Lists';
|
||||
return 'My Lists';
|
||||
});
|
||||
|
||||
const noListsMessage = computed(() => {
|
||||
@ -197,13 +195,13 @@ const fetchLists = async () => {
|
||||
};
|
||||
|
||||
const fetchListsAndGroups = async () => {
|
||||
loading.value = true;
|
||||
await Promise.all([
|
||||
fetchLists(),
|
||||
fetchAllAccessibleGroups()
|
||||
]);
|
||||
await fetchCurrentViewGroupName(); // Depends on allFetchedGroups
|
||||
loading.value = false;
|
||||
loading.value = true;
|
||||
await Promise.all([
|
||||
fetchLists(),
|
||||
fetchAllAccessibleGroups()
|
||||
]);
|
||||
await fetchCurrentViewGroupName(); // Depends on allFetchedGroups
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
|
||||
@ -215,8 +213,8 @@ const availableGroupsForModal = computed(() => {
|
||||
});
|
||||
|
||||
const getGroupName = (groupId?: number | null): string | undefined => {
|
||||
if (!groupId) return undefined;
|
||||
return allFetchedGroups.value.find(g => g.id === groupId)?.name;
|
||||
if (!groupId) return undefined;
|
||||
return allFetchedGroups.value.find(g => g.id === groupId)?.name;
|
||||
}
|
||||
|
||||
const onListCreated = () => {
|
||||
@ -233,24 +231,36 @@ onMounted(() => {
|
||||
|
||||
// Watch for changes in groupId (e.g., if used as a component and prop changes)
|
||||
watch(currentGroupId, () => {
|
||||
fetchListsAndGroups();
|
||||
fetchListsAndGroups();
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-padding { padding: 1rem; }
|
||||
.mb-3 { margin-bottom: 1.5rem; }
|
||||
.mt-1 { margin-top: 0.5rem; }
|
||||
.mt-2 { margin-top: 1rem; }
|
||||
.page-padding {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.mb-3 {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.mt-1 {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.mt-2 {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.interactive-list-item {
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-speed) var(--transition-ease-out);
|
||||
}
|
||||
|
||||
.interactive-list-item:hover,
|
||||
.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-offset: -3px;
|
||||
}
|
||||
@ -261,33 +271,43 @@ watch(currentGroupId, () => {
|
||||
opacity: 0.7;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.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 {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
bottom: 5rem;
|
||||
right: 1.5rem;
|
||||
z-index: 999; /* Below modals */
|
||||
z-index: 999;
|
||||
/* Below modals */
|
||||
}
|
||||
|
||||
.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 */
|
||||
.list-item-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
align-items: flex-start; /* Align items to top if they wrap */
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
align-items: flex-start;
|
||||
/* Align items to top if they wrap */
|
||||
}
|
||||
|
||||
.list-item-main {
|
||||
flex-grow: 1;
|
||||
margin-right: 1rem; /* Space before details */
|
||||
flex-grow: 1;
|
||||
margin-right: 1rem;
|
||||
/* Space before details */
|
||||
}
|
||||
|
||||
.list-item-details {
|
||||
flex-shrink: 0; /* Prevent badges from shrinking */
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
/* Prevent badges from shrinking */
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
@ -1,5 +1,7 @@
|
||||
import axios from 'axios';
|
||||
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
|
||||
const api = axios.create({
|
||||
@ -28,35 +30,36 @@ api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
const authStore = useAuthStore(); // Get auth store instance
|
||||
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
try {
|
||||
const refreshToken = localStorage.getItem('refreshToken');
|
||||
if (!refreshToken) {
|
||||
// No refresh token, logout or redirect
|
||||
// This logic might be better handled in the auth store or router
|
||||
const refreshTokenValue = authStore.refreshToken; // Get from store for consistency
|
||||
if (!refreshTokenValue) {
|
||||
console.error('No refresh token, redirecting to login');
|
||||
// Consider emitting an event or calling an auth store action
|
||||
// window.location.href = '/auth/login'; // Avoid direct manipulation if possible
|
||||
authStore.clearTokens(); // Clear tokens in store and localStorage
|
||||
await router.push('/auth/login');
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
const response = await api.post('/api/v1/auth/refresh-token', { // Ensure this path is correct
|
||||
refresh_token: refreshToken,
|
||||
// Use the store's refresh mechanism if it already handles API call and token setting
|
||||
// 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;
|
||||
localStorage.setItem('token', newAccessToken);
|
||||
if (newRefreshToken) localStorage.setItem('refreshToken', newRefreshToken); // Backend might not always send a new one
|
||||
// The authStore.setTokens will update localStorage as well.
|
||||
authStore.setTokens({ access_token: newAccessToken, refresh_token: newRefreshToken });
|
||||
|
||||
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
|
||||
return api(originalRequest);
|
||||
} catch (refreshError) {
|
||||
console.error('Refresh token failed:', refreshError);
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('refreshToken');
|
||||
// window.location.href = '/auth/login'; // Avoid direct manipulation
|
||||
authStore.clearTokens(); // Clear tokens in store and localStorage
|
||||
await router.push('/auth/login');
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
}
|
||||
@ -74,18 +77,18 @@ export { api, globalAxios };
|
||||
import { API_VERSION, API_ENDPOINTS } from '@/config/api-config';
|
||||
|
||||
export const getApiUrl = (endpoint: string): string => {
|
||||
// Assuming API_BASE_URL already includes http://localhost:8000
|
||||
// and endpoint starts with /
|
||||
// The original `getApiUrl` added /api/v1, ensure this is correct for your setup
|
||||
return `${API_BASE_URL}/api/${API_VERSION}${endpoint}`;
|
||||
// Assuming API_BASE_URL already includes http://localhost:8000
|
||||
// and endpoint starts with /
|
||||
// The original `getApiUrl` added /api/v1, ensure this is correct for your setup
|
||||
return `${API_BASE_URL}/api/${API_VERSION}${endpoint}`;
|
||||
};
|
||||
|
||||
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),
|
||||
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 }; // Also re-export for convenience
|
@ -2,6 +2,7 @@ import { API_ENDPOINTS } from '@/config/api-config';
|
||||
import { apiClient } from '@/services/api';
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
import router from '@/router';
|
||||
|
||||
|
||||
interface AuthState {
|
||||
@ -10,6 +11,7 @@ interface AuthState {
|
||||
user: {
|
||||
email: string;
|
||||
name: string;
|
||||
id?: string | number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
@ -43,6 +45,34 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
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 formData = new FormData();
|
||||
formData.append('username', email);
|
||||
@ -56,6 +86,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
const { access_token, refresh_token } = response.data;
|
||||
setTokens({ access_token, refresh_token });
|
||||
await fetchCurrentUser();
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@ -66,6 +97,8 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
const refreshAccessToken = async () => {
|
||||
if (!refreshToken.value) {
|
||||
clearTokens();
|
||||
await router.push('/auth/login');
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
@ -74,31 +107,32 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
refresh_token: refreshToken.value,
|
||||
});
|
||||
|
||||
const { access_token, refresh_token } = response.data;
|
||||
setTokens({ access_token, refresh_token });
|
||||
const { access_token, refresh_token: newRefreshToken } = response.data;
|
||||
setTokens({ access_token, refresh_token: newRefreshToken });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('AuthStore: Refresh token failed:', error);
|
||||
clearTokens();
|
||||
await router.push('/auth/login');
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
const logout = async () => {
|
||||
clearTokens();
|
||||
await router.push('/auth/login');
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user,
|
||||
// Getters
|
||||
isAuthenticated,
|
||||
getUser,
|
||||
// Actions
|
||||
setTokens,
|
||||
clearTokens,
|
||||
setUser,
|
||||
fetchCurrentUser,
|
||||
login,
|
||||
signup,
|
||||
refreshAccessToken,
|
||||
|
Loading…
Reference in New Issue
Block a user