
This commit introduces a comprehensive chore management system, allowing users to create, manage, and track both personal and group chores. Key changes include: - Addition of new API endpoints for personal and group chores in `be/app/api/v1/endpoints/chores.py`. - Implementation of chore models and schemas to support the new functionality in `be/app/models.py` and `be/app/schemas/chore.py`. - Integration of chore services in the frontend to handle API interactions for chore management. - Creation of new Vue components for displaying and managing chores, including `ChoresPage.vue` and `PersonalChoresPage.vue`. - Updates to the router to include chore-related routes and navigation. This feature enhances user collaboration and organization within shared living environments, aligning with the project's goal of streamlining household management.
447 lines
13 KiB
Vue
447 lines
13 KiB
Vue
<template>
|
|
<main class="container page-padding">
|
|
<!-- <h1 class="mb-3">Your Groups</h1> -->
|
|
|
|
<div v-if="fetchError" 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>
|
|
{{ fetchError }}
|
|
</div>
|
|
<button type="button" class="btn btn-sm btn-danger" @click="fetchGroups">Retry</button>
|
|
</div>
|
|
|
|
<div v-else-if="groups.length === 0" 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>
|
|
<button class="btn btn-primary mt-2" @click="openCreateGroupDialog">
|
|
<svg class="icon" aria-hidden="true">
|
|
<use xlink:href="#icon-plus" />
|
|
</svg>
|
|
Create New Group
|
|
</button>
|
|
</div>
|
|
|
|
<div v-else class="mb-3">
|
|
<div class="neo-groups-grid">
|
|
<div v-for="group in groups" :key="group.id" class="neo-group-card" @click="selectGroup(group)">
|
|
<h1 class="neo-group-header">{{ group.name }}</h1>
|
|
<div class="neo-group-actions">
|
|
<button class="btn btn-sm btn-secondary" @click.stop="openCreateListDialog(group)">
|
|
<svg class="icon" aria-hidden="true">
|
|
<use xlink:href="#icon-plus" />
|
|
</svg>
|
|
List
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="neo-create-group-card" @click="openCreateGroupDialog">
|
|
+ Group
|
|
</div>
|
|
</div>
|
|
|
|
<details class="card mb-3 mt-4">
|
|
<summary class="card-header flex items-center cursor-pointer justify-between">
|
|
<h3>
|
|
<svg class="icon" aria-hidden="true">
|
|
<use xlink:href="#icon-user" />
|
|
</svg>
|
|
Join a Group with Invite Code
|
|
</h3>
|
|
<span class="expand-icon" aria-hidden="true">▼</span>
|
|
</summary>
|
|
<div class="card-body">
|
|
<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" />
|
|
</div>
|
|
<button type="submit" class="btn btn-secondary" :disabled="joiningGroup">
|
|
<span v-if="joiningGroup" class="spinner-dots-sm" role="status"><span /><span /><span /></span>
|
|
Join
|
|
</button>
|
|
</form>
|
|
<p v-if="joinGroupFormError" class="form-error-text mt-1">{{ joinGroupFormError }}</p>
|
|
</div>
|
|
</details>
|
|
</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-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>
|
|
</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" />
|
|
<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>
|
|
Create
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Create List Modal -->
|
|
<CreateListModal v-model="showCreateListModal" :groups="availableGroupsForModal" @created="onListCreated" />
|
|
</main>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted, nextTick } from 'vue';
|
|
import { useRouter } from 'vue-router';
|
|
import { apiClient, API_ENDPOINTS } from '@/config/api';
|
|
import { useStorage } from '@vueuse/core';
|
|
import { onClickOutside } from '@vueuse/core';
|
|
import { useNotificationStore } from '@/stores/notifications';
|
|
import CreateListModal from '@/components/CreateListModal.vue';
|
|
|
|
interface Group {
|
|
id: number;
|
|
name: string;
|
|
description?: string;
|
|
member_count: number;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
const router = useRouter();
|
|
const notificationStore = useNotificationStore();
|
|
const groups = ref<Group[]>([]);
|
|
const loading = ref(false);
|
|
const fetchError = ref<string | null>(null);
|
|
|
|
const showCreateGroupDialog = ref(false);
|
|
const newGroupName = ref('');
|
|
const creatingGroup = ref(false);
|
|
const newGroupNameInputRef = ref<HTMLInputElement | null>(null);
|
|
const createGroupModalRef = ref<HTMLElement | null>(null);
|
|
const createGroupFormError = ref<string | null>(null);
|
|
|
|
const inviteCodeToJoin = ref('');
|
|
const joiningGroup = ref(false);
|
|
const joinInviteCodeInputRef = ref<HTMLInputElement | null>(null);
|
|
const joinGroupFormError = ref<string | null>(null);
|
|
|
|
const showCreateListModal = ref(false);
|
|
const availableGroupsForModal = ref<{ label: string; value: number; }[]>([]);
|
|
|
|
// Cache groups in localStorage
|
|
const cachedGroups = useStorage<Group[]>('cached-groups', []);
|
|
const cachedTimestamp = useStorage<number>('cached-groups-timestamp', 0);
|
|
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds
|
|
|
|
// Load cached data immediately if available and not expired
|
|
const loadCachedData = () => {
|
|
const now = Date.now();
|
|
if (cachedGroups.value.length > 0 && (now - cachedTimestamp.value) < CACHE_DURATION) {
|
|
groups.value = cachedGroups.value;
|
|
}
|
|
};
|
|
|
|
// Fetch fresh data from API
|
|
const fetchGroups = async () => {
|
|
try {
|
|
const response = await apiClient.get(API_ENDPOINTS.GROUPS.BASE);
|
|
groups.value = response.data;
|
|
|
|
// Update cache
|
|
cachedGroups.value = response.data;
|
|
cachedTimestamp.value = Date.now();
|
|
} catch (err) {
|
|
fetchError.value = err instanceof Error ? err.message : 'Failed to load groups';
|
|
// If we have cached data, keep showing it even if refresh failed
|
|
if (cachedGroups.value.length === 0) {
|
|
groups.value = [];
|
|
}
|
|
}
|
|
};
|
|
|
|
const openCreateGroupDialog = () => {
|
|
newGroupName.value = '';
|
|
createGroupFormError.value = null;
|
|
showCreateGroupDialog.value = true;
|
|
nextTick(() => {
|
|
newGroupNameInputRef.value?.focus();
|
|
});
|
|
};
|
|
|
|
const closeCreateGroupDialog = () => {
|
|
showCreateGroupDialog.value = false;
|
|
};
|
|
|
|
onClickOutside(createGroupModalRef, closeCreateGroupDialog);
|
|
|
|
const handleCreateGroup = async () => {
|
|
if (!newGroupName.value.trim()) {
|
|
createGroupFormError.value = 'Group name is required';
|
|
newGroupNameInputRef.value?.focus();
|
|
return;
|
|
}
|
|
createGroupFormError.value = null;
|
|
creatingGroup.value = true;
|
|
try {
|
|
const response = await apiClient.post(API_ENDPOINTS.GROUPS.BASE, {
|
|
name: newGroupName.value,
|
|
});
|
|
const newGroup = response.data as Group;
|
|
if (newGroup && newGroup.id && newGroup.name) {
|
|
groups.value.push(newGroup);
|
|
closeCreateGroupDialog();
|
|
notificationStore.addNotification({ message: `Group '${newGroup.name}' created successfully.`, type: 'success' });
|
|
// Update cache
|
|
cachedGroups.value = groups.value;
|
|
cachedTimestamp.value = Date.now();
|
|
} else {
|
|
throw new Error('Invalid data received from server.');
|
|
}
|
|
} catch (error: unknown) {
|
|
const message = error instanceof Error ? error.message : 'Failed to create group. Please try again.';
|
|
createGroupFormError.value = message;
|
|
console.error('Error creating group:', error);
|
|
notificationStore.addNotification({ message, type: 'error' });
|
|
} finally {
|
|
creatingGroup.value = false;
|
|
}
|
|
};
|
|
|
|
const handleJoinGroup = async () => {
|
|
if (!inviteCodeToJoin.value.trim()) {
|
|
joinGroupFormError.value = 'Invite code is required';
|
|
joinInviteCodeInputRef.value?.focus();
|
|
return;
|
|
}
|
|
joinGroupFormError.value = null;
|
|
joiningGroup.value = true;
|
|
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) {
|
|
// 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);
|
|
}
|
|
inviteCodeToJoin.value = '';
|
|
notificationStore.addNotification({ message: `Successfully joined group '${joinedGroup.name}'.`, type: 'success' });
|
|
// Update cache
|
|
cachedGroups.value = groups.value;
|
|
cachedTimestamp.value = Date.now();
|
|
} 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' });
|
|
}
|
|
} catch (error: unknown) {
|
|
const message = error instanceof Error ? error.message : 'Failed to join group. Please check the invite code and try again.';
|
|
joinGroupFormError.value = message;
|
|
console.error('Error joining group:', error);
|
|
notificationStore.addNotification({ message, type: 'error' });
|
|
} finally {
|
|
joiningGroup.value = false;
|
|
}
|
|
};
|
|
|
|
const selectGroup = (group: Group) => {
|
|
router.push(`/groups/${group.id}`);
|
|
};
|
|
|
|
const openCreateListDialog = (group: Group) => {
|
|
availableGroupsForModal.value = [{
|
|
label: group.name,
|
|
value: group.id
|
|
}];
|
|
showCreateListModal.value = true;
|
|
};
|
|
|
|
const onListCreated = (newList: any) => {
|
|
notificationStore.addNotification({
|
|
message: `List '${newList.name}' created successfully.`,
|
|
type: 'success'
|
|
});
|
|
};
|
|
|
|
onMounted(async () => {
|
|
// Load cached data immediately
|
|
loadCachedData();
|
|
|
|
// Then fetch fresh data in background
|
|
await fetchGroups();
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
.page-padding {
|
|
padding: 1rem;
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.mb-3 {
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.mt-4 {
|
|
margin-top: 2rem;
|
|
}
|
|
|
|
.mt-1 {
|
|
margin-top: 0.5rem;
|
|
}
|
|
|
|
.ml-2 {
|
|
margin-left: 0.5rem;
|
|
}
|
|
|
|
/* Responsive grid for cards */
|
|
.neo-groups-grid {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 2rem;
|
|
justify-content: center;
|
|
align-items: flex-start;
|
|
margin-bottom: 2rem;
|
|
}
|
|
|
|
/* Card styles */
|
|
.neo-group-card,
|
|
.neo-create-group-card {
|
|
border-radius: 18px;
|
|
box-shadow: 6px 6px 0 #111;
|
|
max-width: 420px;
|
|
min-width: 260px;
|
|
width: 100%;
|
|
margin: 0 auto;
|
|
border: none;
|
|
background: var(--light);
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-bottom: 0;
|
|
padding: 2rem 2rem 1.5rem 2rem;
|
|
cursor: pointer;
|
|
transition: transform 0.1s ease-in-out, box-shadow 0.1s ease-in-out;
|
|
border: 3px solid #111;
|
|
}
|
|
|
|
.neo-group-card:hover {
|
|
transform: translateY(-3px);
|
|
box-shadow: 6px 9px 0 #111;
|
|
}
|
|
|
|
.neo-group-header {
|
|
font-weight: 900;
|
|
font-size: 1.25rem;
|
|
/* margin-bottom: 1rem; */
|
|
letter-spacing: 0.5px;
|
|
text-transform: none;
|
|
}
|
|
|
|
.neo-group-actions {
|
|
margin-top: 0;
|
|
}
|
|
|
|
.neo-create-group-card {
|
|
border: 3px dashed #111;
|
|
background: var(--light);
|
|
padding: 2.5rem 0;
|
|
text-align: center;
|
|
font-weight: 900;
|
|
font-size: 1.1rem;
|
|
color: #222;
|
|
cursor: pointer;
|
|
margin-top: 0;
|
|
transition: background 0.1s;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-height: 120px;
|
|
margin-bottom: 2.5rem;
|
|
}
|
|
|
|
.neo-create-group-card:hover {
|
|
background: #f0f0f0;
|
|
}
|
|
|
|
.form-error-text {
|
|
color: var(--danger);
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.flex-grow {
|
|
flex-grow: 1;
|
|
}
|
|
|
|
details>summary {
|
|
list-style: none;
|
|
}
|
|
|
|
details>summary::-webkit-details-marker {
|
|
display: none;
|
|
}
|
|
|
|
.expand-icon {
|
|
transition: transform 0.2s ease-in-out;
|
|
}
|
|
|
|
details[open] .expand-icon {
|
|
transform: rotate(180deg);
|
|
}
|
|
|
|
.cursor-pointer {
|
|
cursor: pointer;
|
|
}
|
|
|
|
/* Responsive adjustments */
|
|
@media (max-width: 900px) {
|
|
.neo-groups-grid {
|
|
gap: 1.2rem;
|
|
}
|
|
|
|
.neo-group-card,
|
|
.neo-create-group-card {
|
|
max-width: 95vw;
|
|
min-width: 180px;
|
|
padding-left: 1rem;
|
|
padding-right: 1rem;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 600px) {
|
|
.page-padding {
|
|
padding: 0.5rem;
|
|
}
|
|
|
|
.neo-group-card,
|
|
.neo-create-group-card {
|
|
padding: 1.2rem 0.7rem 1rem 0.7rem;
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.neo-group-header {
|
|
font-size: 1.1rem;
|
|
}
|
|
}
|
|
</style> |