Files
yourpart3/frontend/src/components/AppNavigation.vue

914 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<nav
ref="navRoot"
class="app-navigation"
:class="{ 'app-navigation--suppress-hover': suppressHover }"
>
<div class="nav-primary">
<ul>
<!-- Hauptmenü -->
<li
v-for="(item, key) in menu"
:key="key"
class="mainmenuitem"
:class="{ 'mainmenuitem--active': isItemActive(item), 'mainmenuitem--expanded': isMainExpanded(key) }"
tabindex="0"
role="button"
:aria-haspopup="hasTopLevelSubmenu(item) ? 'menu' : undefined"
:aria-expanded="hasTopLevelSubmenu(item) ? String(isMainExpanded(key)) : undefined"
@click="handleItem(item, $event, key)"
@keydown.enter.prevent="handleItem(item, $event, key)"
@keydown.space.prevent="handleItem(item, $event, key)"
>
<span
v-if="item.icon"
:style="`background-image:url('/images/icons/${item.icon}')`"
class="menu-icon"
>&nbsp;</span>
<span class="mainmenuitem__label">{{ $t(`navigation.${key}`) }}</span>
<span v-if="hasTopLevelSubmenu(item)" class="mainmenuitem__caret">&#x25BE;</span>
<!-- Untermenü Ebene 1 -->
<ul v-if="hasTopLevelSubmenu(item)" class="submenu1" :class="{ 'submenu1--open': isMainExpanded(key) }">
<li
v-for="(subitem, subkey) in item.children"
:key="subkey"
tabindex="0"
role="menuitem"
:class="{ 'submenu1__item--expanded': isSubExpanded(`${key}:${subkey}`) }"
@click="handleSubItem(subitem, subkey, key, $event)"
@keydown.enter.prevent="handleSubItem(subitem, subkey, key, $event)"
@keydown.space.prevent="handleSubItem(subitem, subkey, key, $event)"
>
<span
v-if="subitem.icon"
:style="`background-image:url('/images/icons/${subitem.icon}')`"
class="submenu-icon"
>&nbsp;</span>
<span>{{ subitem?.label || $t(`navigation.m-${key}.${subkey}`) }}</span>
<span
v-if="hasSecondLevelSubmenu(subitem, subkey)"
class="subsubmenu"
>&#x25B6;</span>
<!-- ForumUnterliste -->
<ul
v-if="subkey === 'forum' && forumList.length"
class="submenu2"
:class="{ 'submenu2--open': isSubExpanded(`${key}:${subkey}`) }"
>
<li
v-for="forum in forumList"
:key="forum.id"
tabindex="0"
role="menuitem"
@click="handleItem({ action: 'openForum', params: forum.id }, $event)"
@keydown.enter.prevent="handleItem({ action: 'openForum', params: forum.id }, $event)"
@keydown.space.prevent="handleItem({ action: 'openForum', params: forum.id }, $event)"
>
{{ forum.name }}
</li>
</ul>
<!-- Vokabeltrainer-Unterliste (Sprachen) -->
<ul
v-else-if="subkey === 'vocabtrainer' && vocabLanguagesList.length"
class="submenu2"
:class="{ 'submenu2--open': isSubExpanded(`${key}:${subkey}`) }"
>
<li
tabindex="0"
role="menuitem"
@click="handleItem({ path: '/socialnetwork/vocab/new' }, $event)"
@keydown.enter.prevent="handleItem({ path: '/socialnetwork/vocab/new' }, $event)"
@keydown.space.prevent="handleItem({ path: '/socialnetwork/vocab/new' }, $event)"
>
{{ $t('navigation.m-sprachenlernen.m-vocabtrainer.newLanguage') }}
</li>
<li
v-for="lang in vocabLanguagesList"
:key="lang.id"
tabindex="0"
role="menuitem"
@click="handleItem({ path: `/socialnetwork/vocab/${lang.id}` }, $event)"
@keydown.enter.prevent="handleItem({ path: `/socialnetwork/vocab/${lang.id}` }, $event)"
@keydown.space.prevent="handleItem({ path: `/socialnetwork/vocab/${lang.id}` }, $event)"
>
{{ lang.name }}
</li>
</ul>
<!-- Weiteres Untermenü Ebene 2 -->
<ul
v-else-if="subitem.children"
class="submenu2"
:class="{ 'submenu2--open': isSubExpanded(`${key}:${subkey}`) }"
>
<li
v-for="(subsubitem, subsubkey) in subitem.children"
:key="subsubkey"
tabindex="0"
role="menuitem"
@click="handleItem(subsubitem, $event)"
@keydown.enter.prevent="handleItem(subsubitem, $event)"
@keydown.space.prevent="handleItem(subsubitem, $event)"
>
<span
v-if="subsubitem.icon"
:style="`background-image:url('/images/icons/${subsubitem.icon}')`"
class="submenu-icon"
>&nbsp;</span>
<span>{{ subsubitem?.label || $t(`navigation.m-${key}.m-${subkey}.${subsubkey}`) }}</span>
</li>
</ul>
</li>
<!-- Eingeloggte Freunde -->
<li
v-if="item.showLoggedinFriends === 1 && friendsList.length"
v-for="friend in friendsList"
:key="friend.id"
tabindex="0"
role="menuitem"
@click="handleItem({ action: 'openChat', params: friend.id }, $event)"
@keydown.enter.prevent="handleItem({ action: 'openChat', params: friend.id }, $event)"
@keydown.space.prevent="handleItem({ action: 'openChat', params: friend.id }, $event)"
>
{{ friend.username }}
<ul class="submenu2">
<li
tabindex="0"
role="menuitem"
@click="handleItem({ action: 'openChat', params: friend.id }, $event)"
@keydown.enter.prevent="handleItem({ action: 'openChat', params: friend.id }, $event)"
@keydown.space.prevent="handleItem({ action: 'openChat', params: friend.id }, $event)"
>
{{ $t('navigation.m-friends.chat') }}
</li>
<li
tabindex="0"
role="menuitem"
@click="handleItem({ action: 'openProfile', params: friend.id }, $event)"
@keydown.enter.prevent="handleItem({ action: 'openProfile', params: friend.id }, $event)"
@keydown.space.prevent="handleItem({ action: 'openProfile', params: friend.id }, $event)"
>
{{ $t('navigation.m-friends.profile') }}
</li>
</ul>
</li>
</ul>
</li>
</ul>
</div>
<div class="right-block">
<button type="button" @click="accessMailbox" class="mailbox" aria-label="Mailbox"></button>
<span class="logoutblock">
<span class="username">{{ user.username }}</span>
<span class="menuitem" @click="logout">
{{ $t('navigation.logout') }}
</span>
</span>
</div>
</nav>
</template>
<script>
import { mapGetters, mapActions } from 'vuex';
import { createApp } from 'vue';
import apiClient from '@/utils/axios.js';
import { EventBus } from '@/utils/eventBus.js';
import RandomChatDialog from '../dialogues/chat/RandomChatDialog.vue';
import MultiChatDialog from '../dialogues/chat/MultiChatDialog.vue';
// Wichtig: die zentrale Instanzen importieren
import store from '@/store';
import router from '@/router';
import i18n from '@/i18n';
export default {
name: 'AppNavigation',
components: {
RandomChatDialog,
MultiChatDialog
},
data() {
return {
forumList: [],
friendsList: [],
vocabLanguagesList: [],
expandedMainKey: null,
expandedSubKey: null,
pinnedMainKey: null,
pinnedSubKey: null,
suppressHover: false,
hoverReleaseTimer: null,
isMobileNav: false
};
},
computed: {
...mapGetters(['menu', 'user', 'menuNeedsUpdate', 'socket'])
},
watch: {
menuNeedsUpdate(newVal) {
if (newVal) this.loadMenu();
},
$route() {
this.collapseMenus();
},
socket(newSocket) {
if (newSocket) {
newSocket.on('forumschanged', this.fetchForums);
newSocket.on('friendloginchanged', this.fetchFriends);
newSocket.on('reloadmenu', this.loadMenu);
}
}
},
created() {
if (this.user?.id) {
this.loadMenu();
this.fetchForums();
this.fetchFriends();
this.fetchVocabLanguages();
}
this.updateViewportState();
window.addEventListener('resize', this.updateViewportState);
document.addEventListener('click', this.handleDocumentClick);
document.addEventListener('keydown', this.handleDocumentKeydown);
},
beforeUnmount() {
const sock = this.socket;
if (sock) {
sock.off('forumschanged');
sock.off('friendloginchanged');
sock.off('reloadmenu');
}
window.removeEventListener('resize', this.updateViewportState);
document.removeEventListener('click', this.handleDocumentClick);
document.removeEventListener('keydown', this.handleDocumentKeydown);
if (this.hoverReleaseTimer) {
clearTimeout(this.hoverReleaseTimer);
}
},
methods: {
...mapActions(['loadMenu', 'logout']),
updateViewportState() {
this.isMobileNav = window.innerWidth <= 960;
if (!this.isMobileNav) {
this.expandedMainKey = null;
this.expandedSubKey = null;
}
},
isMainExpanded(key) {
return this.isMobileNav
? this.expandedMainKey === key
: this.pinnedMainKey === key;
},
isSubExpanded(key) {
return this.isMobileNav
? this.expandedSubKey === key
: this.pinnedSubKey === key;
},
toggleMain(key) {
this.expandedMainKey = this.expandedMainKey === key ? null : key;
this.expandedSubKey = null;
},
toggleSub(key) {
this.expandedSubKey = this.expandedSubKey === key ? null : key;
},
togglePinnedMain(key) {
this.pinnedMainKey = this.pinnedMainKey === key ? null : key;
this.pinnedSubKey = null;
},
togglePinnedSub(key) {
this.pinnedSubKey = this.pinnedSubKey === key ? null : key;
},
collapseMenus() {
this.expandedMainKey = null;
this.expandedSubKey = null;
this.pinnedMainKey = null;
this.pinnedSubKey = null;
this.suppressHover = true;
if (this.hoverReleaseTimer) {
clearTimeout(this.hoverReleaseTimer);
}
this.hoverReleaseTimer = window.setTimeout(() => {
this.suppressHover = false;
this.hoverReleaseTimer = null;
}, 180);
this.$nextTick(() => {
if (document.activeElement && typeof document.activeElement.blur === 'function') {
document.activeElement.blur();
}
});
},
handleDocumentClick(event) {
const root = this.$refs.navRoot;
if (!root || root.contains(event.target)) {
return;
}
this.collapseMenus();
},
handleDocumentKeydown(event) {
if (event.key === 'Escape') {
this.collapseMenus();
}
},
hasChildren(item) {
if (!item?.children) {
return false;
}
if (Array.isArray(item.children)) {
return item.children.length > 0;
}
return Object.keys(item.children).length > 0;
},
hasTopLevelSubmenu(item) {
return this.hasChildren(item) || (item?.showLoggedinFriends === 1 && this.friendsList.length > 0);
},
hasSecondLevelSubmenu(subitem, subkey) {
if (subkey === 'forum') {
return this.forumList.length > 0;
}
if (subkey === 'vocabtrainer') {
return this.vocabLanguagesList.length > 0;
}
return this.hasChildren(subitem);
},
isItemActive(item) {
if (!item?.path || !this.$route?.path) {
return false;
}
if (item.path === '/') {
return this.$route.path === '/';
}
return this.$route.path === item.path || this.$route.path.startsWith(`${item.path}/`);
},
openMultiChat() {
// Räume können später dynamisch geladen werden, hier als Platzhalter ein Beispiel:
const exampleRooms = [
{ id: 1, title: 'Allgemein' },
{ id: 2, title: 'Rollenspiel' }
];
const ref = this.$root.$refs.multiChatDialog;
if (ref && typeof ref.open === 'function') {
ref.open(exampleRooms);
} else if (ref?.$refs?.dialog && typeof ref.$refs.dialog.open === 'function') {
ref.$refs.dialog.open();
} else {
console.error('MultiChatDialog nicht bereit oder ohne open()');
}
},
accessMailbox() {
const openMessages = () => {
EventBus.emit('open-falukant-messages');
};
if (this.$route?.path?.startsWith('/falukant')) {
openMessages();
return;
}
this.$router.push({ name: 'FalukantOverview' }).then(() => {
window.setTimeout(openMessages, 150);
});
},
async fetchForums() {
try {
const res = await apiClient.get('/api/forum');
this.forumList = res.data;
} catch (err) {
console.error('Error fetching forums:', err);
}
},
async fetchFriends() {
try {
const res = await apiClient.get('/api/socialnetwork/friends/loggedin');
this.friendsList = res.data;
} catch (err) {
console.error('Error fetching friends:', err);
}
},
async fetchVocabLanguages() {
try {
const res = await apiClient.get('/api/vocab/languages');
this.vocabLanguagesList = res.data?.languages || [];
} catch (err) {
console.error('Error fetching vocab languages:', err);
this.vocabLanguagesList = [];
}
},
openForum(forumId) {
this.$router.push({ name: 'Forum', params: { id: forumId } });
},
openProfile(userId) {
this.$root.$refs.userProfileDialog.userId = userId;
this.$root.$refs.userProfileDialog.open();
},
openChat(userId) {
console.log('openChat:', userId);
// Datei erstellen und ans body anhängen
const container = document.createElement('div');
document.body.appendChild(container);
},
/**
* Einheitliche KlickLogik:
* 1) Nur aufklappen, wenn noch Untermenüs existieren
* 2) Bei `view`: Dialog/Window öffnen
* 3) Bei `action`: custom action aufrufen
* 4) Sonst: normale Router-Navigation
*/
handleItem(item, event, key = null) {
event.stopPropagation();
if (key && this.hasTopLevelSubmenu(item)) {
if (this.isMobileNav) {
this.toggleMain(key);
} else {
this.togglePinnedMain(key);
}
return;
}
if (this.hasChildren(item)) return;
// 2) view → Dialog/Window
if (item.view) {
const dialogRef = this.$root.$refs[item.class];
if (!dialogRef) {
console.error(`Dialog-Ref '${item.class}' nicht gefunden! Bitte prüfe Ref und Menü-Konfiguration.`);
return;
}
// Robust öffnen: erst open(), sonst auf inneres DialogWidget zurückgreifen
if (typeof dialogRef.open === 'function') {
dialogRef.open();
} else if (dialogRef.$refs?.dialog && typeof dialogRef.$refs.dialog.open === 'function') {
dialogRef.$refs.dialog.open();
} else {
console.error(`Dialog '${item.class}' gefunden, aber keine open()-Methode verfügbar.`);
}
this.collapseMenus();
return;
}
// 3) custom action (openForum, openChat, ...)
if (item.action && typeof this[item.action] === 'function') {
this[item.action](item.params, event);
this.collapseMenus();
return;
}
// 4) StandardNavigation
if (item.path) {
this.$router.push(item.path);
this.collapseMenus();
}
},
handleSubItem(item, subkey, parentKey, event) {
event.stopPropagation();
const compoundKey = `${parentKey}:${subkey}`;
if (this.hasSecondLevelSubmenu(item, subkey)) {
if (this.isMobileNav) {
this.toggleSub(compoundKey);
} else {
this.togglePinnedSub(compoundKey);
}
return;
}
this.handleItem(item, event);
}
}
};
</script>
<style lang="scss" scoped>
@import '../assets/styles.scss';
.app-navigation,
.nav-primary > ul {
display: flex;
padding: 0;
margin: 0;
flex-direction: row;
}
.app-navigation {
width: 100%;
max-width: none;
margin: 0 auto;
align-items: center;
gap: 10px;
padding: 6px 12px;
flex-wrap: wrap;
border-radius: 0;
background:
linear-gradient(180deg, rgba(249, 236, 225, 0.98) 0%, rgba(246, 228, 212, 0.98) 100%);
border-top: 1px solid rgba(93, 64, 55, 0.08);
border-bottom: 1px solid rgba(93, 64, 55, 0.12);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.46);
color: var(--color-text-primary);
z-index: 999;
}
.nav-primary {
flex: 1;
min-width: 0;
overflow: visible;
position: relative;
z-index: 1;
}
.nav-primary > ul {
min-width: 0;
justify-content: flex-start;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
ul {
list-style-type: none;
padding: 0;
margin: 0;
}
.mainmenuitem {
display: flex;
align-items: center;
justify-content: center;
min-height: 36px;
padding: 0 12px;
line-height: 1;
cursor: pointer;
border-radius: 999px;
border: 1px solid transparent;
transition: background-color 0.25s, color 0.25s, transform 0.2s, border-color 0.25s, box-shadow 0.25s;
}
.mainmenuitem:focus-visible,
.submenu1 > li:focus-visible,
.submenu2 > li:focus-visible,
.mailbox:focus-visible,
.menuitem:focus-visible {
outline: 3px solid rgba(120, 195, 138, 0.34);
outline-offset: 2px;
}
.mainmenuitem:hover {
background-color: rgba(248, 162, 43, 0.16);
border-color: rgba(248, 162, 43, 0.2);
transform: translateY(-1px);
}
.mainmenuitem:hover > span {
color: var(--color-primary);
}
.mainmenuitem--expanded {
background-color: rgba(248, 162, 43, 0.16);
border-color: rgba(248, 162, 43, 0.2);
}
.mainmenuitem--active {
background: rgba(255, 255, 255, 0.72);
border-color: rgba(248, 162, 43, 0.22);
box-shadow: 0 6px 14px rgba(93, 64, 55, 0.05);
}
.mainmenuitem__label {
white-space: nowrap;
}
.mainmenuitem__caret {
margin-left: 6px;
font-size: 0.7rem;
color: rgba(95, 75, 57, 0.7);
}
a {
text-decoration: none;
}
.right-block {
display: flex;
align-items: center;
gap: 12px;
padding-left: 10px;
margin-left: auto;
flex: 0 0 auto;
border-left: 1px solid rgba(93, 64, 55, 0.12);
position: relative;
z-index: 3;
background:
linear-gradient(180deg, rgba(249, 236, 225, 0.98) 0%, rgba(246, 228, 212, 0.98) 100%);
}
.logoutblock {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
.menuitem {
cursor: pointer;
color: var(--color-primary);
font-weight: 700;
}
.mailbox {
background-image: url('@/assets/images/icons/message24.png');
background-size: contain;
background-repeat: no-repeat;
background-position: center;
width: 38px;
height: 38px;
border-radius: 999px;
background-color: rgba(120, 195, 138, 0.12);
border: 1px solid rgba(93, 64, 55, 0.1);
box-shadow: none;
min-height: 0;
padding: 0;
}
.mainmenuitem { position: relative; font-weight: 700; }
.submenu1 {
position: absolute;
display: block;
border: 1px solid rgba(93, 64, 55, 0.12);
background: rgba(255, 252, 247, 0.99);
left: 0;
top: calc(100% + 10px);
min-width: 240px;
padding: 10px;
border-radius: var(--radius-lg);
box-shadow: 0 18px 30px rgba(93, 64, 55, 0.14);
max-height: 0;
overflow: visible;
opacity: 0;
visibility: hidden;
transition: max-height 0.25s ease-in-out,
opacity 0.05s ease-in-out,
visibility 0s 0.05s;
}
.mainmenuitem:hover .submenu1 {
max-height: 500px;
opacity: 1;
visibility: visible;
transition: max-height 0.25s ease-in-out,
opacity 0.05s ease-in-out,
visibility 0s;
}
.mainmenuitem--expanded .submenu1 {
max-height: 500px;
opacity: 1;
visibility: visible;
transition: max-height 0.25s ease-in-out,
opacity 0.05s ease-in-out,
visibility 0s;
}
.submenu1 > li {
display: block;
padding: 0.75em 0.9em;
line-height: 1.1em;
color: var(--color-text-secondary);
position: relative;
border-radius: 14px;
}
.submenu1 > li:hover {
color: var(--color-text-primary);
background-color: rgba(248, 162, 43, 0.12);
}
.menu-icon,
.submenu-icon {
display: inline-block;
background-repeat: no-repeat;
line-height: 1em;
}
.menu-icon {
width: 24px;
height: 24px;
margin-right: 8px;
}
.submenu-icon {
width: 1.2em;
height: 1em;
margin-right: 3px;
background-size: 1.2em 1.2em;
}
.submenu2 {
position: absolute;
display: block;
background: rgba(255, 252, 247, 0.98);
left: calc(100% + 8px);
top: 0;
min-width: 230px;
padding: 8px;
border-radius: var(--radius-lg);
border: 1px solid rgba(71, 52, 35, 0.12);
box-shadow: 0 14px 24px rgba(93, 64, 55, 0.12);
max-height: 0;
overflow: hidden;
opacity: 0;
visibility: hidden;
transition: max-height 0.25s ease-in-out,
opacity 0.05s ease-in-out,
visibility 0s 0.05s;
}
.submenu1 > li:hover .submenu2 {
max-height: 500px;
opacity: 1;
visibility: visible;
transition: max-height 0.25s ease-in-out,
opacity 0.05s ease-in-out,
visibility 0s;
}
.submenu1__item--expanded .submenu2 {
max-height: 500px;
opacity: 1;
visibility: visible;
transition: max-height 0.25s ease-in-out,
opacity 0.05s ease-in-out,
visibility 0s;
}
.app-navigation--suppress-hover .mainmenuitem:hover .submenu1,
.app-navigation--suppress-hover .submenu1 > li:hover .submenu2 {
max-height: 0;
opacity: 0;
visibility: hidden;
}
.submenu1__item--expanded {
color: var(--color-text-primary);
background-color: rgba(248, 162, 43, 0.08);
}
.submenu2 > li {
padding: 0.75em 0.9em;
line-height: 1em;
color: var(--color-text-secondary);
border-radius: 14px;
}
.submenu2 > li:hover {
color: var(--color-text-primary);
background-color: rgba(120, 195, 138, 0.14);
}
.submenu1 > li:focus-visible,
.submenu2 > li:focus-visible {
color: var(--color-text-primary);
background-color: rgba(248, 162, 43, 0.12);
}
.subsubmenu {
float: right;
font-size: 8pt;
margin-right: -4px;
}
.username {
font-weight: 800;
color: var(--color-text-secondary);
}
@media (max-width: 960px) {
.app-navigation {
margin: 0;
flex-direction: column;
flex-wrap: nowrap;
padding: 8px 10px;
align-items: stretch;
}
.nav-primary,
.nav-primary > ul,
.right-block {
width: 100%;
}
.nav-primary {
overflow-x: auto;
overflow-y: visible;
}
.nav-primary > ul {
min-width: 0;
flex-wrap: wrap;
gap: 8px;
}
.right-block {
justify-content: space-between;
padding-left: 0;
margin-left: 0;
border-left: 0;
padding-top: 6px;
border-top: 1px solid rgba(93, 64, 55, 0.1);
}
.logoutblock {
align-items: flex-start;
}
.mainmenuitem {
min-height: 42px;
width: calc(50% - 4px);
justify-content: flex-start;
padding: 0 14px;
}
.submenu1,
.submenu2 {
position: static;
min-width: 100%;
margin-top: 8px;
max-height: 0;
overflow: hidden;
opacity: 0;
visibility: hidden;
padding: 0 10px;
}
.submenu1--open,
.submenu2--open {
max-height: 1200px;
opacity: 1;
visibility: visible;
padding: 10px;
}
.submenu1 > li,
.submenu2 > li {
min-height: 42px;
display: flex;
align-items: center;
}
.mailbox {
width: 42px;
height: 42px;
}
}
@media (max-width: 640px) {
.mainmenuitem {
width: 100%;
}
.right-block {
flex-wrap: wrap;
gap: 10px;
}
.logoutblock {
width: 100%;
}
}
</style>