Refactor backend CORS settings to include default origins and improve error handling in chat services: Introduce dynamic CORS origin handling, enhance RabbitMQ message sending with fallback mechanisms, and update WebSocket service to manage pending messages. Update UI components for better accessibility and responsiveness, including adjustments to dialog and navigation elements. Enhance styling for improved user experience across various components.

This commit is contained in:
Torsten Schulz (local)
2026-03-19 14:44:04 +01:00
parent 4442937ebd
commit 9d44a265ca
67 changed files with 5426 additions and 1099 deletions

View File

@@ -2,6 +2,7 @@
<main class="app-content contenthidden">
<div class="app-content__scroll contentscroll">
<div class="app-content__inner">
<AppSectionBar />
<router-view></router-view>
</div>
</div>
@@ -9,14 +10,20 @@
</template>
<script>
import AppSectionBar from './AppSectionBar.vue';
export default {
name: 'AppContent'
name: 'AppContent',
components: {
AppSectionBar
}
};
</script>
<style scoped>
.app-content {
flex: 1;
height: auto;
min-height: 0;
padding: 0;
overflow: hidden;
@@ -24,20 +31,20 @@
.app-content__scroll {
background: transparent;
height: 100%;
min-height: 0;
}
.app-content__inner {
max-width: var(--shell-max-width);
height: 100%;
min-height: 0;
min-height: 100%;
margin: 0 auto;
padding: 14px 18px;
padding: 14px 18px 12px;
}
@media (max-width: 960px) {
.app-content__inner {
padding: 12px;
padding: 12px 12px 10px;
}
}
</style>

View File

@@ -1,18 +1,31 @@
<template>
<footer class="app-footer">
<div class="app-footer__inner">
<button class="footer-brand" type="button" @click="showFalukantDaemonStatus">
<img src="/images/icons/logo_color.png" alt="YourPart" />
<span>System</span>
</button>
<div class="window-bar">
<button v-for="dialog in openDialogs" :key="dialog.dialog.name" class="dialog-button"
@click="toggleDialogMinimize(dialog.dialog.name)" :title="dialog.dialog.localTitle">
<img v-if="dialog.dialog.icon" :src="'/images/icons/' + dialog.dialog.icon" />
<span class="button-text">{{ dialog.dialog.isTitleTranslated ? $t(dialog.dialog.localTitle) :
dialog.dialog.localTitle }}</span>
</button>
<div class="footer-system">
<button class="footer-brand" type="button" @click="showFalukantDaemonStatus">
<img src="/images/icons/logo_color.png" alt="YourPart" />
<span>System</span>
</button>
<span class="footer-caption">
{{ openDialogs.length === 0 ? 'Keine offenen Dialoge' : `${openDialogs.length} Fenster aktiv` }}
</span>
</div>
<div class="window-bar" :class="{ 'window-bar--empty': openDialogs.length === 0 }">
<button
v-for="dialog in openDialogs"
:key="dialog.dialog.name"
class="dialog-button"
@click="toggleDialogMinimize(dialog.dialog.name)"
:title="dialog.dialog.localTitle"
>
<img v-if="dialog.dialog.icon" :src="'/images/icons/' + dialog.dialog.icon" />
<span class="button-text">{{ dialog.dialog.isTitleTranslated ? $t(dialog.dialog.localTitle) :
dialog.dialog.localTitle }}</span>
</button>
<span v-if="openDialogs.length === 0" class="window-bar__empty">System bereit</span>
</div>
<div class="static-block">
<a href="#" @click.prevent="openImprintDialog">{{ $t('imprint.button') }}</a>
<a href="#" @click.prevent="openDataPrivacyDialog">{{ $t('dataPrivacy.button') }}</a>
@@ -88,6 +101,12 @@ export default {
box-shadow: 0 -6px 18px rgba(93, 64, 55, 0.06);
}
.footer-system {
display: flex;
align-items: center;
gap: 10px;
}
.footer-brand {
min-height: 32px;
padding: 0 10px 0 8px;
@@ -111,6 +130,12 @@ export default {
font-weight: 700;
}
.footer-caption {
font-size: 0.76rem;
color: var(--color-text-secondary);
white-space: nowrap;
}
.window-bar {
flex: 1;
display: flex;
@@ -118,6 +143,20 @@ export default {
justify-content: flex-start;
gap: 10px;
overflow: auto;
min-width: 0;
padding: 4px 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.42);
border: 1px solid rgba(120, 195, 138, 0.16);
}
.window-bar--empty {
justify-content: center;
}
.window-bar__empty {
font-size: 0.78rem;
color: var(--color-text-muted);
}
.dialog-button {
@@ -151,6 +190,8 @@ export default {
align-items: center;
gap: 18px;
white-space: nowrap;
padding-left: 8px;
border-left: 1px solid rgba(120, 195, 138, 0.22);
}
.static-block>a {
@@ -167,14 +208,48 @@ export default {
flex-wrap: wrap;
}
.footer-system,
.window-bar,
.static-block {
width: 100%;
}
.footer-system {
justify-content: space-between;
}
.static-block {
justify-content: space-between;
gap: 12px;
padding-left: 0;
border-left: 0;
border-top: 1px solid rgba(120, 195, 138, 0.2);
padding-top: 6px;
}
}
@media (max-width: 640px) {
.app-footer__inner {
gap: 10px;
padding: 8px 10px 10px;
}
.footer-system {
flex-wrap: wrap;
}
.window-bar {
border-radius: var(--radius-md);
padding: 6px;
}
.dialog-button {
min-height: 34px;
}
.static-block {
flex-wrap: wrap;
justify-content: flex-start;
}
}
</style>

View File

@@ -5,11 +5,13 @@
<div class="logo"><img src="/images/logos/logo.png" alt="YourPart" /></div>
<div class="brand-copy">
<strong>YourPart</strong>
<span>Community, Spiele und Lernen auf einer Plattform</span>
<span>Community-Plattform</span>
</div>
</div>
<div class="header-meta">
<div class="header-pill">Beta</div>
<div class="header-meta__context">
<span class="header-pill">Beta</span>
</div>
<div class="connection-status" v-if="isLoggedIn">
<div class="status-indicator" :class="backendStatusClass">
<span class="status-dot"></span>
@@ -56,12 +58,12 @@ export default {
.app-header {
position: relative;
flex: 0 0 auto;
padding: 8px 14px;
padding: 6px 14px;
background:
linear-gradient(180deg, rgba(255, 248, 236, 0.94) 0%, rgba(247, 235, 216, 0.98) 100%);
linear-gradient(180deg, rgba(255, 249, 240, 0.96) 0%, rgba(246, 236, 220, 0.98) 100%);
color: #2b1f14;
border-bottom: 1px solid rgba(93, 64, 55, 0.12);
box-shadow: 0 6px 18px rgba(93, 64, 55, 0.08);
box-shadow: 0 5px 14px rgba(93, 64, 55, 0.06);
}
.app-header__inner {
@@ -77,15 +79,16 @@ export default {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.logo {
width: 42px;
height: 42px;
padding: 6px;
border-radius: 14px;
width: 40px;
height: 40px;
padding: 5px;
border-radius: 12px;
background:
linear-gradient(180deg, rgba(248, 162, 43, 0.2) 0%, rgba(255, 255, 255, 0.7) 100%);
linear-gradient(180deg, rgba(248, 162, 43, 0.18) 0%, rgba(255, 255, 255, 0.76) 100%);
border: 1px solid rgba(248, 162, 43, 0.22);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.55);
}
@@ -99,18 +102,22 @@ export default {
.brand-copy {
display: flex;
flex-direction: column;
gap: 1px;
gap: 0;
min-width: 0;
}
.brand-copy strong {
font-size: 1.05rem;
font-size: 1rem;
line-height: 1.1;
color: #3a2a1b;
}
.brand-copy span {
font-size: 0.8rem;
color: rgba(95, 75, 57, 0.88);
font-size: 0.74rem;
color: rgba(95, 75, 57, 0.78);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.header-meta {
@@ -119,10 +126,16 @@ export default {
gap: 12px;
}
.header-meta__context {
display: flex;
align-items: center;
gap: 10px;
}
.header-pill {
padding: 5px 10px;
border-radius: 999px;
background: rgba(248, 162, 43, 0.12);
background: rgba(248, 162, 43, 0.14);
border: 1px solid rgba(248, 162, 43, 0.24);
font-size: 0.72rem;
font-weight: 700;
@@ -205,17 +218,46 @@ export default {
}
.app-header__inner {
gap: 8px;
gap: 10px;
flex-wrap: wrap;
}
.header-meta {
width: 100%;
justify-content: space-between;
flex-wrap: wrap;
}
.header-meta__context {
flex-wrap: wrap;
}
.brand-copy span {
font-size: 0.76rem;
white-space: normal;
}
}
@media (max-width: 640px) {
.app-header__inner {
align-items: flex-start;
}
.brand {
width: 100%;
}
.header-meta {
gap: 8px;
}
.connection-status {
width: 100%;
flex-wrap: wrap;
}
.status-indicator {
min-height: 32px;
}
}
</style>

View File

@@ -1,26 +1,44 @@
<template>
<nav>
<ul>
<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"
@click="handleItem(item, $event)"
: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>{{ $t(`navigation.${key}`) }}</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">
<ul v-if="hasTopLevelSubmenu(item)" class="submenu1" :class="{ 'submenu1--open': isMainExpanded(key) }">
<li
v-for="(subitem, subkey) in item.children"
:key="subkey"
@click="handleItem(subitem, $event)"
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"
@@ -37,11 +55,16 @@
<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>
@@ -51,16 +74,25 @@
<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>
@@ -70,11 +102,16 @@
<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"
@@ -91,17 +128,29 @@
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>
@@ -109,13 +158,14 @@
</li>
</ul>
</li>
</ul>
</ul>
</div>
<div class="right-block">
<span @click="accessMailbox" class="mailbox"></span>
<button type="button" @click="accessMailbox" class="mailbox" aria-label="Mailbox"></button>
<span class="logoutblock">
<span class="username">{{ user.username }}</span>
<span @click="logout" class="menuitem">
<span class="menuitem" @click="logout">
{{ $t('navigation.logout') }}
</span>
</span>
@@ -127,6 +177,7 @@
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';
@@ -146,7 +197,14 @@ export default {
return {
forumList: [],
friendsList: [],
vocabLanguagesList: []
vocabLanguagesList: [],
expandedMainKey: null,
expandedSubKey: null,
pinnedMainKey: null,
pinnedSubKey: null,
suppressHover: false,
hoverReleaseTimer: null,
isMobileNav: false
};
},
computed: {
@@ -156,6 +214,9 @@ export default {
menuNeedsUpdate(newVal) {
if (newVal) this.loadMenu();
},
$route() {
this.collapseMenus();
},
socket(newSocket) {
if (newSocket) {
newSocket.on('forumschanged', this.fetchForums);
@@ -171,6 +232,10 @@ export default {
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;
@@ -179,10 +244,88 @@ export default {
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;
@@ -211,6 +354,18 @@ export default {
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 = [
@@ -227,6 +382,21 @@ export default {
}
},
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');
@@ -278,10 +448,18 @@ export default {
* 3) Bei `action`: custom action aufrufen
* 4) Sonst: normale Router-Navigation
*/
handleItem(item, event) {
handleItem(item, event, key = null) {
event.stopPropagation();
// 1) nur aufklappen, wenn es echte Untermenüs gibt (nicht bei leerem children wie bei Startseite)
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
@@ -299,18 +477,38 @@ export default {
} 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') {
return this[item.action](item.params, event);
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);
}
}
};
@@ -319,36 +517,45 @@ export default {
<style lang="scss" scoped>
@import '../assets/styles.scss';
nav,
nav > ul {
.app-navigation,
.nav-primary > ul {
display: flex;
justify-content: space-between;
padding: 0;
margin: 0;
cursor: pointer;
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 {
max-width: var(--shell-max-width);
margin: 0 auto;
align-items: stretch;
gap: 10px;
padding: 6px 12px;
border-radius: 0;
background: var(--color-primary-orange-light);
border-top: 1px solid rgba(93, 64, 55, 0.08);
border-bottom: 1px solid rgba(93, 64, 55, 0.12);
box-shadow: none;
color: var(--color-text-primary);
.nav-primary {
flex: 1;
min-width: 0;
overflow: visible;
position: relative;
z-index: 1;
}
nav > ul {
flex: 1;
.nav-primary > ul {
min-width: 0;
justify-content: flex-start;
align-items: center;
gap: 6px;
background: transparent;
flex-wrap: wrap;
}
@@ -358,28 +565,57 @@ ul {
margin: 0;
}
nav > ul > li {
.mainmenuitem {
display: flex;
align-items: center;
justify-content: center;
min-height: 36px;
padding: 0 12px;
line-height: 1;
cursor: pointer;
border-radius: 999px;
transition: background-color 0.25s, color 0.25s, transform 0.2s;
border: 1px solid transparent;
transition: background-color 0.25s, color 0.25s, transform 0.2s, border-color 0.25s, box-shadow 0.25s;
}
nav > ul > li:hover {
.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);
white-space: nowrap;
border-color: rgba(248, 162, 43, 0.2);
transform: translateY(-1px);
}
nav > ul > li:hover > span {
.mainmenuitem:hover > span {
color: var(--color-primary);
}
nav > ul > li:hover > ul {
display: inline-block;
.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 {
@@ -390,7 +626,14 @@ a {
display: flex;
align-items: center;
gap: 12px;
padding-left: 8px;
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 {
@@ -411,29 +654,29 @@ a {
background-size: contain;
background-repeat: no-repeat;
background-position: center;
width: 40px;
height: 40px;
text-align: left;
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;
}
.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.98);
background: rgba(255, 252, 247, 0.99);
left: 0;
top: calc(100% + 10px);
min-width: 220px;
padding: 8px;
border-radius: 18px;
box-shadow: 0 10px 18px rgba(93, 64, 55, 0.12);
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;
@@ -452,9 +695,19 @@ a {
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: 1em;
line-height: 1.1em;
color: var(--color-text-secondary);
position: relative;
border-radius: 14px;
@@ -487,14 +740,15 @@ a {
.submenu2 {
position: absolute;
display: block;
background: rgba(255, 252, 247, 0.98);
left: calc(100% + 8px);
top: 0;
min-width: 220px;
min-width: 230px;
padding: 8px;
border-radius: 18px;
border-radius: var(--radius-lg);
border: 1px solid rgba(71, 52, 35, 0.12);
box-shadow: 0 10px 18px rgba(93, 64, 55, 0.12);
box-shadow: 0 14px 24px rgba(93, 64, 55, 0.12);
max-height: 0;
overflow: hidden;
opacity: 0;
@@ -513,6 +767,27 @@ a {
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;
@@ -525,6 +800,12 @@ a {
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;
@@ -533,34 +814,100 @@ a {
.username {
font-weight: 800;
color: var(--color-text-secondary);
}
@media (max-width: 960px) {
nav {
.app-navigation {
margin: 0;
flex-direction: column;
flex-wrap: nowrap;
padding: 8px 10px;
align-items: stretch;
}
nav > ul,
.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>

View File

@@ -0,0 +1,198 @@
<template>
<section v-if="isVisible" class="app-section-bar surface-card">
<div class="app-section-bar__copy">
<span class="app-section-bar__eyebrow">{{ sectionLabel }}</span>
<h1 class="app-section-bar__title">{{ pageTitle }}</h1>
</div>
<button
v-if="backTarget"
type="button"
class="app-section-bar__back"
@click="navigateBack"
>
Zurueck
</button>
</section>
</template>
<script>
const SECTION_LABELS = [
{ test: (path) => path.startsWith('/falukant'), label: 'Falukant' },
{ test: (path) => path.startsWith('/socialnetwork/vocab'), label: 'Vokabeltrainer' },
{ test: (path) => path.startsWith('/socialnetwork/forum'), label: 'Forum' },
{ test: (path) => path.startsWith('/socialnetwork'), label: 'Community' },
{ test: (path) => path.startsWith('/friends'), label: 'Community' },
{ test: (path) => path.startsWith('/settings'), label: 'Einstellungen' },
{ test: (path) => path.startsWith('/admin'), label: 'Administration' },
{ test: (path) => path.startsWith('/minigames'), label: 'Minispiele' },
{ test: (path) => path.startsWith('/personal'), label: 'Persoenlich' },
{ test: (path) => path.startsWith('/blogs'), label: 'Blog' }
];
const TITLE_MAP = {
Friends: 'Freunde',
Guestbook: 'Gaestebuch',
'Search users': 'Suche',
Gallery: 'Galerie',
Forum: 'Forum',
ForumTopic: 'Thema',
Diary: 'Tagebuch',
VocabTrainer: 'Sprachen',
VocabNewLanguage: 'Neue Sprache',
VocabSubscribe: 'Sprache abonnieren',
VocabLanguage: 'Sprache',
VocabChapter: 'Kapitel',
VocabCourses: 'Kurse',
VocabCourse: 'Kurs',
VocabLesson: 'Lektion',
FalukantCreate: 'Charakter erstellen',
FalukantOverview: 'Uebersicht',
BranchView: 'Niederlassung',
MoneyHistoryView: 'Geldverlauf',
FalukantFamily: 'Familie',
HouseView: 'Haus',
NobilityView: 'Adel',
ReputationView: 'Ansehen',
ChurchView: 'Kirche',
EducationView: 'Bildung',
BankView: 'Bank',
DirectorView: 'Direktoren',
HealthView: 'Gesundheit',
PoliticsView: 'Politik',
UndergroundView: 'Untergrund',
'Personal settings': 'Persoenliche Daten',
'View settings': 'Ansicht',
'Sexuality settings': 'Sexualitaet',
'Flirt settings': 'Flirt',
'Account settings': 'Account',
Interests: 'Interessen',
AdminInterests: 'Interessenverwaltung',
AdminUsers: 'Benutzer',
AdminUserStatistics: 'Benutzerstatistik',
AdminContacts: 'Kontaktanfragen',
AdminUserRights: 'Rechte',
AdminForums: 'Forumverwaltung',
AdminChatRooms: 'Chaträume',
AdminFalukantEditUserView: 'Falukant-Nutzer',
AdminFalukantMapRegionsView: 'Falukant-Karte',
AdminFalukantCreateNPCView: 'NPC erstellen',
AdminMinigames: 'Match3-Verwaltung',
AdminTaxiTools: 'Taxi-Tools',
AdminServicesStatus: 'Service-Status'
};
export default {
name: 'AppSectionBar',
computed: {
routePath() {
return this.$route?.path || '';
},
isVisible() {
return Boolean(this.$route?.meta?.requiresAuth) && this.routePath !== '/';
},
sectionLabel() {
const found = SECTION_LABELS.find((entry) => entry.test(this.routePath));
return found?.label || 'Bereich';
},
pageTitle() {
return TITLE_MAP[this.$route?.name] || this.sectionLabel;
},
backTarget() {
const params = this.$route?.params || {};
if (this.routePath.startsWith('/socialnetwork/vocab/courses/') && params.lessonId && params.courseId) {
return `/socialnetwork/vocab/courses/${params.courseId}`;
}
if (this.routePath.startsWith('/socialnetwork/vocab/') && params.chapterId && params.languageId) {
return `/socialnetwork/vocab/${params.languageId}`;
}
if (this.routePath.startsWith('/socialnetwork/vocab/new') || this.routePath.startsWith('/socialnetwork/vocab/subscribe')) {
return '/socialnetwork/vocab';
}
if (this.routePath.startsWith('/socialnetwork/vocab/courses/') && params.courseId) {
return '/socialnetwork/vocab/courses';
}
if (this.routePath.startsWith('/admin/users/statistics')) {
return '/admin/users';
}
if (this.routePath.startsWith('/falukant/') && this.routePath !== '/falukant/home') {
return '/falukant/home';
}
if (this.routePath.startsWith('/settings/') && this.routePath !== '/settings/personal') {
return '/settings/personal';
}
if (this.routePath.startsWith('/admin/') && this.routePath !== '/admin/users') {
return '/admin/users';
}
return null;
}
},
methods: {
navigateBack() {
if (this.backTarget) {
this.$router.push(this.backTarget);
}
}
}
};
</script>
<style scoped>
.app-section-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 18px;
margin-bottom: 16px;
background: linear-gradient(180deg, rgba(255, 252, 247, 0.98), rgba(248, 240, 231, 0.94));
}
.app-section-bar__copy {
min-width: 0;
}
.app-section-bar__eyebrow {
display: inline-flex;
margin-bottom: 6px;
padding: 4px 10px;
border-radius: var(--radius-pill);
background: var(--color-secondary-soft);
color: var(--color-text-secondary);
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.app-section-bar__title {
margin: 0;
font-size: clamp(1.15rem, 1.6vw, 1.6rem);
}
.app-section-bar__back {
flex: 0 0 auto;
background: rgba(255, 255, 255, 0.82);
box-shadow: none;
border: 1px solid var(--color-border);
}
@media (max-width: 760px) {
.app-section-bar {
flex-direction: column;
align-items: flex-start;
}
.app-section-bar__back {
width: 100%;
}
}
</style>

View File

@@ -12,13 +12,27 @@
<script>
import { markRaw } from 'vue';
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
import { getApiBaseURL } from '@/utils/axios.js';
/** Backend-Route: GET /api/models/3d/falukant/characters/:filename (Proxy mit Draco-Optimierung) */
const MODELS_API_PATH = '/api/models/3d/falukant/characters';
let threeRuntimePromise = null;
async function loadThreeRuntime() {
if (!threeRuntimePromise) {
threeRuntimePromise = Promise.all([
import('three'),
import('three/addons/loaders/GLTFLoader.js'),
import('three/addons/loaders/DRACOLoader.js')
]).then(([THREE, { GLTFLoader }, { DRACOLoader }]) => ({
THREE,
GLTFLoader,
DRACOLoader
}));
}
return threeRuntimePromise;
}
export default {
name: 'Character3D',
@@ -48,9 +62,10 @@ export default {
model: null,
animationId: null,
mixer: null,
clock: markRaw(new THREE.Clock()),
clock: null,
baseYPosition: 0,
showFallback: false
showFallback: false,
threeRuntime: null
};
},
computed: {
@@ -110,32 +125,42 @@ export default {
}
},
watch: {
actualGender() {
this.loadModel();
async actualGender() {
await this.loadModel();
},
ageGroup() {
this.loadModel();
async ageGroup() {
await this.loadModel();
}
},
mounted() {
this.init3D();
this.loadModel();
async mounted() {
await this.init3D();
await this.loadModel();
this.animate();
},
beforeUnmount() {
this.cleanup();
},
methods: {
init3D() {
async ensureThreeRuntime() {
if (!this.threeRuntime) {
this.threeRuntime = markRaw(await loadThreeRuntime());
}
return this.threeRuntime;
},
async init3D() {
const container = this.$refs.container;
if (!container) return;
this.showFallback = false;
const { THREE } = await this.ensureThreeRuntime();
this.clock = markRaw(new THREE.Clock());
// Scene erstellen - markRaw verwenden, um Vue's Reactivity zu vermeiden
this.scene = markRaw(new THREE.Scene());
if (!this.noBackground) {
this.scene.background = new THREE.Color(0xf0f0f0);
this.loadBackground();
await this.loadBackground();
}
// Camera erstellen
@@ -174,7 +199,8 @@ export default {
window.addEventListener('resize', this.onWindowResize);
},
loadBackground() {
async loadBackground() {
const { THREE } = await this.ensureThreeRuntime();
// Optimierte Versionen (512×341, ~130 KB); Originale ~3 MB
const backgrounds = ['bg1_opt.png', 'bg2_opt.png'];
const randomBg = backgrounds[Math.floor(Math.random() * backgrounds.length)];
@@ -202,6 +228,7 @@ export default {
async loadModel() {
if (!this.scene) return;
const { THREE, GLTFLoader, DRACOLoader } = await this.ensureThreeRuntime();
// Altes Modell entfernen
if (this.model) {
@@ -323,6 +350,10 @@ export default {
animate() {
this.animationId = requestAnimationFrame(this.animate);
if (!this.clock) {
return;
}
const delta = this.clock.getDelta();
// Animation-Mixer aktualisieren

View File

@@ -16,7 +16,7 @@
<slot></slot>
</div>
<div class="dialog-footer">
<button v-for="button in buttons" :key="button.text" @click="buttonClick(button.action)" class="dialog-button">
<button v-for="button in buttons" :key="button.text" @click="buttonClick(button.action)" class="dialog-button" :disabled="button.disabled">
{{ isTitleTranslated ? $t(button.text) : button.text }}
</button>
</div>
@@ -142,6 +142,9 @@ export default {
return this.minimized;
},
startDragging(event) {
if (window.innerWidth <= 760) {
return;
}
this.isDragging = true;
const dialog = this.$refs.dialog;
this.dragOffsetX = event.clientX - dialog.offsetLeft;
@@ -186,7 +189,8 @@ export default {
align-items: center;
justify-content: center;
z-index: 1000;
background: rgba(0, 0, 0, 0.5);
background: rgba(24, 18, 11, 0.44);
backdrop-filter: blur(10px);
}
.dialog-overlay.non-modal {
@@ -195,14 +199,17 @@ export default {
}
.dialog {
background: white;
background:
linear-gradient(180deg, rgba(255, 252, 247, 0.98) 0%, rgba(249, 242, 232, 0.98) 100%);
display: flex;
flex-direction: column;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
border-radius: 8px;
box-shadow: var(--shadow-medium);
border-radius: var(--radius-lg);
border: 1px solid rgba(93, 64, 55, 0.12);
pointer-events: all;
position: absolute;
transform: translate(-50%, -50%);
overflow: hidden;
}
.dialog.minimized {
@@ -214,64 +221,112 @@ export default {
display: flex;
align-items: center;
justify-content: space-between;
padding: 5px 5px;
border-bottom: 1px solid #ddd;
background-color: var(--color-primary-orange);
gap: 10px;
padding: 12px 16px;
border-bottom: 1px solid rgba(93, 64, 55, 0.1);
background:
linear-gradient(180deg, rgba(248, 162, 43, 0.16) 0%, rgba(255, 255, 255, 0.56) 100%);
cursor: move;
}
.dialog-icon {
padding: 2px 5px 0 0;
padding: 2px 6px 0 0;
}
.dialog-icon img {
width: 20px;
height: 20px;
object-fit: contain;
}
.dialog-title {
flex-grow: 1;
font-size: 1.5em;
font-weight: bold;
font-size: 1.08rem;
font-weight: 800;
color: var(--color-text-primary);
}
.dialog-close,
.dialog-minimize {
cursor: pointer;
font-size: 1.5em;
margin-left: 10px;
font-size: 1.1rem;
margin-left: 0;
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
color: var(--color-text-secondary);
transition: background-color var(--transition-fast), color var(--transition-fast);
}
.dialog-close:hover,
.dialog-minimize:hover {
background: rgba(255, 255, 255, 0.72);
color: var(--color-text-primary);
}
.dialog-body {
flex-grow: 1;
padding: 20px;
padding: 18px 20px;
overflow-y: auto;
display: var(--dialog-display);
color: var(--color-text-primary);
&[style*="--dialog-display: flex"] {
flex-direction: column;
}
}
dialog-footer {
.dialog-footer {
display: flex;
justify-content: flex-end;
padding: 10px 20px;
border-top: 1px solid #ddd;
gap: 10px;
padding: 14px 20px 18px;
border-top: 1px solid rgba(93, 64, 55, 0.08);
background: rgba(255, 255, 255, 0.46);
}
.dialog-button {
margin-left: 10px;
padding: 5px 10px;
cursor: pointer;
background: var(--color-primary-orange);
color: #000000;
border: none;
border-radius: 4px;
transition: background 0.02s;
margin-left: 0;
min-height: 38px;
}
.dialog-button:hover {
background: #FFF4F0;
color: #5D4037;
border: 1px solid #5D4037;
color: #2b1f14;
}
.is-active {
z-index: 1100;
}
@media (max-width: 760px) {
.dialog {
width: calc(100vw - 16px) !important;
max-width: calc(100vw - 16px);
height: auto !important;
max-height: calc(100dvh - 16px);
}
.dialog-header {
cursor: default;
padding: 10px 12px;
}
.dialog-title {
font-size: 1rem;
}
.dialog-body {
padding: 14px;
}
.dialog-footer {
flex-direction: column;
}
.dialog-button {
width: 100%;
}
}
</style>

View File

@@ -105,7 +105,8 @@ export default {
align-items: center;
justify-content: center;
z-index: 1000;
background: rgba(0, 0, 0, 0.5);
background: rgba(24, 18, 11, 0.44);
backdrop-filter: blur(10px);
}
.dialog-overlay.non-modal {
@@ -114,12 +115,14 @@ export default {
}
.dialog {
background: white;
background: linear-gradient(180deg, rgba(255, 252, 247, 0.98) 0%, rgba(249, 242, 232, 0.98) 100%);
display: flex;
flex-direction: column;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
border-radius: 8px;
box-shadow: var(--shadow-medium);
border-radius: var(--radius-lg);
border: 1px solid rgba(93, 64, 55, 0.12);
pointer-events: all;
overflow: hidden;
}
.dialog.minimized {
@@ -131,9 +134,9 @@ export default {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 20px;
border-bottom: 1px solid #ddd;
background-color: var(--color-primary-orange);
padding: 12px 16px;
border-bottom: 1px solid rgba(93, 64, 55, 0.1);
background: linear-gradient(180deg, rgba(248, 162, 43, 0.16) 0%, rgba(255, 255, 255, 0.56) 100%);
}
.dialog-icon {
@@ -142,42 +145,46 @@ export default {
.dialog-title {
flex-grow: 1;
font-size: 1.5em;
font-weight: bold;
font-size: 1.08rem;
font-weight: 800;
color: var(--color-text-primary);
}
.dialog-close,
.dialog-minimize {
cursor: pointer;
font-size: 1.5em;
margin-left: 10px;
font-size: 1.1rem;
margin-left: 0;
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
color: var(--color-text-secondary);
}
.dialog-body {
flex-grow: 1;
padding: 20px;
padding: 18px 20px;
overflow-y: auto;
color: var(--color-text-primary);
}
.dialog-footer {
display: flex;
justify-content: flex-end;
padding: 10px 20px;
border-top: 1px solid #ddd;
padding: 14px 20px 18px;
border-top: 1px solid rgba(93, 64, 55, 0.08);
background: rgba(255, 255, 255, 0.46);
}
.dialog-button {
margin-left: 10px;
padding: 10px 20px;
cursor: pointer;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
transition: background 0.3s;
margin-left: 0;
min-height: 38px;
}
.dialog-button:hover {
background: #0056b3;
color: #2b1f14;
}
</style>

View File

@@ -85,6 +85,7 @@ import InputNumberWidget from '@/components/form/InputNumberWidget.vue';
import FloatInputWidget from '@/components/form/FloatInputWidget.vue';
import CheckboxWidget from '@/components/form/CheckboxWidget.vue';
import MultiselectWidget from '@/components/form/MultiselectWidget.vue';
import { showApiError, showError } from '@/utils/feedback.js';
export default {
name: "SettingsWidget",
@@ -158,7 +159,7 @@ export default {
// Prüfe ob das Setting unveränderlich ist
const setting = this.settings.find(s => s.id === settingId);
if (setting && setting.immutable && setting.value) {
alert(this.$t('settings.immutable.tooltip'));
showError(this, this.$t('settings.immutable.tooltip'));
return;
}
@@ -172,9 +173,7 @@ export default {
this.fetchSettings();
} catch (err) {
console.error('Error updating setting:', err);
if (err.response && err.response.data && err.response.data.error) {
alert(err.response.data.error);
}
showApiError(this, err, 'Aenderung konnte nicht gespeichert werden.');
}
},
languagesList() {
@@ -208,6 +207,7 @@ export default {
});
} catch (err) {
console.error('Error updating visibility:', err);
showApiError(this, err, 'Sichtbarkeit konnte nicht aktualisiert werden.');
}
},
openContactDialog() {
@@ -267,4 +267,4 @@ export default {
color: var(--color-text-secondary);
border: 1px solid var(--color-text-secondary);
}
</style>
</style>

View File

@@ -43,6 +43,7 @@
<script>
import { mapState, mapGetters } from "vuex";
import apiClient from "@/utils/axios.js";
import { EventBus } from '@/utils/eventBus.js';
import MessagesDialog from './MessagesDialog.vue';
export default {
@@ -94,10 +95,12 @@ export default {
// Socket.IO (Backend notifyUser) Hauptkanal für Falukant-Events
this.setupSocketListeners();
this.setupDaemonListeners();
EventBus.on('open-falukant-messages', this.openMessages);
},
beforeUnmount() {
this.teardownSocketListeners();
this.teardownDaemonListeners();
EventBus.off('open-falukant-messages', this.openMessages);
},
methods: {
preloadQuickAccessImages() {
@@ -229,18 +232,20 @@ export default {
justify-content: center;
align-items: center;
flex-wrap: wrap;
background-color: #f4f4f4;
border: 1px solid #ccc;
border-radius: 4px;
background:
linear-gradient(180deg, rgba(255, 251, 246, 0.96) 0%, rgba(247, 238, 224, 0.98) 100%);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-sizing: border-box;
width: 100%;
max-width: 100%;
gap: 1.2em;
padding: 0.4rem 0.75rem;
padding: 0.55rem 0.9rem;
margin: 0 0 1.5em 0;
position: sticky;
top: 0;
z-index: 100;
box-shadow: var(--shadow-soft);
}
.status-item {
@@ -248,6 +253,11 @@ export default {
cursor: pointer;
display: inline-flex;
align-items: center;
min-height: 34px;
padding: 0 10px;
border-radius: 999px;
background: rgba(255,255,255,0.68);
border: 1px solid rgba(93, 64, 55, 0.08);
}
.quick-access {