mitlist/fe/src/pages/GroupsPage.vue
google-labs-jules[bot] 198222c3ff feat: Add missing i18n translations for page components (partial)
This commit introduces internationalization for several page components by identifying hardcoded strings, adding them to translation files, and updating the components to use translation keys.

Processed pages:
- fe/src/pages/AuthCallbackPage.vue: I internationalized an error message.
- fe/src/pages/ChoresPage.vue: I internationalized console error messages and an input placeholder.
- fe/src/pages/ErrorNotFound.vue: I found no missing translations.
- fe/src/pages/GroupDetailPage.vue: I internationalized various UI elements (ARIA labels, button text, fallback user display names) and console/error messages.
- fe/src/pages/GroupsPage.vue: I internationalized error messages and console logs.
- fe/src/pages/IndexPage.vue: I found no missing user-facing translations.
- fe/src/pages/ListDetailPage.vue: My analysis is complete, and I identified a console message and a fallback string for translation (implementation of changes for this page is pending).

For each processed page where changes were needed:
- I added new keys to `fe/src/i18n/en.json`.
- I added corresponding placeholder keys `"[TRANSLATE] Original Text"` to `fe/src/i18n/de.json`, `fe/src/i18n/es.json`, and `fe/src/i18n/fr.json`.
- I updated the Vue component to use the `t()` function with the new keys.

Further pages in `fe/src/pages/` are pending analysis and internationalization as per our original plan.
2025-06-07 20:40:49 +00:00

548 lines
17 KiB
Vue

<template>
<main class="container page-padding">
<!-- <h1 class="mb-3">Your Groups</h1> -->
<!-- Initial Loading Spinner -->
<div v-if="isInitiallyLoading && groups.length === 0 && !fetchError" class="text-center my-5">
<p>{{ t('groupsPage.loadingText', 'Loading groups...') }}</p>
<span class="spinner-dots-lg" role="status"><span /><span /><span /></span>
</div>
<!-- Error Display -->
<div v-else-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(true)">{{
t('groupsPage.retryButton') }}</button>
</div>
<!-- Empty State: show if not initially loading, no error, and groups genuinely empty -->
<div v-else-if="!isInitiallyLoading && 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>
<!-- Groups List -->
<div v-else-if="groups.length > 0" 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>
</div>
<!-- Create or Join 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">{{ activeTab === 'create' ? t('groupsPage.createDialog.title') :
t('groupsPage.joinGroup.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>
<!-- Tabs -->
<div class="modal-tabs">
<button @click="activeTab = 'create'" :class="{ 'active': activeTab === 'create' }">
{{ t('groupsPage.createDialog.createButton') }}
</button>
<button @click="activeTab = 'join'" :class="{ 'active': activeTab === 'join' }">
{{ t('groupsPage.joinGroup.joinButton') }}
</button>
</div>
<!-- Create Form -->
<form v-if="activeTab === 'create'" @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>
<!-- Join Form -->
<form v-if="activeTab === 'join'" @submit.prevent="handleJoinGroup">
<div class="modal-body">
<div class="form-group">
<label for="joinInviteCodeInput" class="form-label">{{ t('groupsPage.joinGroup.inputLabel', 'Invite Code')
}}</label>
<input type="text" id="joinInviteCodeInput" v-model="inviteCodeToJoin" class="form-input"
:placeholder="t('groupsPage.joinGroup.inputPlaceholder')" required ref="joinInviteCodeInputRef" />
<p v-if="joinGroupFormError" class="form-error-text">{{ joinGroupFormError }}</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="joiningGroup">
<span v-if="joiningGroup" class="spinner-dots-sm" role="status"><span /><span /><span /></span>
{{ t('groupsPage.joinGroup.joinButton') }}
</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, watch } 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 fetchError = ref<string | null>(null);
const isInitiallyLoading = ref(true); // Added for managing initial load state
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 activeTab = ref<'create' | 'join'>('create');
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
// Attempt to initialize groups from valid cache
const now = Date.now();
if (cachedGroups.value && (now - cachedTimestamp.value) < CACHE_DURATION) {
if (cachedGroups.value.length > 0) {
groups.value = JSON.parse(JSON.stringify(cachedGroups.value)); // Deep copy for safety from potential proxy issues
isInitiallyLoading.value = false;
} else { // Valid cache, but it's empty
groups.value = []; // Ensure it's an empty array
isInitiallyLoading.value = false; // We know it's empty, not "loading"
}
}
// If cache is stale or not present, groups.value remains [], and isInitiallyLoading remains true.
// Fetch fresh data from API
const fetchGroups = async (isRetryAttempt = false) => {
// If it's a retry triggered by user AND the list is currently empty, set loading to true to show spinner.
// Or, if it's the very first load (isInitiallyLoading is still true) AND list is empty (no cache hit).
if ((isRetryAttempt && groups.value.length === 0) || (isInitiallyLoading.value && groups.value.length === 0)) {
isInitiallyLoading.value = true;
}
// If groups.value has items (from cache), isInitiallyLoading is false, and this fetch acts as a background update.
fetchError.value = null; // Clear previous error before new attempt
try {
const response = await apiClient.get(API_ENDPOINTS.GROUPS.BASE);
const freshGroups = response.data as Group[];
groups.value = freshGroups;
// Update cache
cachedGroups.value = freshGroups;
cachedTimestamp.value = Date.now();
} catch (err: any) {
let message = t('groupsPage.errors.fetchFailed');
// Attempt to get a more specific error message from the API response
if (err.response && err.response.data && err.response.data.detail) {
message = err.response.data.detail;
} else if (err.message) {
message = err.message;
}
fetchError.value = message;
// If fetch fails, groups.value will retain its current state (either from cache or empty).
// The template will then show the error message.
} finally {
isInitiallyLoading.value = false; // Mark loading as complete for this attempt
}
};
watch(activeTab, (newTab) => {
if (showCreateGroupDialog.value) {
createGroupFormError.value = null;
joinGroupFormError.value = null;
nextTick(() => {
if (newTab === 'create') {
newGroupNameInputRef.value?.focus();
} else {
joinInviteCodeInputRef.value?.focus();
}
});
}
});
const openCreateGroupDialog = () => {
activeTab.value = 'create'; // Default to create tab
newGroupName.value = '';
createGroupFormError.value = null;
inviteCodeToJoin.value = '';
joinGroupFormError.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(t('groupsPage.errors.invalidDataFromServer'));
}
} catch (error: any) {
const message = error.response?.data?.detail || (error instanceof Error ? error.message : t('groupsPage.errors.createFailed'));
createGroupFormError.value = message;
console.error(t('groupsPage.errors.createFailedConsole'), 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, {
code: inviteCodeToJoin.value.trim()
});
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();
closeCreateGroupDialog();
} 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' });
closeCreateGroupDialog();
}
} catch (error: any) {
const message = error.response?.data?.detail || (error instanceof Error ? error.message : t('groupsPage.errors.joinFailed'));
joinGroupFormError.value = message;
console.error(t('groupsPage.errors.joinFailedConsole'), error);
notificationStore.addNotification({ message, type: 'error' });
} finally {
joiningGroup.value = false;
}
};
const selectGroup = (group: Group) => {
router.push(`/groups/${group.id}`);
};
const openCreateListDialog = (group: Group) => {
// Ensure we have the latest groups data
fetchGroups().then(() => {
availableGroupsForModal.value = [{
label: group.name,
value: group.id
}];
showCreateListModal.value = true;
});
};
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(); // Refresh data, isRetryAttempt will be false
};
onMounted(() => {
// groups might have been populated from cache synchronously above.
// isInitiallyLoading reflects whether cache was used or if we need to show a spinner.
// Call fetchGroups to get fresh data or perform initial load if cache was missed.
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;
}
/* Modal Tabs */
.modal-tabs {
display: flex;
border-bottom: 1px solid #eee;
margin: 0 1.5rem;
}
.modal-tabs button {
background: none;
border: none;
padding: 0.75rem 0.25rem;
margin-right: 1.5rem;
cursor: pointer;
font-size: 1rem;
color: var(--text-color-secondary);
border-bottom: 3px solid transparent;
margin-bottom: -2px;
font-weight: 500;
}
.modal-tabs button.active {
color: var(--primary);
border-bottom-color: var(--primary);
font-weight: 600;
}
/* 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>