mitlist/fe/src/pages/GroupsPage.vue
google-labs-jules[bot] 5c9ba3f38c feat: Internationalize AuthCallback, Chores, ErrorNotFound, GroupDetail pages
This commit introduces internationalization for several pages:
- AuthCallbackPage.vue
- ChoresPage.vue (a comprehensive page with many elements)
- ErrorNotFound.vue
- GroupDetailPage.vue (including sub-sections for members, invites, chores summary, and expenses summary)

Key changes:
- Integrated `useI18n` in each listed page to handle translatable strings.
- Replaced hardcoded text in templates and relevant script sections (notifications, dynamic messages, fallbacks, etc.) with `t('key')` calls.
- Added new translation keys, organized under page-specific namespaces (e.g., `authCallbackPage`, `choresPage`, `errorNotFoundPage`, `groupDetailPage`), to `fe/src/i18n/en.json`.
- Added corresponding keys with placeholder translations (prefixed with DE:, FR:, ES:) to `fe/src/i18n/de.json`, `fe/src/i18n/fr.json`, and `fe/src/i18n/es.json`.
- Reused existing translation keys (e.g., for chore frequency options) where applicable.
2025-06-02 00:19:26 +02:00

460 lines
14 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">{{ t('groupsPage.retryButton') }}</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>{{ t('groupsPage.emptyState.title') }}</h3>
<p>{{ t('groupsPage.emptyState.description') }}</p>
<button class="btn btn-primary mt-2" @click="openCreateGroupDialog">
<svg class="icon" aria-hidden="true">
<use xlink:href="#icon-plus" />
</svg>
{{ t('groupsPage.emptyState.createButton') }}
</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>
{{ t('groupsPage.groupCard.newListButton') }}
</button>
</div>
</div>
<div class="neo-create-group-card" @click="openCreateGroupDialog">
{{ t('groupsPage.createCard.title') }}
</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>
{{ t('groupsPage.joinGroup.title') }}
</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">{{ t('groupsPage.joinGroup.inputLabel') }}</label>
<input type="text" id="joinInviteCodeInput" v-model="inviteCodeToJoin" class="form-input"
:placeholder="t('groupsPage.joinGroup.inputPlaceholder')" 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>
{{ t('groupsPage.joinGroup.joinButton') }}
</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">{{ t('groupsPage.createDialog.title') }}</h3>
<button class="close-button" @click="closeCreateGroupDialog" :aria-label="t('groupsPage.createDialog.closeButtonLabel')">
<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">{{ t('groupsPage.createDialog.groupNameLabel') }}</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">{{ t('groupsPage.createDialog.cancelButton') }}</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>
{{ t('groupsPage.createDialog.createButton') }}
</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 { useI18n } from 'vue-i18n';
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';
import VButton from '@/components/valerie/VButton.vue';
import VIcon from '@/components/valerie/VIcon.vue';
const { t } = useI18n();
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 : t('groupsPage.errors.fetchFailed');
// 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 = t('groupsPage.errors.groupNameRequired');
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: t('groupsPage.notifications.groupCreatedSuccess', { groupName: newGroup.name }), 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 : t('groupsPage.errors.createFailed');
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 = t('groupsPage.errors.inviteCodeRequired');
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: t('groupsPage.notifications.joinSuccessNamed', { groupName: 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: t('groupsPage.notifications.joinSuccessGeneric'), type: 'success' });
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : t('groupsPage.errors.joinFailed');
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) => {
console.log('Opening create list dialog for group:', group);
// Ensure we have the latest groups data
fetchGroups().then(() => {
console.log('Setting up modal with group:', group);
availableGroupsForModal.value = [{
label: group.name,
value: group.id
}];
showCreateListModal.value = true;
console.log('Modal should be visible now:', showCreateListModal.value);
});
};
const onListCreated = (newList: any) => {
notificationStore.addNotification({
message: t('groupsPage.notifications.listCreatedSuccess', { listName: newList.name }),
type: 'success'
});
// Optionally refresh the groups list to show the new list
fetchGroups();
};
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>