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.
- 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
)

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.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

View File

@ -23,6 +23,22 @@ from app.core.exceptions import (
async def create_list(db: AsyncSession, list_in: ListCreate, creator_id: int) -> ListModel:
"""Creates a new list record."""
try:
# 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,
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
else:
# If no transaction is active, start one
async with db.begin():
db_list = ListModel(
name=list_in.name,

View File

@ -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

View File

@ -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)

View File

@ -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>

View File

@ -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;
// 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.
// 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.
}
// 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
}
}
// 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: space-between;
border-left-width: 5px;
border-left-style: solid;
overflow: hidden; // For smooth animation
&.notification-success {
border-left-color: var(--positive-color, #4caf50);
.notification-icon { color: var(--positive-color, #4caf50); }
justify-content: center;
// valerie-ui's .alert-close-btn might already style the icon color on hover.
}
&.notification-error {
border-left-color: var(--negative-color, #f44336);
.notification-icon { color: var(--negative-color, #f44336); }
}
&.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); }
}
}
.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>

View File

@ -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);

View File

@ -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);

View File

@ -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>

View File

@ -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>
@ -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>

View File

@ -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) -->
<!-- <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>
@ -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(() => {
@ -239,18 +237,30 @@ watch(currentGroupId, () => {
</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,18 +271,23 @@ 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 */
@ -280,14 +295,19 @@ watch(currentGroupId, () => {
display: flex;
justify-content: space-between;
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 {
flex-grow: 1;
margin-right: 1rem; /* Space before details */
margin-right: 1rem;
/* Space before details */
}
.list-item-details {
flex-shrink: 0; /* Prevent badges from shrinking */
flex-shrink: 0;
/* Prevent badges from shrinking */
text-align: right;
}
</style>

View File

@ -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);
}
}

View File

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