![google-labs-jules[bot]](/assets/img/avatar_default.png)
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.
548 lines
17 KiB
Vue
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> |