mitlist/fe/src/layouts/MainLayout.vue
mohamad f49e15c05c
Some checks failed
Deploy to Production, build images and push to Gitea Registry / build_and_push (pull_request) Failing after 1m24s
feat: Introduce FastAPI and Vue.js guidelines, enhance API structure, and add caching support
This commit adds new guidelines for FastAPI and Vue.js development, emphasizing best practices for component structure, API performance, and data handling. It also introduces caching mechanisms using Redis for improved performance and updates the API structure to streamline authentication and user management. Additionally, new endpoints for categories and time entries are implemented, enhancing the overall functionality of the application.
2025-06-09 21:02:51 +02:00

461 lines
13 KiB
Vue

<template>
<div class="main-layout">
<header class="app-header">
<div class="toolbar-title">mitlist</div>
<div v-if="authStore.isAuthenticated" class="header-controls">
<div class="control-item">
<button ref="addMenuTrigger" class="icon-button" :class="{ 'is-active': addMenuOpen }"
:aria-expanded="addMenuOpen" aria-controls="add-menu-dropdown" aria-label="Add new list or group"
@click="toggleAddMenu">
<span class="material-icons">add_circle_outline</span>
</button>
<Transition name="dropdown-fade">
<div v-if="addMenuOpen" id="add-menu-dropdown" ref="addMenuDropdown" class="dropdown-menu add-dropdown"
role="menu">
<div class="dropdown-header">{{ $t('addSelector.title') }}</div>
<a href="#" role="menuitem" @click.prevent="handleAddList">{{ $t('addSelector.addList') }}</a>
<a href="#" role="menuitem" @click.prevent="handleAddGroup">{{ $t('addSelector.addGroup') }}</a>
</div>
</Transition>
</div>
<div class="control-item">
<button ref="languageMenuTrigger" class="icon-button language-button"
:class="{ 'is-active': languageMenuOpen }" :aria-expanded="languageMenuOpen"
aria-controls="language-menu-dropdown"
:aria-label="`Change language, current: ${currentLanguageCode.toUpperCase()}`" @click="toggleLanguageMenu">
<span class="material-icons-outlined">translate</span>
<span class="current-language">{{ currentLanguageCode.toUpperCase() }}</span>
</button>
<Transition name="dropdown-fade">
<div v-if="languageMenuOpen" id="language-menu-dropdown" ref="languageMenuDropdown"
class="dropdown-menu language-dropdown" role="menu">
<div class="dropdown-header">{{ $t('languageSelector.title') }}</div>
<a v-for="(name, code) in availableLanguages" :key="code" href="#" role="menuitem" class="language-option"
:class="{ 'active': currentLanguageCode === code }" @click.prevent="changeLanguage(code)">
{{ name }}
</a>
</div>
</Transition>
</div>
<div class="control-item">
<button ref="userMenuTrigger" class="icon-button user-menu-button" :class="{ 'is-active': userMenuOpen }"
:aria-expanded="userMenuOpen" aria-controls="user-menu-dropdown" aria-label="User menu"
@click="toggleUserMenu">
<img v-if="authStore.user?.avatarUrl" :src="authStore.user.avatarUrl" alt="User Avatar"
class="user-avatar" />
<span v-else class="material-icons">account_circle</span>
</button>
<Transition name="dropdown-fade">
<div v-if="userMenuOpen" id="user-menu-dropdown" ref="userMenuDropdown" class="dropdown-menu" role="menu">
<div v-if="authStore.user" class="dropdown-user-info">
<strong>{{ authStore.user.name }}</strong>
<small>{{ authStore.user.email }}</small>
</div>
<a href="#" role="menuitem" @click.prevent="handleLogout">Logout</a>
</div>
</Transition>
</div>
</div>
</header>
<main class="page-container">
<router-view v-slot="{ Component, route }">
<keep-alive v-if="route.meta.keepAlive">
<component :is="Component" />
</keep-alive>
<component v-else :is="Component" :key="route.fullPath" />
</router-view>
</main>
<OfflineIndicator />
<footer class="app-footer">
<nav class="tabs">
<router-link to="/lists" class="tab-item" active-class="active">
<span class="material-icons">list</span>
<span class="tab-text">Lists</span>
</router-link>
<router-link to="/groups" class="tab-item" :class="{ 'active': $route.path.startsWith('/groups') }"
@click.prevent="navigateToGroups">
<span class="material-icons">group</span>
<span class="tab-text">Groups</span>
</router-link>
<router-link to="/chores" class="tab-item" active-class="active">
<span class="material-icons">task_alt</span>
<span class="tab-text">Chores</span>
</router-link>
<router-link to="/expenses" class="tab-item" active-class="active">
<span class="material-icons">payments</span>
<span class="tab-text">Expenses</span>
</router-link>
</nav>
</footer>
<CreateListModal v-model="showCreateListModal" @created="handleListCreated" />
<CreateGroupModal v-model="showCreateGroupModal" @created="handleGroupCreated" />
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import OfflineIndicator from '@/components/OfflineIndicator.vue';
import { useNotificationStore } from '@/stores/notifications';
import { useGroupStore } from '@/stores/groupStore';
import { useI18n } from 'vue-i18n';
import CreateListModal from '@/components/CreateListModal.vue';
import CreateGroupModal from '@/components/CreateGroupModal.vue';
import { onClickOutside } from '@vueuse/core';
const router = useRouter();
const route = useRoute();
const authStore = useAuthStore();
const notificationStore = useNotificationStore();
const groupStore = useGroupStore();
const { t, locale } = useI18n();
const addMenuOpen = ref(false);
const addMenuDropdown = ref<HTMLElement | null>(null);
const addMenuTrigger = ref<HTMLElement | null>(null);
const toggleAddMenu = () => { addMenuOpen.value = !addMenuOpen.value; };
onClickOutside(addMenuDropdown, () => { addMenuOpen.value = false; }, { ignore: [addMenuTrigger] });
const languageMenuOpen = ref(false);
const languageMenuDropdown = ref<HTMLElement | null>(null);
const languageMenuTrigger = ref<HTMLElement | null>(null);
const toggleLanguageMenu = () => { languageMenuOpen.value = !languageMenuOpen.value; };
onClickOutside(languageMenuDropdown, () => { languageMenuOpen.value = false; }, { ignore: [languageMenuTrigger] });
const userMenuOpen = ref(false);
const userMenuDropdown = ref<HTMLElement | null>(null);
const userMenuTrigger = ref<HTMLElement | null>(null);
const toggleUserMenu = () => { userMenuOpen.value = !userMenuOpen.value; };
onClickOutside(userMenuDropdown, () => { userMenuOpen.value = false; }, { ignore: [userMenuTrigger] });
const availableLanguages = computed(() => ({
en: t('languageSelector.languages.en'),
de: t('languageSelector.languages.de'),
nl: t('languageSelector.languages.nl'),
fr: t('languageSelector.languages.fr'),
es: t('languageSelector.languages.es')
}));
const currentLanguageCode = computed(() => locale.value);
const changeLanguage = (languageCode: string) => {
locale.value = languageCode;
localStorage.setItem('language', languageCode);
languageMenuOpen.value = false;
notificationStore.addNotification({
type: 'success',
message: `Language changed to ${availableLanguages.value[languageCode as keyof typeof availableLanguages.value]}`,
});
};
const showCreateListModal = ref(false);
const showCreateGroupModal = ref(false);
const handleAddList = () => {
addMenuOpen.value = false;
showCreateListModal.value = true;
};
const handleAddGroup = () => {
addMenuOpen.value = false;
showCreateGroupModal.value = true;
};
const handleListCreated = (newList: any) => {
notificationStore.addNotification({ message: `List '${newList.name}' created successfully`, type: 'success' });
showCreateListModal.value = false;
};
const handleGroupCreated = (newGroup: any) => {
notificationStore.addNotification({ message: `Group '${newGroup.name}' created successfully`, type: 'success' });
showCreateGroupModal.value = false;
groupStore.fetchGroups();
};
const handleLogout = async () => {
try {
userMenuOpen.value = false;
authStore.logout();
notificationStore.addNotification({ type: 'success', message: 'Logged out successfully' });
await router.push('/auth/login');
} catch (error: unknown) {
notificationStore.addNotification({
type: 'error',
message: error instanceof Error ? error.message : 'Logout failed',
});
}
};
const navigateToGroups = () => {
if (groupStore.isLoading) return;
if (groupStore.groupCount === 1 && groupStore.firstGroupId) {
router.push(`/groups/${groupStore.firstGroupId}`);
} else {
router.push('/groups');
}
};
onMounted(async () => {
if (authStore.isAuthenticated) {
try {
await authStore.fetchCurrentUser();
await groupStore.fetchGroups();
} catch (error) {
console.error('Failed to initialize app data:', error);
}
}
const savedLanguage = localStorage.getItem('language');
if (savedLanguage && Object.keys(availableLanguages.value).includes(savedLanguage)) {
locale.value = savedLanguage;
}
});
</script>
<style lang="scss" scoped>
@import url('https://fonts.googleapis.com/icon?family=Material+Icons|Material+Icons+Outlined');
.main-layout {
display: flex;
flex-direction: column;
min-height: 100vh;
background-color: #f9f9f9;
}
.app-header {
background-color: #fff8f0;
padding: 0 1rem;
height: var(--header-height);
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
position: sticky;
top: 0;
z-index: 100;
}
.toolbar-title {
font-size: 1.5rem;
font-weight: 500;
letter-spacing: 0.1ch;
color: var(--primary);
}
.header-controls {
display: flex;
align-items: center;
gap: 0.5rem;
}
.control-item {
position: relative;
}
.icon-button {
background: none;
border: 1px solid transparent;
color: var(--primary);
cursor: pointer;
padding: 0.5rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s ease, box-shadow 0.2s ease;
&:hover {
background-color: rgba(255, 123, 84, 0.1);
}
&.is-active {
background-color: rgba(255, 123, 84, 0.15);
box-shadow: 0 0 0 2px rgba(255, 123, 84, 0.3);
}
.material-icons,
.material-icons-outlined {
font-size: 26px;
}
}
.language-button {
border-radius: 20px;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
}
.current-language {
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.5px;
}
.user-menu-button {
padding: 0;
width: 40px;
height: 40px;
overflow: hidden;
.user-avatar {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.dropdown-menu {
position: absolute;
right: 0;
top: calc(100% + 8px);
background-color: white;
border: 1px solid #ddd;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 180px;
z-index: 101;
overflow: hidden;
a {
display: block;
padding: 0.75rem 1rem;
color: var(--text-color);
text-decoration: none;
transition: background-color 0.2s ease;
&:hover {
background-color: #f5f5f5;
}
}
}
.dropdown-header {
padding: 0.5rem 1rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-color);
opacity: 0.7;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid #e0e0e0;
}
.dropdown-user-info {
padding: 0.75rem 1rem;
border-bottom: 1px solid #e0e0e0;
display: flex;
flex-direction: column;
strong {
font-weight: 500;
}
small {
font-size: 0.8em;
opacity: 0.7;
}
}
.language-option.active {
background-color: rgba(255, 123, 84, 0.1);
color: var(--primary);
font-weight: 500;
}
.dropdown-fade-enter-active,
.dropdown-fade-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.dropdown-fade-enter-from,
.dropdown-fade-leave-to {
opacity: 0;
transform: translateY(-10px);
}
.page-container {
flex-grow: 1;
padding-bottom: calc(var(--footer-height) + 1rem);
}
.app-footer {
background-color: white;
border-top: 1px solid #e0e0e0;
height: var(--footer-height);
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 100;
box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.05);
}
.tabs {
display: flex;
height: 100%;
}
.tab-item {
flex-grow: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #757575;
text-decoration: none;
padding: 0.5rem 0;
gap: 4px;
transition: background-color 0.2s ease, color 0.2s ease;
position: relative;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 3px;
background-color: var(--primary);
transition: width 0.3s ease;
}
&.active {
color: var(--primary);
&::after {
width: 50%;
}
}
&:hover:not(.active) {
color: var(--primary);
}
.material-icons {
font-size: 24px;
}
.tab-text {
font-size: 0.75rem;
}
@media (min-width: 768px) {
flex-direction: row;
gap: 8px;
.tab-text {
font-size: 0.9rem;
}
}
}
</style>