
All checks were successful
Deploy to Production, build images and push to Gitea Registry / build_and_push (pull_request) Successful in 1m30s
This commit introduces a detailed roadmap for implementing various features, focusing on backend and frontend improvements. Key additions include: - New database schema designs for financial audit logging, archiving lists, and categorizing items. - Backend logic for financial audit logging, archiving functionality, and chore subtasks. - Frontend UI updates for archiving lists, managing categories, and enhancing the chore interface. - Introduction of a guest user flow and integration of Redis for caching to improve performance. These changes aim to enhance the application's functionality, user experience, and maintainability.
464 lines
13 KiB
Vue
464 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 {
|
|
// Only fetch user if we don't have user data yet
|
|
if (!authStore.user) {
|
|
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> |