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:
@@ -45,9 +45,9 @@
|
||||
--space-10: 40px;
|
||||
--space-12: 48px;
|
||||
|
||||
--radius-sm: 10px;
|
||||
--radius-md: 16px;
|
||||
--radius-lg: 24px;
|
||||
--radius-sm: 5px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-pill: 999px;
|
||||
|
||||
--shadow-soft: 0 12px 30px rgba(47, 29, 14, 0.08);
|
||||
@@ -118,7 +118,7 @@ span.button {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
min-height: 42px;
|
||||
min-height: 44px;
|
||||
padding: 0 18px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-pill);
|
||||
@@ -147,11 +147,23 @@ span.button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
button:disabled,
|
||||
.button:disabled,
|
||||
span.button:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible,
|
||||
a:focus-visible {
|
||||
a:focus-visible,
|
||||
[role="button"]:focus-visible,
|
||||
[role="menuitem"]:focus-visible,
|
||||
[tabindex]:focus-visible {
|
||||
outline: 3px solid rgba(120, 195, 138, 0.32);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@@ -263,11 +275,72 @@ main,
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.app-content__inner > .contenthidden {
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.app-content__inner > .contenthidden > .contentscroll {
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.surface-card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-soft);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.form-stack {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-field > label,
|
||||
.form-field > span:first-child {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 0.88rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.form-error {
|
||||
font-size: 0.88rem;
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.field-error {
|
||||
border-color: rgba(177, 59, 53, 0.44) !important;
|
||||
box-shadow: 0 0 0 4px rgba(177, 59, 53, 0.12) !important;
|
||||
}
|
||||
|
||||
.form-actions-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.button-secondary {
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
color: var(--color-text-primary);
|
||||
border-color: var(--color-border);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.button-secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.link {
|
||||
@@ -338,4 +411,27 @@ main,
|
||||
h2 {
|
||||
font-size: clamp(1.35rem, 5vw, 2rem);
|
||||
}
|
||||
|
||||
.contentscroll table {
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html:focus-within {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
> </span>
|
||||
<span>{{ $t(`navigation.${key}`) }}</span>
|
||||
<span class="mainmenuitem__label">{{ $t(`navigation.${key}`) }}</span>
|
||||
<span v-if="hasTopLevelSubmenu(item)" class="mainmenuitem__caret">▾</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) Standard‑Navigation
|
||||
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>
|
||||
|
||||
198
frontend/src/components/AppSectionBar.vue
Normal file
198
frontend/src/components/AppSectionBar.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
<template>
|
||||
<DialogWidget ref="dialog" title="passwordReset.title" :isTitleTranslated="true" :show-close=true :buttons="buttons" @close="closeDialog" @reset="resetPassword" name="PasswordReset">
|
||||
<div>
|
||||
<label>{{ $t("passwordReset.email") }} <input type="email" v-model="email" required /></label>
|
||||
<div class="form-stack">
|
||||
<div class="form-field">
|
||||
<label for="password-reset-email">{{ $t("passwordReset.email") }}</label>
|
||||
<input id="password-reset-email" type="email" v-model="email" required :class="{ 'field-error': emailTouched && !isEmailValid }" />
|
||||
<span class="form-hint">Wir senden den Link an die hinterlegte E-Mail-Adresse.</span>
|
||||
<span v-if="emailTouched && !isEmailValid" class="form-error">Bitte eine gueltige E-Mail-Adresse eingeben.</span>
|
||||
</div>
|
||||
</div>
|
||||
</DialogWidget>
|
||||
</template>
|
||||
@@ -9,6 +14,7 @@
|
||||
<script>
|
||||
import apiClient from '@/utils/axios.js';
|
||||
import DialogWidget from '@/components/DialogWidget.vue';
|
||||
import { showApiError, showSuccess } from '@/utils/feedback.js';
|
||||
|
||||
export default {
|
||||
name: 'PasswordResetDialog',
|
||||
@@ -18,9 +24,21 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
email: '',
|
||||
buttons: [{ text: 'passwordReset.reset', action: 'reset' }]
|
||||
emailTouched: false,
|
||||
buttons: [{ text: 'passwordReset.reset', action: 'reset', disabled: true }]
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isEmailValid() {
|
||||
return /\S+@\S+\.\S+/.test(this.email);
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
email() {
|
||||
this.emailTouched = true;
|
||||
this.buttons[0].disabled = !this.isEmailValid;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
open() {
|
||||
this.$refs.dialog.open();
|
||||
@@ -29,15 +47,18 @@ export default {
|
||||
this.$refs.dialog.close();
|
||||
},
|
||||
async resetPassword() {
|
||||
if (!this.isEmailValid) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await apiClient.post('/api/users/requestPasswordReset', {
|
||||
email: this.email
|
||||
});
|
||||
this.$refs.dialog.close();
|
||||
alert(this.$t("passwordReset.success"));
|
||||
showSuccess(this, 'tr:passwordReset.success');
|
||||
} catch (error) {
|
||||
console.error('Error resetting password:', error);
|
||||
alert(this.$t("passwordReset.failure"));
|
||||
showApiError(this, error, 'tr:passwordReset.failure');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,18 +2,27 @@
|
||||
<DialogWidget ref="dialog" title="register.title" :show-close="true" :buttons="buttons" :modal="true"
|
||||
@close="closeDialog" @register="register" width="35em" height="33em" name="RegisterDialog"
|
||||
:isTitleTranslated="true">
|
||||
<div class="form-content">
|
||||
<div>
|
||||
<label>{{ $t("register.email") }}<input type="email" v-model="email" /></label>
|
||||
<div class="form-content form-stack">
|
||||
<div class="form-field">
|
||||
<label for="register-email">{{ $t("register.email") }}</label>
|
||||
<input id="register-email" type="email" v-model="email" :class="{ 'field-error': emailTouched && !isEmailValid }" />
|
||||
<span v-if="emailTouched && !isEmailValid" class="form-error">Bitte eine gueltige E-Mail-Adresse eingeben.</span>
|
||||
</div>
|
||||
<div>
|
||||
<label>{{ $t("register.username") }}<input type="text" v-model="username" /></label>
|
||||
<div class="form-field">
|
||||
<label for="register-username">{{ $t("register.username") }}</label>
|
||||
<input id="register-username" type="text" v-model="username" :class="{ 'field-error': usernameTouched && !isUsernameValid }" />
|
||||
<span v-if="usernameTouched && !isUsernameValid" class="form-error">Der Benutzername sollte mindestens 3 Zeichen haben.</span>
|
||||
</div>
|
||||
<div>
|
||||
<label>{{ $t("register.password") }}<input type="password" v-model="password" /></label>
|
||||
<div class="form-field">
|
||||
<label for="register-password">{{ $t("register.password") }}</label>
|
||||
<input id="register-password" type="password" v-model="password" :class="{ 'field-error': passwordTouched && !isPasswordValid }" />
|
||||
<span class="form-hint">Mindestens 8 Zeichen.</span>
|
||||
<span v-if="passwordTouched && !isPasswordValid" class="form-error">Das Passwort ist noch zu kurz.</span>
|
||||
</div>
|
||||
<div>
|
||||
<label>{{ $t("register.repeatPassword") }}<input type="password" v-model="repeatPassword" /></label>
|
||||
<div class="form-field">
|
||||
<label for="register-repeat-password">{{ $t("register.repeatPassword") }}</label>
|
||||
<input id="register-repeat-password" type="password" v-model="repeatPassword" :class="{ 'field-error': repeatPasswordTouched && !doPasswordsMatch }" />
|
||||
<span v-if="repeatPasswordTouched && !doPasswordsMatch" class="form-error">Die Passwoerter stimmen nicht ueberein.</span>
|
||||
</div>
|
||||
<SelectDropdownWidget labelTr="settings.personal.label.language" :v-model="language"
|
||||
tooltipTr="settings.personal.tooltip.language" :list="languages" :value="language" />
|
||||
@@ -26,6 +35,7 @@ import { mapActions } from 'vuex';
|
||||
import apiClient from '@/utils/axios.js';
|
||||
import DialogWidget from '@/components/DialogWidget.vue';
|
||||
import SelectDropdownWidget from '@/components/form/SelectDropdownWidget.vue';
|
||||
import { showApiError, showError } from '@/utils/feedback.js';
|
||||
|
||||
export default {
|
||||
name: 'RegisterDialog',
|
||||
@@ -41,6 +51,10 @@ export default {
|
||||
repeatPassword: '',
|
||||
language: null,
|
||||
languages: [],
|
||||
emailTouched: false,
|
||||
usernameTouched: false,
|
||||
passwordTouched: false,
|
||||
repeatPasswordTouched: false,
|
||||
buttons: [
|
||||
{ text: 'register.close', action: 'close' },
|
||||
{ text: 'register.register', action: 'register', disabled: !this.canRegister }
|
||||
@@ -48,11 +62,35 @@ export default {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isEmailValid() {
|
||||
return /\S+@\S+\.\S+/.test(this.email);
|
||||
},
|
||||
isUsernameValid() {
|
||||
return this.username.trim().length >= 3;
|
||||
},
|
||||
isPasswordValid() {
|
||||
return this.password.length >= 8;
|
||||
},
|
||||
doPasswordsMatch() {
|
||||
return Boolean(this.password) && this.password === this.repeatPassword;
|
||||
},
|
||||
canRegister() {
|
||||
return this.password && this.repeatPassword && this.password === this.repeatPassword;
|
||||
return this.isEmailValid && this.isUsernameValid && this.isPasswordValid && this.doPasswordsMatch && this.language;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
email() {
|
||||
this.emailTouched = true;
|
||||
},
|
||||
username() {
|
||||
this.usernameTouched = true;
|
||||
},
|
||||
password() {
|
||||
this.passwordTouched = true;
|
||||
},
|
||||
repeatPassword() {
|
||||
this.repeatPasswordTouched = true;
|
||||
},
|
||||
canRegister(newValue) {
|
||||
this.buttons[1].disabled = !newValue;
|
||||
}
|
||||
@@ -82,7 +120,7 @@ export default {
|
||||
},
|
||||
async register() {
|
||||
if (!this.canRegister) {
|
||||
this.$root.$refs.errrorDialog.open('tr:register.passwordMismatch');
|
||||
showError(this, 'tr:register.passwordMismatch');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -99,14 +137,14 @@ export default {
|
||||
this.$refs.dialog.close();
|
||||
this.$router.push('/activate');
|
||||
} else {
|
||||
this.$root.$refs.errrorDialog.open("tr:register.failure");
|
||||
showError(this, 'tr:register.failure');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.response && error.response.status === 409) {
|
||||
this.$root.$refs.errrorDialog.open('tr:register.' + error.response.data.error);
|
||||
showError(this, `tr:register.${error.response.data.error}`);
|
||||
} else {
|
||||
console.error('Error registering user:', error);
|
||||
this.$root.$refs.errrorDialog.open('tr:register.' + error.response.data.error);
|
||||
showApiError(this, error, 'tr:register.failure');
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -125,21 +163,11 @@ export default {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-content>div {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
input[type="email"],
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 0.5em;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
{{ $t('falukant.branch.selection.selected') }}:
|
||||
<strong>{{ selectedRegion.name }}</strong>
|
||||
</div>
|
||||
<label class="form-label">
|
||||
<label class="form-label form-field">
|
||||
{{ $t('falukant.branch.columns.type') }}
|
||||
<select v-model="selectedType" class="form-control">
|
||||
<option
|
||||
@@ -72,8 +72,10 @@
|
||||
({{ formatCost(computeBranchCost(type)) }})
|
||||
</option>
|
||||
</select>
|
||||
<span class="form-hint">Waehle zuerst Region und dann den Niederlassungstyp.</span>
|
||||
</label>
|
||||
</div>
|
||||
<div v-else class="form-hint">Waehle auf der Karte eine freie Region aus.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,6 +85,7 @@
|
||||
<script>
|
||||
import DialogWidget from '@/components/DialogWidget.vue';
|
||||
import apiClient from '@/utils/axios.js';
|
||||
import { showApiError, showError, showSuccess } from '@/utils/feedback.js';
|
||||
|
||||
export default {
|
||||
name: 'CreateBranchDialog',
|
||||
@@ -109,7 +112,7 @@
|
||||
dialogButtons() {
|
||||
return [
|
||||
{ text: this.$t('Cancel'), action: this.close },
|
||||
{ text: this.$t('falukant.branch.actions.create'), action: this.onConfirm },
|
||||
{ text: this.$t('falukant.branch.actions.create'), action: this.onConfirm, disabled: !this.selectedRegion || !this.selectedType },
|
||||
];
|
||||
},
|
||||
},
|
||||
@@ -144,7 +147,10 @@
|
||||
},
|
||||
|
||||
async onConfirm() {
|
||||
if (!this.selectedRegion || !this.selectedType) return;
|
||||
if (!this.selectedRegion || !this.selectedType) {
|
||||
showError(this, 'Bitte zuerst Region und Typ auswaehlen.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await apiClient.post('/api/falukant/branches', {
|
||||
@@ -152,13 +158,14 @@
|
||||
branchTypeId: this.selectedType,
|
||||
});
|
||||
this.$emit('create-branch');
|
||||
showSuccess(this, 'Niederlassung erfolgreich erstellt.');
|
||||
this.close();
|
||||
} catch (e) {
|
||||
if (e?.response?.status === 412 && e?.response?.data?.error === 'insufficientFunds') {
|
||||
alert(this.$t('falukant.branch.actions.insufficientFunds'));
|
||||
showError(this, this.$t('falukant.branch.actions.insufficientFunds'));
|
||||
} else {
|
||||
console.error('Error creating branch', e);
|
||||
alert(this.$t('falukant.error.generic') || 'Fehler beim Erstellen der Niederlassung.');
|
||||
showApiError(this, e, this.$t('falukant.error.generic') || 'Fehler beim Erstellen der Niederlassung.');
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -365,4 +372,4 @@
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<template>
|
||||
<DialogWidget ref="dialog" title="error.title" :show-close="true" :buttons="buttons" :modal="true" width="25em"
|
||||
height="15em" name="ErrorDialog" :isTitleTranslated=true>
|
||||
<DialogWidget ref="dialog" title="error.title" :show-close="true" :buttons="buttons" :modal="true" width="28em"
|
||||
height="16em" name="ErrorDialog" :isTitleTranslated=true>
|
||||
<div class="error-content">
|
||||
<span class="error-content__badge">Fehler</span>
|
||||
<p>{{ translatedErrorMessage }}</p>
|
||||
</div>
|
||||
</DialogWidget>
|
||||
@@ -45,8 +46,27 @@ export default {
|
||||
|
||||
<style scoped>
|
||||
.error-content {
|
||||
padding: 1em;
|
||||
color: red;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 1.2em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-content__badge {
|
||||
justify-self: center;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(177, 59, 53, 0.12);
|
||||
color: var(--color-danger);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.error-content p {
|
||||
margin: 0;
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1.55;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<template>
|
||||
<DialogWidget ref="dialog" :title="translatedTitle" :show-close="true" :buttons="translatedButtons" :modal="true" width="25em"
|
||||
height="15em" name="MessageDialog" :isTitleTranslated=false>
|
||||
<DialogWidget ref="dialog" :title="translatedTitle" :show-close="true" :buttons="translatedButtons" :modal="true" width="28em"
|
||||
height="16em" name="MessageDialog" :isTitleTranslated=false>
|
||||
<div class="message-content">
|
||||
<span class="message-content__badge">Hinweis</span>
|
||||
<p>{{ translatedMessage }}</p>
|
||||
</div>
|
||||
</DialogWidget>
|
||||
@@ -41,14 +42,6 @@ export default {
|
||||
if (this.message.startsWith('tr:')) {
|
||||
const i18nKey = this.message.substring(3);
|
||||
const translation = this.$t(i18nKey);
|
||||
console.log('translatedMessage:', {
|
||||
i18nKey: i18nKey,
|
||||
translation: translation,
|
||||
parameters: this.parameters,
|
||||
allMinigames: this.$t('minigames'),
|
||||
crashSection: this.$t('minigames.taxi.crash')
|
||||
});
|
||||
// Ersetze Parameter in der Übersetzung
|
||||
return this.interpolateParameters(translation);
|
||||
}
|
||||
return this.message;
|
||||
@@ -89,26 +82,16 @@ export default {
|
||||
}
|
||||
},
|
||||
interpolateParameters(text) {
|
||||
// Ersetze {key} Platzhalter mit den entsprechenden Werten
|
||||
let result = text;
|
||||
console.log('interpolateParameters:', {
|
||||
originalText: text,
|
||||
parameters: this.parameters
|
||||
});
|
||||
|
||||
for (const [key, value] of Object.entries(this.parameters)) {
|
||||
const placeholder = `{${key}}`;
|
||||
const regex = new RegExp(placeholder.replace(/[{}]/g, '\\$&'), 'g');
|
||||
result = result.replace(regex, value);
|
||||
console.log(`Replaced ${placeholder} with ${value}:`, result);
|
||||
}
|
||||
|
||||
console.log('Final result:', result);
|
||||
return result;
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
// Stelle sicher, dass Event Listener entfernt wird
|
||||
beforeUnmount() {
|
||||
document.removeEventListener('keydown', this.handleKeyDown);
|
||||
}
|
||||
};
|
||||
@@ -116,8 +99,27 @@ export default {
|
||||
|
||||
<style scoped>
|
||||
.message-content {
|
||||
padding: 1em;
|
||||
color: #000000;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 1.2em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.message-content__badge {
|
||||
justify-self: center;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(120, 195, 138, 0.16);
|
||||
color: #24523a;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.message-content p {
|
||||
margin: 0;
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1.55;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -497,6 +497,7 @@
|
||||
"gifts": "Werbegeschenke",
|
||||
"sendGift": "Werbegeschenk senden",
|
||||
"cancel": "Werbung abbrechen",
|
||||
"cancelConfirm": "Willst du die Werbung wirklich abbrechen? Der Fortschritt geht dabei verloren.",
|
||||
"cancelSuccess": "Die Werbung wurde abgebrochen.",
|
||||
"cancelError": "Die Werbung konnte nicht abgebrochen werden.",
|
||||
"cancelTooSoon": "Du kannst die Werbung erst nach 24 Stunden abbrechen.",
|
||||
|
||||
@@ -451,6 +451,7 @@
|
||||
"spouse": {
|
||||
"wooing": {
|
||||
"cancel": "Cancel wooing",
|
||||
"cancelConfirm": "Do you really want to cancel wooing? Progress will be lost.",
|
||||
"cancelSuccess": "Wooing has been cancelled.",
|
||||
"cancelError": "Wooing could not be cancelled.",
|
||||
"cancelTooSoon": "You can only cancel wooing after 24 hours."
|
||||
|
||||
@@ -481,6 +481,7 @@
|
||||
"gifts": "Regalos de cortejo",
|
||||
"sendGift": "Enviar regalo",
|
||||
"cancel": "Cancelar el cortejo",
|
||||
"cancelConfirm": "¿Seguro que quieres cancelar el cortejo? Se perderá el progreso.",
|
||||
"cancelSuccess": "El cortejo se ha cancelado.",
|
||||
"cancelError": "No se pudo cancelar el cortejo.",
|
||||
"cancelTooSoon": "Solo puedes cancelar el cortejo después de 24 horas.",
|
||||
|
||||
@@ -7,6 +7,7 @@ import i18n from './i18n';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import * as components from 'vuetify/components';
|
||||
import * as directives from 'vuetify/directives';
|
||||
import feedbackPlugin from './utils/feedback';
|
||||
|
||||
function getBrowserLanguage() {
|
||||
// Prüfe zuerst die bevorzugte Sprache
|
||||
@@ -56,5 +57,6 @@ app.use(store);
|
||||
app.use(router);
|
||||
app.use(i18n);
|
||||
app.use(vuetify);
|
||||
app.use(feedbackPlugin);
|
||||
|
||||
app.mount('#app');
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import AdminInterestsView from '../views/admin/InterestsView.vue';
|
||||
import AdminContactsView from '../views/admin/ContactsView.vue';
|
||||
import RoomsView from '../views/admin/RoomsView.vue';
|
||||
import UserRightsView from '../views/admin/UserRightsView.vue';
|
||||
import ForumAdminView from '../dialogues/admin/ForumAdminView.vue';
|
||||
import AdminFalukantEditUserView from '../views/admin/falukant/EditUserView.vue';
|
||||
import AdminFalukantMapRegionsView from '../views/admin/falukant/MapRegionsView.vue';
|
||||
import AdminFalukantCreateNPCView from '../views/admin/falukant/CreateNPCView.vue';
|
||||
import AdminMinigamesView from '../views/admin/MinigamesView.vue';
|
||||
import AdminTaxiToolsView from '../views/admin/TaxiToolsView.vue';
|
||||
import AdminUsersView from '../views/admin/UsersView.vue';
|
||||
import UserStatisticsView from '../views/admin/UserStatisticsView.vue';
|
||||
import ServicesStatusView from '../views/admin/ServicesStatusView.vue';
|
||||
const AdminInterestsView = () => import('../views/admin/InterestsView.vue');
|
||||
const AdminContactsView = () => import('../views/admin/ContactsView.vue');
|
||||
const RoomsView = () => import('../views/admin/RoomsView.vue');
|
||||
const UserRightsView = () => import('../views/admin/UserRightsView.vue');
|
||||
const ForumAdminView = () => import('../dialogues/admin/ForumAdminView.vue');
|
||||
const AdminFalukantEditUserView = () => import('../views/admin/falukant/EditUserView.vue');
|
||||
const AdminFalukantMapRegionsView = () => import('../views/admin/falukant/MapRegionsView.vue');
|
||||
const AdminFalukantCreateNPCView = () => import('../views/admin/falukant/CreateNPCView.vue');
|
||||
const AdminMinigamesView = () => import('../views/admin/MinigamesView.vue');
|
||||
const AdminTaxiToolsView = () => import('../views/admin/TaxiToolsView.vue');
|
||||
const AdminUsersView = () => import('../views/admin/UsersView.vue');
|
||||
const UserStatisticsView = () => import('../views/admin/UserStatisticsView.vue');
|
||||
const ServicesStatusView = () => import('../views/admin/ServicesStatusView.vue');
|
||||
|
||||
const adminRoutes = [
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import ActivateView from '../views/auth/ActivateView.vue';
|
||||
const ActivateView = () => import('../views/auth/ActivateView.vue');
|
||||
|
||||
const authRoutes = [
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import BlogListView from '@/views/blog/BlogListView.vue';
|
||||
import BlogView from '@/views/blog/BlogView.vue';
|
||||
import BlogEditorView from '@/views/blog/BlogEditorView.vue';
|
||||
const BlogListView = () => import('@/views/blog/BlogListView.vue');
|
||||
const BlogView = () => import('@/views/blog/BlogView.vue');
|
||||
const BlogEditorView = () => import('@/views/blog/BlogEditorView.vue');
|
||||
import { buildAbsoluteUrl } from '@/utils/seo.js';
|
||||
|
||||
export default [
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import BranchView from '../views/falukant/BranchView.vue';
|
||||
import Createview from '../views/falukant/CreateView.vue';
|
||||
import FalukantOverviewView from '../views/falukant/OverviewView.vue';
|
||||
import MoneyHistoryView from '../views/falukant/MoneyHistoryView.vue';
|
||||
import FamilyView from '../views/falukant/FamilyView.vue';
|
||||
import HouseView from '../views/falukant/HouseView.vue';
|
||||
import NobilityView from '../views/falukant/NobilityView.vue';
|
||||
import ReputationView from '../views/falukant/ReputationView.vue';
|
||||
import ChurchView from '../views/falukant/ChurchView.vue';
|
||||
import EducationView from '../views/falukant/EducationView.vue';
|
||||
import BankView from '../views/falukant/BankView.vue';
|
||||
import DirectorView from '../views/falukant/DirectorView.vue';
|
||||
import HealthView from '../views/falukant/HealthView.vue';
|
||||
import PoliticsView from '../views/falukant/PoliticsView.vue';
|
||||
import UndergroundView from '../views/falukant/UndergroundView.vue';
|
||||
const BranchView = () => import('../views/falukant/BranchView.vue');
|
||||
const Createview = () => import('../views/falukant/CreateView.vue');
|
||||
const FalukantOverviewView = () => import('../views/falukant/OverviewView.vue');
|
||||
const MoneyHistoryView = () => import('../views/falukant/MoneyHistoryView.vue');
|
||||
const FamilyView = () => import('../views/falukant/FamilyView.vue');
|
||||
const HouseView = () => import('../views/falukant/HouseView.vue');
|
||||
const NobilityView = () => import('../views/falukant/NobilityView.vue');
|
||||
const ReputationView = () => import('../views/falukant/ReputationView.vue');
|
||||
const ChurchView = () => import('../views/falukant/ChurchView.vue');
|
||||
const EducationView = () => import('../views/falukant/EducationView.vue');
|
||||
const BankView = () => import('../views/falukant/BankView.vue');
|
||||
const DirectorView = () => import('../views/falukant/DirectorView.vue');
|
||||
const HealthView = () => import('../views/falukant/HealthView.vue');
|
||||
const PoliticsView = () => import('../views/falukant/PoliticsView.vue');
|
||||
const UndergroundView = () => import('../views/falukant/UndergroundView.vue');
|
||||
|
||||
const falukantRoutes = [
|
||||
{
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import store from '../store';
|
||||
import HomeView from '../views/HomeView.vue';
|
||||
import authRoutes from './authRoutes';
|
||||
import socialRoutes from './socialRoutes';
|
||||
import settingsRoutes from './settingsRoutes';
|
||||
@@ -12,6 +11,8 @@ import personalRoutes from './personalRoutes';
|
||||
import marketingRoutes from './marketingRoutes';
|
||||
import { applyRouteSeo, buildAbsoluteUrl } from '../utils/seo';
|
||||
|
||||
const HomeView = () => import('../views/HomeView.vue');
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import PeronalSettingsView from '../views/settings/PersonalView.vue';
|
||||
import ViewSettingsView from '../views/settings/ViewView.vue';
|
||||
import FlirtSettingsView from '../views/settings/FlirtView.vue';
|
||||
import SexualitySettingsView from '../views/settings/SexualityView.vue';
|
||||
import AccountSettingsView from '../views/settings/AccountView.vue';
|
||||
import InterestsView from '../views/settings/InterestsView.vue';
|
||||
const PeronalSettingsView = () => import('../views/settings/PersonalView.vue');
|
||||
const ViewSettingsView = () => import('../views/settings/ViewView.vue');
|
||||
const FlirtSettingsView = () => import('../views/settings/FlirtView.vue');
|
||||
const SexualitySettingsView = () => import('../views/settings/SexualityView.vue');
|
||||
const AccountSettingsView = () => import('../views/settings/AccountView.vue');
|
||||
const InterestsView = () => import('../views/settings/InterestsView.vue');
|
||||
|
||||
const settingsRoutes = [
|
||||
{
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import FriendsView from '../views/social/FriendsView.vue';
|
||||
import SearchView from '../views/social/SearchView.vue';
|
||||
import GalleryView from '../views/social/GalleryView.vue';
|
||||
import GuestbookView from '../views/social/GuestbookView.vue';
|
||||
import DiaryView from '../views/social/DiaryView.vue';
|
||||
import ForumView from '../views/social/ForumView.vue';
|
||||
import ForumTopicView from '../views/social/ForumTopicView.vue';
|
||||
import VocabTrainerView from '../views/social/VocabTrainerView.vue';
|
||||
import VocabNewLanguageView from '../views/social/VocabNewLanguageView.vue';
|
||||
import VocabLanguageView from '../views/social/VocabLanguageView.vue';
|
||||
import VocabSubscribeView from '../views/social/VocabSubscribeView.vue';
|
||||
import VocabChapterView from '../views/social/VocabChapterView.vue';
|
||||
import VocabCourseListView from '../views/social/VocabCourseListView.vue';
|
||||
import VocabCourseView from '../views/social/VocabCourseView.vue';
|
||||
import VocabLessonView from '../views/social/VocabLessonView.vue';
|
||||
const FriendsView = () => import('../views/social/FriendsView.vue');
|
||||
const SearchView = () => import('../views/social/SearchView.vue');
|
||||
const GalleryView = () => import('../views/social/GalleryView.vue');
|
||||
const GuestbookView = () => import('../views/social/GuestbookView.vue');
|
||||
const DiaryView = () => import('../views/social/DiaryView.vue');
|
||||
const ForumView = () => import('../views/social/ForumView.vue');
|
||||
const ForumTopicView = () => import('../views/social/ForumTopicView.vue');
|
||||
const VocabTrainerView = () => import('../views/social/VocabTrainerView.vue');
|
||||
const VocabNewLanguageView = () => import('../views/social/VocabNewLanguageView.vue');
|
||||
const VocabLanguageView = () => import('../views/social/VocabLanguageView.vue');
|
||||
const VocabSubscribeView = () => import('../views/social/VocabSubscribeView.vue');
|
||||
const VocabChapterView = () => import('../views/social/VocabChapterView.vue');
|
||||
const VocabCourseListView = () => import('../views/social/VocabCourseListView.vue');
|
||||
const VocabCourseView = () => import('../views/social/VocabCourseView.vue');
|
||||
const VocabLessonView = () => import('../views/social/VocabLessonView.vue');
|
||||
|
||||
const socialRoutes = [
|
||||
{
|
||||
|
||||
67
frontend/src/utils/feedback.js
Normal file
67
frontend/src/utils/feedback.js
Normal file
@@ -0,0 +1,67 @@
|
||||
function getVm(context) {
|
||||
if (!context) return null;
|
||||
return context.proxy || context;
|
||||
}
|
||||
|
||||
function getRootRefs(context) {
|
||||
const vm = getVm(context);
|
||||
return vm?.$root?.$refs || {};
|
||||
}
|
||||
|
||||
function normalizeMessage(message, fallback = 'tr:error.network') {
|
||||
if (!message) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (typeof message === 'string') {
|
||||
return message;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function showMessage(context, message, title = 'tr:message.title', parameters = {}, onClose = null) {
|
||||
const refs = getRootRefs(context);
|
||||
refs.messageDialog?.open?.(normalizeMessage(message, 'tr:message.title'), title, parameters, onClose);
|
||||
}
|
||||
|
||||
export function showSuccess(context, message, title = 'tr:message.title', parameters = {}, onClose = null) {
|
||||
showMessage(context, message, title, parameters, onClose);
|
||||
}
|
||||
|
||||
export function showInfo(context, message, title = 'tr:message.title', parameters = {}, onClose = null) {
|
||||
showMessage(context, message, title, parameters, onClose);
|
||||
}
|
||||
|
||||
export function showError(context, message, fallback = 'tr:error.network') {
|
||||
const refs = getRootRefs(context);
|
||||
refs.errorDialog?.open?.(normalizeMessage(message, fallback));
|
||||
}
|
||||
|
||||
export function showApiError(context, error, fallback = 'tr:error.network') {
|
||||
const responseError = error?.response?.data?.error;
|
||||
|
||||
if (typeof responseError === 'string') {
|
||||
const normalized = responseError.startsWith('tr:') || responseError.includes(' ')
|
||||
? responseError
|
||||
: `tr:error.${responseError}`;
|
||||
showError(context, normalized, fallback);
|
||||
return;
|
||||
}
|
||||
|
||||
showError(context, fallback, fallback);
|
||||
}
|
||||
|
||||
export default {
|
||||
install(app) {
|
||||
const getAppContext = () => app._instance?.proxy;
|
||||
|
||||
app.config.globalProperties.$feedback = {
|
||||
showMessage: (...args) => showMessage(getAppContext(), ...args),
|
||||
showSuccess: (...args) => showSuccess(getAppContext(), ...args),
|
||||
showInfo: (...args) => showInfo(getAppContext(), ...args),
|
||||
showError: (...args) => showError(getAppContext(), ...args),
|
||||
showApiError: (...args) => showApiError(getAppContext(), ...args)
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -3,9 +3,11 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineAsyncComponent } from 'vue';
|
||||
import { mapGetters } from 'vuex';
|
||||
import HomeNoLoginView from './home/NoLoginView.vue';
|
||||
import HomeLoggedInView from './home/LoggedInView.vue';
|
||||
|
||||
const HomeNoLoginView = defineAsyncComponent(() => import('./home/NoLoginView.vue'));
|
||||
const HomeLoggedInView = defineAsyncComponent(() => import('./home/LoggedInView.vue'));
|
||||
|
||||
export default {
|
||||
name: 'HomeView',
|
||||
@@ -20,4 +22,4 @@ export default {
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -10,6 +10,36 @@
|
||||
|
||||
<!-- Match3 Levels Tab -->
|
||||
<div v-if="activeTab === 'match3-levels'" class="match3-admin">
|
||||
<section class="workflow-hero surface-card">
|
||||
<div>
|
||||
<span class="workflow-hero__eyebrow">Arbeitsfluss</span>
|
||||
<h2>{{ $t('admin.match3.title') }}</h2>
|
||||
<p>Erst Level waehlen, dann Spielfeld und Ziele anpassen und erst am Ende speichern.</p>
|
||||
</div>
|
||||
<div class="workflow-hero__meta">
|
||||
<span class="workflow-pill">{{ currentModeLabel }}</span>
|
||||
<span class="workflow-pill">{{ levels.length }} Level</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="workflow-grid">
|
||||
<article class="workflow-card surface-card">
|
||||
<span class="workflow-card__step">1</span>
|
||||
<h3>Level waehlen</h3>
|
||||
<p>Bestehendes Level oeffnen oder sofort mit einer neuen Vorlage starten.</p>
|
||||
</article>
|
||||
<article class="workflow-card surface-card">
|
||||
<span class="workflow-card__step">2</span>
|
||||
<h3>Spielfeld bauen</h3>
|
||||
<p>Groesse, Zuege, Kacheln und Layout zuerst festziehen, bevor Ziele folgen.</p>
|
||||
</article>
|
||||
<article class="workflow-card surface-card">
|
||||
<span class="workflow-card__step">3</span>
|
||||
<h3>Ziele speichern</h3>
|
||||
<p>Objectives nur dann scharf stellen, wenn Grunddaten und Board bereits stimmen.</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div class="section-header">
|
||||
<h2>{{ $t('admin.match3.title') }}</h2>
|
||||
</div>
|
||||
@@ -31,13 +61,38 @@
|
||||
{{ $t('admin.match3.levelFormat', { number: level.order, name: level.name }) }}
|
||||
</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-secondary level-select-action" @click="createLevel">
|
||||
{{ $t('admin.match3.newLevel') }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="level-selection__hint">
|
||||
{{ isCreatingLevel ? 'Du erstellst gerade ein neues Level.' : 'Du bearbeitest ein bestehendes Level mit allen verbundenen Objectives.' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section class="admin-summary-grid">
|
||||
<article class="admin-summary-card surface-card">
|
||||
<span class="admin-summary-card__label">Modus</span>
|
||||
<strong>{{ currentModeLabel }}</strong>
|
||||
<p>{{ selectedLevel ? selectedLevel.name : 'Neue Vorlage mit leerem Spielfeld' }}</p>
|
||||
</article>
|
||||
<article class="admin-summary-card surface-card">
|
||||
<span class="admin-summary-card__label">Spielfeld</span>
|
||||
<strong>{{ levelForm.boardWidth }} x {{ levelForm.boardHeight }}</strong>
|
||||
<p>{{ levelForm.moveLimit }} Zuege, {{ levelForm.tileTypes.length }} aktive Tile-Typen.</p>
|
||||
</article>
|
||||
<article class="admin-summary-card surface-card">
|
||||
<span class="admin-summary-card__label">Objectives</span>
|
||||
<strong>{{ objectiveCount }}</strong>
|
||||
<p>{{ objectiveCount ? 'Ziele vorhanden und bearbeitbar.' : 'Noch keine Zieldefinition hinterlegt.' }}</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<!-- Level Details -->
|
||||
<div v-if="selectedLevelId !== 'new' && selectedLevel" class="level-details">
|
||||
<div class="details-header">
|
||||
<h3>{{ selectedLevel.name }}</h3>
|
||||
<p>Bestehendes Level anpassen, ohne den Kontext des aktuellen Spielflusses zu verlieren.</p>
|
||||
</div>
|
||||
<div class="details-content">
|
||||
<div class="form-group">
|
||||
@@ -185,7 +240,7 @@
|
||||
<button type="button" class="btn btn-danger" @click="deleteSelectedLevel">
|
||||
{{ $t('admin.match3.delete') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" @click="saveLevel">
|
||||
<button type="button" class="btn btn-primary" :disabled="!isLevelFormValid" @click="saveLevel">
|
||||
{{ $t('admin.match3.update') }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -539,7 +594,7 @@
|
||||
<button type="button" class="btn btn-secondary" @click="cancelEdit">
|
||||
{{ $t('admin.match3.cancel') }}
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<button type="submit" class="btn btn-primary" :disabled="!isLevelFormValid">
|
||||
{{ $t('admin.match3.create') }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -553,6 +608,7 @@
|
||||
<script>
|
||||
import SimpleTabs from '../../components/SimpleTabs.vue';
|
||||
import apiClient from '../../utils/axios.js';
|
||||
import { showError, showSuccess } from '@/utils/feedback.js';
|
||||
|
||||
export default {
|
||||
name: 'AdminMinigamesView',
|
||||
@@ -593,10 +649,29 @@ export default {
|
||||
gridTemplateRows: `repeat(${this.levelForm.boardHeight}, 1fr)`
|
||||
};
|
||||
},
|
||||
|
||||
isCreatingLevel() {
|
||||
return this.selectedLevelId === 'new';
|
||||
},
|
||||
selectedLevel() {
|
||||
if (this.selectedLevelId === 'new') return null;
|
||||
return this.levels.find(l => l.id === this.selectedLevelId);
|
||||
},
|
||||
objectiveCount() {
|
||||
return this.levelForm.objectives?.length || 0;
|
||||
},
|
||||
currentModeLabel() {
|
||||
return this.isCreatingLevel ? 'Neues Level' : 'Level bearbeiten';
|
||||
},
|
||||
isLevelFormValid() {
|
||||
return Boolean(
|
||||
this.levelForm.name?.trim() &&
|
||||
this.levelForm.description?.trim() &&
|
||||
this.levelForm.boardWidth >= 3 &&
|
||||
this.levelForm.boardHeight >= 3 &&
|
||||
this.levelForm.moveLimit >= 5 &&
|
||||
this.levelForm.order >= 1 &&
|
||||
this.levelForm.tileTypes?.length
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -730,20 +805,14 @@ export default {
|
||||
},
|
||||
|
||||
setTileType(index, tileType) {
|
||||
console.log('setTileType called with:', index, tileType);
|
||||
if (tileType === 'o') {
|
||||
// Leer
|
||||
this.boardMatrix[index] = { active: false, tileType: 'o', index: index };
|
||||
} else if (tileType === 'r') {
|
||||
// Zufällig
|
||||
this.boardMatrix[index] = { active: true, tileType: 'r', index: index };
|
||||
console.log('Set random tile at index:', index, this.boardMatrix[index]);
|
||||
} else {
|
||||
// Spezifischer Tile-Typ
|
||||
this.boardMatrix[index] = { active: true, tileType: tileType, index: index };
|
||||
}
|
||||
this.selectedCellIndex = null; // Auswahl aufheben
|
||||
console.log('Board matrix after update:', this.boardMatrix);
|
||||
this.selectedCellIndex = null;
|
||||
},
|
||||
|
||||
// Mapping für Tile-Typen zu Zeichen
|
||||
@@ -785,7 +854,6 @@ export default {
|
||||
objectives: []
|
||||
};
|
||||
this.updateBoardMatrix();
|
||||
console.log('Bearbeitung abgebrochen, Objectives zurückgesetzt:', this.levelForm.objectives);
|
||||
},
|
||||
|
||||
updateBoardMatrix() {
|
||||
@@ -905,6 +973,7 @@ export default {
|
||||
...this.levelForm,
|
||||
boardLayout: this.generateBoardLayout()
|
||||
};
|
||||
const wasCreating = this.selectedLevelId === 'new';
|
||||
|
||||
let savedLevel;
|
||||
if (this.selectedLevelId !== 'new') {
|
||||
@@ -939,9 +1008,10 @@ export default {
|
||||
this.selectedLevelId = 'new';
|
||||
this.selectedCellIndex = null;
|
||||
this.loadLevels();
|
||||
showSuccess(this, wasCreating ? 'Level wurde erstellt.' : 'Level wurde aktualisiert.');
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Speichern des Levels:', error);
|
||||
alert('Fehler beim Speichern des Levels');
|
||||
showError(this, 'Fehler beim Speichern des Levels');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -950,8 +1020,10 @@ export default {
|
||||
try {
|
||||
await apiClient.delete(`/api/admin/minigames/match3/levels/${levelId}`);
|
||||
this.loadLevels();
|
||||
showSuccess(this, 'Level wurde geloescht.');
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Löschen des Levels:', error);
|
||||
showError(this, 'Fehler beim Loeschen des Levels');
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1025,6 +1097,94 @@ export default {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.workflow-hero {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding: 22px 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.workflow-hero h2 {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.workflow-hero p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.workflow-hero__eyebrow,
|
||||
.admin-summary-card__label {
|
||||
display: inline-flex;
|
||||
margin-bottom: 4px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.workflow-hero__meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.workflow-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 34px;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(248, 162, 43, 0.12);
|
||||
color: #8a5411;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.workflow-grid,
|
||||
.admin-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.workflow-card,
|
||||
.admin-summary-card {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.workflow-card__step {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(248, 162, 43, 0.12);
|
||||
color: #8a5411;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.workflow-card h3,
|
||||
.admin-summary-card strong {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.admin-summary-card strong {
|
||||
display: block;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.workflow-card p,
|
||||
.admin-summary-card p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
margin-bottom: 30px;
|
||||
text-align: center;
|
||||
@@ -1055,6 +1215,8 @@ export default {
|
||||
.level-dropdown {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.level-select {
|
||||
@@ -1072,6 +1234,15 @@ export default {
|
||||
border-color: #F9A22C;
|
||||
}
|
||||
|
||||
.level-select-action {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.level-selection__hint {
|
||||
margin: 12px 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Level Details & Form */
|
||||
.level-details,
|
||||
.level-form {
|
||||
@@ -1096,6 +1267,11 @@ export default {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.details-header p {
|
||||
margin: 8px 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
@@ -1479,6 +1655,16 @@ export default {
|
||||
.match3-admin {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.workflow-hero {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.workflow-grid,
|
||||
.admin-summary-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -1,23 +1,35 @@
|
||||
<template>
|
||||
<div class="admin-users">
|
||||
<h1>{{ $t('navigation.m-administration.useradministration') }}</h1>
|
||||
<section class="admin-users__hero surface-card">
|
||||
<span class="admin-users__eyebrow">Administration</span>
|
||||
<h1>{{ $t('navigation.m-administration.useradministration') }}</h1>
|
||||
<p>Benutzer suchen, Kerndaten anpassen und Sperrstatus direkt im System pflegen.</p>
|
||||
</section>
|
||||
|
||||
<AdminUserSearch @select="select" />
|
||||
<section class="admin-users__search surface-card">
|
||||
<AdminUserSearch @select="select" />
|
||||
</section>
|
||||
|
||||
<div v-if="selected" class="edit">
|
||||
<h2>{{ selected.username }}</h2>
|
||||
<label>
|
||||
{{ $t('admin.user.name') }}
|
||||
<section v-if="selected" class="edit surface-card">
|
||||
<div class="edit__header">
|
||||
<h2>{{ selected.username }}</h2>
|
||||
<span class="edit__badge">{{ form.active ? 'Aktiv' : 'Gesperrt' }}</span>
|
||||
</div>
|
||||
|
||||
<label class="edit__field">
|
||||
<span>{{ $t('admin.user.name') }}</span>
|
||||
<input v-model="form.username" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
{{ $t('admin.user.blocked') }}
|
||||
|
||||
<label class="edit__toggle">
|
||||
<input type="checkbox" :checked="!form.active" @change="toggleBlocked($event)" />
|
||||
<span>{{ $t('admin.user.blocked') }}</span>
|
||||
</label>
|
||||
|
||||
<div class="actions">
|
||||
<button @click="save">{{ $t('common.save') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -57,12 +69,105 @@ export default {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-users { padding: 20px; }
|
||||
.results table { width: 100%; border-collapse: collapse; }
|
||||
.results th, .results td { border: 1px solid #ddd; padding: 8px; }
|
||||
.edit { margin-top: 16px; display: grid; gap: 10px; max-width: 480px; }
|
||||
.actions { display: flex; gap: 8px; }
|
||||
button { cursor: pointer; }
|
||||
.admin-users {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.admin-users__hero,
|
||||
.admin-users__search,
|
||||
.edit {
|
||||
background: linear-gradient(180deg, rgba(255, 252, 247, 0.97), rgba(250, 244, 235, 0.95));
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.admin-users__hero,
|
||||
.admin-users__search,
|
||||
.edit {
|
||||
padding: 22px 24px;
|
||||
}
|
||||
|
||||
.admin-users__eyebrow {
|
||||
display: inline-flex;
|
||||
margin-bottom: 8px;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-secondary-soft);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.admin-users__hero p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.edit {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.edit__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.edit__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-primary-soft);
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.edit__field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.edit__field span {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.edit__toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.admin-users__hero,
|
||||
.admin-users__search,
|
||||
.edit {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.edit__header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
<!-- Benutzer-Suche -->
|
||||
<div class="search-section">
|
||||
<label>{{ $t('admin.falukant.edituser.username') }}: <input type="text" v-model="user.username" @keyup.enter="searchUser" /></label>
|
||||
<label>{{ $t('admin.falukant.edituser.characterName') }}: <input type="text" v-model="user.characterName" @keyup.enter="searchUser" /></label>
|
||||
<button @click="searchUser">{{ $t('admin.falukant.edituser.search') }}</button>
|
||||
<label class="form-field">{{ $t('admin.falukant.edituser.username') }} <input type="text" v-model="user.username" @keyup.enter="searchUser" /></label>
|
||||
<label class="form-field">{{ $t('admin.falukant.edituser.characterName') }} <input type="text" v-model="user.characterName" @keyup.enter="searchUser" /></label>
|
||||
<button @click="searchUser" :disabled="!canSearch">{{ $t('admin.falukant.edituser.search') }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Benutzer-Liste -->
|
||||
@@ -40,8 +40,8 @@
|
||||
</select>
|
||||
</label>
|
||||
<div class="action-buttons">
|
||||
<button @click="saveUser">{{ $t('common.save') }}</button>
|
||||
<button @click="deleteUser">{{ $t('common.delete') }}</button>
|
||||
<button @click="saveUser" :disabled="!hasUserChanges">{{ $t('common.save') }}</button>
|
||||
<button @click="deleteUser" class="button-secondary">{{ $t('common.delete') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -122,6 +122,7 @@ import { mapState } from 'vuex';
|
||||
import { mapActions } from 'vuex';
|
||||
import SimpleTabs from '@/components/SimpleTabs.vue';
|
||||
import apiClient from '@/utils/axios.js';
|
||||
import { showApiError, showError, showSuccess } from '@/utils/feedback.js';
|
||||
|
||||
export default {
|
||||
name: 'AdminFalukantEditUserView',
|
||||
@@ -162,6 +163,15 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
...mapState('falukant', ['user']),
|
||||
canSearch() {
|
||||
return this.user.username.trim().length > 0 || this.user.characterName.trim().length > 0;
|
||||
},
|
||||
hasUserChanges() {
|
||||
if (!this.editableUser || !this.originalUser) return false;
|
||||
return this.editableUser.falukantData[0].money != this.originalUser.falukantData[0].money
|
||||
|| this.editableUser.falukantData[0].character.title_of_nobility != this.originalUser.falukantData[0].character.title_of_nobility
|
||||
|| this.originalAge != this.age;
|
||||
},
|
||||
availableStockTypes() {
|
||||
if (!this.newStock.branchId || !this.stockTypes.length) {
|
||||
return this.stockTypes;
|
||||
@@ -191,6 +201,10 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
async searchUser() {
|
||||
if (!this.canSearch) {
|
||||
showError(this, 'Bitte Benutzername oder Charaktername eingeben.');
|
||||
return;
|
||||
}
|
||||
const userResult = await apiClient.post('/api/admin/falukant/searchuser', {
|
||||
userName: this.user.username,
|
||||
characterName: this.user.characterName
|
||||
@@ -221,9 +235,9 @@ export default {
|
||||
}
|
||||
try {
|
||||
await apiClient.post(`/api/admin/falukant/edituser`, dataToChange);
|
||||
this.$root.$refs.messageDialog.open('tr:admin.falukant.edituser.success');
|
||||
showSuccess(this, 'tr:admin.falukant.edituser.success');
|
||||
} catch (error) {
|
||||
this.$root.$refs.errorDialog.open('tr:admin.falukant.edituser.error');
|
||||
showApiError(this, error, 'tr:admin.falukant.edituser.error');
|
||||
}
|
||||
},
|
||||
async deleteUser() {
|
||||
@@ -245,7 +259,7 @@ export default {
|
||||
this.userBranches = branchesResult.data;
|
||||
} catch (error) {
|
||||
console.error('Error loading user branches:', error);
|
||||
this.$root.$refs.errorDialog.open('tr:admin.falukant.edituser.errorLoadingBranches');
|
||||
showApiError(this, error, 'tr:admin.falukant.edituser.errorLoadingBranches');
|
||||
} finally {
|
||||
this.loading.branches = false;
|
||||
}
|
||||
@@ -255,7 +269,7 @@ export default {
|
||||
await apiClient.put(`/api/admin/falukant/stock/${stock.id}`, {
|
||||
quantity: stock.quantity
|
||||
});
|
||||
this.$root.$refs.messageDialog.open('tr:admin.falukant.edituser.stockUpdated');
|
||||
showSuccess(this, 'tr:admin.falukant.edituser.stockUpdated');
|
||||
} catch (error) {
|
||||
console.error('Error updating stock:', error);
|
||||
this.$root.$refs.errorDialog.open('tr:admin.falukant.edituser.errorUpdatingStock');
|
||||
@@ -675,4 +689,4 @@ export default {
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
<template>
|
||||
<div class="blog-list">
|
||||
<h1>Blogs</h1>
|
||||
<div class="toolbar">
|
||||
<router-link v-if="$store.getters.isLoggedIn" class="btn" to="/blogs/create">Neuen Blog erstellen</router-link>
|
||||
</div>
|
||||
<div v-if="loading">Laden…</div>
|
||||
<div v-else>
|
||||
<div v-if="!blogs.length">Keine Blogs gefunden.</div>
|
||||
<ul>
|
||||
<li v-for="b in blogs" :key="b.id">
|
||||
<router-link :to="blogUrl(b)">{{ b.title }}</router-link>
|
||||
<small> – {{ b.owner?.username }}</small>
|
||||
</li>
|
||||
</ul>
|
||||
<section class="blog-list__hero surface-card">
|
||||
<div>
|
||||
<span class="blog-list__kicker">Community-Blogs</span>
|
||||
<h1>Blogs</h1>
|
||||
<p>Artikel, Projektstaende und persoenliche Einblicke aus der YourPart-Community.</p>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<router-link v-if="$store.getters.isLoggedIn" class="btn" to="/blogs/create">Neuen Blog erstellen</router-link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="loading" class="blog-list__state surface-card">Laden…</div>
|
||||
<div v-else-if="!blogs.length" class="blog-list__state surface-card">Keine Blogs gefunden.</div>
|
||||
<div v-else class="blog-grid">
|
||||
<article v-for="b in blogs" :key="b.id" class="blog-card surface-card">
|
||||
<div class="blog-card__meta">von {{ b.owner?.username || 'Unbekannt' }}</div>
|
||||
<h2><router-link :to="blogUrl(b)">{{ b.title }}</router-link></h2>
|
||||
<p>{{ blogExcerpt(b) }}</p>
|
||||
<router-link class="blog-card__link" :to="blogUrl(b)">Zum Blog</router-link>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -31,6 +38,90 @@ export default {
|
||||
const slug = createBlogSlug(blog?.owner?.username, blog?.title);
|
||||
return slug ? `/blogs/${encodeURIComponent(slug)}` : `/blogs/${blog.id}`;
|
||||
},
|
||||
blogExcerpt(blog) {
|
||||
const source = blog?.description || 'Oeffentliche Eintraege, Gedanken und Projektstaende aus der Community.';
|
||||
return source.length > 150 ? `${source.slice(0, 147)}...` : source;
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.blog-list {
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.blog-list__hero {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 24px 26px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.blog-list__kicker {
|
||||
display: inline-block;
|
||||
margin-bottom: 10px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(120, 195, 138, 0.14);
|
||||
color: #42634e;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.blog-list__hero p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.blog-list__state {
|
||||
padding: 26px;
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.blog-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.blog-card {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.blog-card__meta {
|
||||
margin-bottom: 10px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.blog-card h2 {
|
||||
margin-bottom: 10px;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.blog-card p {
|
||||
margin-bottom: 16px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.blog-card__link {
|
||||
color: var(--color-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.blog-list__hero {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,34 +1,43 @@
|
||||
<template>
|
||||
<div class="blog-view">
|
||||
<div v-if="loading">Laden…</div>
|
||||
<div v-else>
|
||||
<h1>{{ blog.title }}</h1>
|
||||
<p v-if="blog.description">{{ blog.description }}</p>
|
||||
<div class="meta">von {{ blog.owner?.username }}</div>
|
||||
<div v-if="$store.getters.isLoggedIn" class="actions">
|
||||
<router-link class="editbutton" v-if="isOwner" :to="{ name: 'BlogEdit', params: { id: blog.id } }">Bearbeiten</router-link>
|
||||
</div>
|
||||
<div class="posts">
|
||||
<h2>{{ $t('blog.posts') }}</h2>
|
||||
<div v-if="!items.length">{{ $t('blog.noPosts') }}</div>
|
||||
<article v-for="p in items" :key="p.id" class="post">
|
||||
<h3>{{ p.title }}</h3>
|
||||
<div class="content" v-html="sanitize(p.content)" />
|
||||
</article>
|
||||
<div class="pagination" v-if="total > pageSize">
|
||||
<div v-if="loading" class="blog-view__state surface-card">Laden…</div>
|
||||
<div v-else-if="blog" class="blog-layout">
|
||||
<section class="blog-hero surface-card">
|
||||
<div>
|
||||
<div class="meta">von {{ blog.owner?.username }}</div>
|
||||
<h1>{{ blog.title }}</h1>
|
||||
<p v-if="blog.description" class="blog-description">{{ blog.description }}</p>
|
||||
</div>
|
||||
<div v-if="$store.getters.isLoggedIn" class="actions">
|
||||
<router-link class="editbutton" v-if="isOwner" :to="{ name: 'BlogEdit', params: { id: blog.id } }">Bearbeiten</router-link>
|
||||
</div>
|
||||
</section>
|
||||
<div class="blog-content">
|
||||
<section class="posts surface-card">
|
||||
<div class="posts__header">
|
||||
<h2>{{ $t('blog.posts') }}</h2>
|
||||
<span class="posts__count">{{ total }} Eintraege</span>
|
||||
</div>
|
||||
<div v-if="!items.length" class="blog-view__state">Keine Eintraege vorhanden.</div>
|
||||
<article v-for="p in items" :key="p.id" class="post">
|
||||
<h3>{{ p.title }}</h3>
|
||||
<div class="content" v-html="sanitize(p.content)" />
|
||||
</article>
|
||||
<div class="pagination" v-if="total > pageSize">
|
||||
<button :disabled="page===1" @click="go(page-1)">«</button>
|
||||
<span>{{ page }} / {{ pages }}</span>
|
||||
<button :disabled="page===pages" @click="go(page+1)">»</button>
|
||||
</div>
|
||||
</section>
|
||||
<div v-if="isOwner" class="post-editor surface-card">
|
||||
<h3>{{ $t('blog.newPost') }}</h3>
|
||||
<form @submit.prevent="addPost">
|
||||
<input v-model="newPost.title" :placeholder="$t('blog.title')" required />
|
||||
<RichTextEditor v-model="newPost.content" :blog-id="blog.id" />
|
||||
<button class="btn" type="submit">{{ $t('blog.publish') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isOwner" class="post-editor">
|
||||
<h3>{{ $t('blog.newPost') }}</h3>
|
||||
<form @submit.prevent="addPost">
|
||||
<input v-model="newPost.title" :placeholder="$t('blog.title')" required />
|
||||
<RichTextEditor v-model="newPost.content" :blog-id="blog.id" />
|
||||
<button class="btn" type="submit">{{ $t('blog.publish') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -169,12 +178,109 @@ export default {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.blog-view {
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.blog-layout {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.blog-hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
padding: 26px;
|
||||
}
|
||||
|
||||
.blog-description {
|
||||
margin: 0;
|
||||
max-width: 70ch;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin-bottom: 10px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.blog-content {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(280px, 0.95fr);
|
||||
gap: 18px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.posts,
|
||||
.post-editor {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.posts__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.posts__count {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.post + .post {
|
||||
margin-top: 18px;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.content {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.blog-view__state {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.editbutton {
|
||||
border: 1px solid #000;
|
||||
background-color: #f9a22c;
|
||||
margin-bottom: 1em;
|
||||
border-radius: 3px;
|
||||
padding: 0.2em 0.5em;
|
||||
margin-bottom: 0;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.blog-hero,
|
||||
.blog-content {
|
||||
grid-template-columns: 1fr;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.blog-hero {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.posts,
|
||||
.post-editor {
|
||||
padding: 18px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,14 @@
|
||||
<div class="contenthidden">
|
||||
<StatusBar ref="statusBar" />
|
||||
<div class="contentscroll">
|
||||
<h2>{{ $t('falukant.branch.title') }}</h2>
|
||||
<div class="falukant-branch">
|
||||
<section class="branch-hero surface-card">
|
||||
<div>
|
||||
<span class="branch-kicker">Niederlassung</span>
|
||||
<h2>{{ $t('falukant.branch.title') }}</h2>
|
||||
<p>Produktion, Lager, Verkauf und Transport in einer spielweltbezogenen Steuerflaeche.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<BranchSelection
|
||||
:branches="branches"
|
||||
@@ -308,6 +315,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1110,8 +1118,49 @@ export default {
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
h2 {
|
||||
padding-top: 20px;
|
||||
.falukant-branch {
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.branch-hero {
|
||||
padding: 24px 26px;
|
||||
margin-bottom: 16px;
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(248, 162, 43, 0.16), transparent 28%),
|
||||
linear-gradient(180deg, rgba(255, 250, 243, 0.98) 0%, rgba(247, 238, 224, 0.98) 100%);
|
||||
}
|
||||
|
||||
.branch-kicker {
|
||||
display: inline-block;
|
||||
margin-bottom: 10px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(248, 162, 43, 0.14);
|
||||
color: #8a5411;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.branch-hero p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.branch-tab-content {
|
||||
margin-top: 16px;
|
||||
padding: 18px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: rgba(255, 252, 247, 0.86);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.branch-tab-pane {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.send-all-vehicles {
|
||||
@@ -1161,11 +1210,12 @@ h2 {
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
background: rgba(255,255,255,0.98);
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-lg);
|
||||
min-width: 400px;
|
||||
max-width: 600px;
|
||||
box-shadow: var(--shadow-medium);
|
||||
}
|
||||
|
||||
.send-vehicle-form {
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
<StatusBar />
|
||||
<div class="contentscroll family-layout">
|
||||
<div class="family-content">
|
||||
<h2>{{ $t('falukant.family.title') }}</h2>
|
||||
<section class="family-hero surface-card">
|
||||
<div>
|
||||
<span class="family-kicker">Familie</span>
|
||||
<h2>{{ $t('falukant.family.title') }}</h2>
|
||||
<p>Beziehungen, Kinder und familiäre Entwicklung in einer eigenen Spielweltansicht.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="spouse-section">
|
||||
<h3>{{ $t('falukant.family.spouse.title') }}</h3>
|
||||
@@ -36,7 +42,7 @@
|
||||
<td>
|
||||
<div class="progress">
|
||||
<div class="progress-inner" :style="{
|
||||
width: relationships[0].progress + '%',
|
||||
width: normalizeWooingProgress(relationships[0].progress) + '%',
|
||||
backgroundColor: progressColor(relationships[0].progress)
|
||||
}"></div>
|
||||
</div>
|
||||
@@ -200,6 +206,8 @@ import Character3D from '@/components/Character3D.vue'
|
||||
import apiClient from '@/utils/axios.js'
|
||||
import { mapState } from 'vuex'
|
||||
|
||||
const WOOING_PROGRESS_TARGET = 70
|
||||
|
||||
export default {
|
||||
name: 'FamilyView',
|
||||
components: {
|
||||
@@ -342,6 +350,8 @@ export default {
|
||||
},
|
||||
|
||||
async cancelWooing() {
|
||||
const confirmed = window.confirm(this.$t('falukant.family.spouse.wooing.cancelConfirm'));
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
await apiClient.post('/api/falukant/family/cancel-wooing');
|
||||
await this.loadFamilyData();
|
||||
@@ -409,11 +419,16 @@ export default {
|
||||
},
|
||||
|
||||
progressColor(p) {
|
||||
const pct = Math.max(0, Math.min(100, p)) / 100;
|
||||
const pct = this.normalizeWooingProgress(p) / 100;
|
||||
const red = Math.round(255 * (1 - pct));
|
||||
const green = Math.round(255 * pct);
|
||||
return `rgb(${red}, ${green}, 0)`;
|
||||
},
|
||||
normalizeWooingProgress(p) {
|
||||
const raw = Number(p) || 0
|
||||
const normalized = (raw / WOOING_PROGRESS_TARGET) * 100
|
||||
return Math.max(0, Math.min(100, normalized))
|
||||
},
|
||||
|
||||
jumpToPartyForm() {
|
||||
this.$router.push({
|
||||
@@ -469,7 +484,33 @@ export default {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: flex-start;
|
||||
padding-top: 24px;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.family-hero {
|
||||
padding: 24px 26px;
|
||||
margin-bottom: 16px;
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(248, 162, 43, 0.16), transparent 28%),
|
||||
linear-gradient(180deg, rgba(255, 250, 243, 0.98) 0%, rgba(247, 238, 224, 0.98) 100%);
|
||||
}
|
||||
|
||||
.family-kicker {
|
||||
display: inline-block;
|
||||
margin-bottom: 10px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(248, 162, 43, 0.14);
|
||||
color: #8a5411;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.family-hero p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.self-character-3d {
|
||||
@@ -483,15 +524,20 @@ export default {
|
||||
|
||||
.family-content {
|
||||
flex: 1;
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.spouse-section,
|
||||
.children-section,
|
||||
.lovers-section {
|
||||
border: 1px solid #ccc;
|
||||
margin: 10px 0;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
margin: 12px 0;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 16px;
|
||||
background: rgba(255, 252, 247, 0.86);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.relationship-container {
|
||||
@@ -513,8 +559,8 @@ export default {
|
||||
.partner-character-3d {
|
||||
width: 200px;
|
||||
height: 280px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background-color: #fdf1db;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -537,8 +583,8 @@ export default {
|
||||
.child-character-3d {
|
||||
width: 200px;
|
||||
height: 280px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background-color: #fdf1db;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -597,10 +643,6 @@ export default {
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.relationship>table,
|
||||
.relationship>ul {
|
||||
display: inline-block;
|
||||
@@ -648,4 +690,11 @@ h2 {
|
||||
.set-heir-button:hover {
|
||||
background-color: #218838;
|
||||
}
|
||||
</style>
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.relationship-row,
|
||||
.children-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,46 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="falukant-overview">
|
||||
<StatusBar />
|
||||
<h2>{{ $t('falukant.overview.title') }}</h2>
|
||||
<section class="falukant-hero surface-card">
|
||||
<div>
|
||||
<span class="falukant-kicker">Falukant</span>
|
||||
<h2>{{ $t('falukant.overview.title') }}</h2>
|
||||
<p>Dein Stand in Wirtschaft, Familie und Besitz in einer verdichteten Uebersicht.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="falukantUser?.character" class="falukant-summary-grid">
|
||||
<article class="summary-card surface-card">
|
||||
<span class="summary-card__label">Niederlassungen</span>
|
||||
<strong>{{ branchCount }}</strong>
|
||||
<p>Direkter Zugriff auf deine wichtigsten Geschaeftsstandorte.</p>
|
||||
</article>
|
||||
<article class="summary-card surface-card">
|
||||
<span class="summary-card__label">Produktionen aktiv</span>
|
||||
<strong>{{ productionCount }}</strong>
|
||||
<p>Laufende Produktionen, die zeitnah Abschluss oder Kontrolle brauchen.</p>
|
||||
</article>
|
||||
<article class="summary-card surface-card">
|
||||
<span class="summary-card__label">Lagerpositionen</span>
|
||||
<strong>{{ stockEntryCount }}</strong>
|
||||
<p>Verdichteter Blick auf Warenbestand ueber alle Regionen.</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section v-if="falukantUser?.character" class="falukant-routine-grid">
|
||||
<article
|
||||
v-for="action in routineActions"
|
||||
:key="action.title"
|
||||
class="routine-card surface-card"
|
||||
>
|
||||
<span class="routine-card__eyebrow">{{ action.kicker }}</span>
|
||||
<h3>{{ action.title }}</h3>
|
||||
<p>{{ action.description }}</p>
|
||||
<button type="button" :class="action.secondary ? 'button-secondary' : ''" @click="openRoute(action.route)">
|
||||
{{ action.cta }}
|
||||
</button>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<!-- Erben-Auswahl wenn kein Charakter vorhanden -->
|
||||
<div v-if="!falukantUser?.character" class="heir-selection-container">
|
||||
@@ -136,6 +175,7 @@
|
||||
import StatusBar from '@/components/falukant/StatusBar.vue';
|
||||
import Character3D from '@/components/Character3D.vue';
|
||||
import apiClient from '@/utils/axios.js';
|
||||
import { showError, showSuccess } from '@/utils/feedback.js';
|
||||
import { mapState } from 'vuex';
|
||||
|
||||
const AVATAR_POSITIONS = {
|
||||
@@ -233,6 +273,50 @@ export default {
|
||||
const m = this.falukantUser?.money;
|
||||
return typeof m === 'string' ? parseFloat(m) : m;
|
||||
},
|
||||
branchCount() {
|
||||
return this.falukantUser?.branches?.length || 0;
|
||||
},
|
||||
productionCount() {
|
||||
return this.productions.length;
|
||||
},
|
||||
stockEntryCount() {
|
||||
return this.allStock.length;
|
||||
},
|
||||
routineActions() {
|
||||
return [
|
||||
{
|
||||
kicker: 'Routine',
|
||||
title: 'Niederlassung oeffnen',
|
||||
description: 'Die schnellste Route zu Produktion, Lager, Verkauf und Transport.',
|
||||
cta: 'Zu den Betrieben',
|
||||
route: 'BranchView',
|
||||
},
|
||||
{
|
||||
kicker: 'Ueberblick',
|
||||
title: 'Finanzen pruefen',
|
||||
description: 'Kontostand, Verlauf und wirtschaftliche Entwicklung ohne lange Suche.',
|
||||
cta: 'Geldhistorie',
|
||||
route: 'MoneyHistoryView',
|
||||
secondary: true,
|
||||
},
|
||||
{
|
||||
kicker: 'Charakter',
|
||||
title: 'Familie und Nachfolge',
|
||||
description: 'Wichtige persoenliche Entscheidungen und Haushaltsstatus gesammelt.',
|
||||
cta: 'Familie oeffnen',
|
||||
route: 'FalukantFamily',
|
||||
secondary: true,
|
||||
},
|
||||
{
|
||||
kicker: 'Besitz',
|
||||
title: 'Haus und Umfeld',
|
||||
description: 'Wohnsitz und alltaeglicher Status als eigener Arbeitsbereich.',
|
||||
cta: 'Zum Haus',
|
||||
route: 'HouseView',
|
||||
secondary: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
locale() {
|
||||
return window.navigator.language || 'en-US';
|
||||
},
|
||||
@@ -369,6 +453,16 @@ export default {
|
||||
openBranch(branchId) {
|
||||
this.$router.push({ name: 'BranchView', params: { branchId } });
|
||||
},
|
||||
openRoute(routeName) {
|
||||
if (routeName === 'BranchView') {
|
||||
const firstBranch = this.falukantUser?.branches?.[0];
|
||||
if (firstBranch?.id) {
|
||||
this.openBranch(firstBranch.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.$router.push({ name: routeName });
|
||||
},
|
||||
async fetchProductions() {
|
||||
try {
|
||||
const response = await apiClient.get('/api/falukant/productions');
|
||||
@@ -399,15 +493,15 @@ export default {
|
||||
async selectHeir(heirId) {
|
||||
try {
|
||||
await apiClient.post('/api/falukant/heirs/select', { heirId });
|
||||
// Lade User-Daten neu
|
||||
await this.fetchFalukantUser();
|
||||
if (this.falukantUser?.character) {
|
||||
await this.fetchAllStock();
|
||||
await this.fetchProductions();
|
||||
}
|
||||
showSuccess(this, 'Erbe wurde uebernommen.');
|
||||
} catch (error) {
|
||||
console.error('Error selecting heir:', error);
|
||||
alert(this.$t('falukant.overview.heirSelection.error'));
|
||||
showError(this, this.$t('falukant.overview.heirSelection.error'));
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -415,16 +509,99 @@ export default {
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.falukant-overview {
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.falukant-hero {
|
||||
padding: 24px 26px;
|
||||
margin-bottom: 16px;
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(248, 162, 43, 0.16), transparent 28%),
|
||||
linear-gradient(180deg, rgba(255, 250, 243, 0.98) 0%, rgba(247, 238, 224, 0.98) 100%);
|
||||
}
|
||||
|
||||
.falukant-kicker {
|
||||
display: inline-block;
|
||||
margin-bottom: 10px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(248, 162, 43, 0.14);
|
||||
color: #8a5411;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.falukant-hero p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.falukant-summary-grid,
|
||||
.falukant-routine-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.falukant-routine-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.summary-card,
|
||||
.routine-card {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.summary-card strong {
|
||||
display: block;
|
||||
margin: 6px 0 8px;
|
||||
font-size: 1.8rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.summary-card p,
|
||||
.routine-card p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.summary-card__label,
|
||||
.routine-card__eyebrow {
|
||||
display: inline-flex;
|
||||
margin-bottom: 4px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.routine-card h3 {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.routine-card button {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.overviewcontainer {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr 1fr;
|
||||
grid-gap: 5px;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.overviewcontainer>div {
|
||||
border: 1px solid #ccc;
|
||||
padding: 5px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
padding: 16px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: rgba(255, 253, 249, 0.82);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.imagecontainer {
|
||||
@@ -438,10 +615,12 @@ export default {
|
||||
}
|
||||
|
||||
.avatar {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background-color: rgba(255,255,255,0.72);
|
||||
background-repeat: no-repeat;
|
||||
image-rendering: crisp-edges;
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.house-with-character {
|
||||
@@ -453,8 +632,8 @@ export default {
|
||||
.house {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background-repeat: no-repeat;
|
||||
image-rendering: crisp-edges;
|
||||
z-index: 1;
|
||||
@@ -470,16 +649,13 @@ export default {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
h2 {
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.heir-selection-container {
|
||||
border: 2px solid #dc3545;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(177, 59, 53, 0.18);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
background-color: #fff3cd;
|
||||
background-color: rgba(255, 243, 205, 0.92);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.heir-selection-container h3 {
|
||||
@@ -495,10 +671,10 @@ h2 {
|
||||
}
|
||||
|
||||
.heir-card {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 15px;
|
||||
background-color: white;
|
||||
background-color: rgba(255,255,255,0.86);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
@@ -518,6 +694,20 @@ h2 {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.falukant-routine-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.falukant-summary-grid,
|
||||
.falukant-routine-grid,
|
||||
.overviewcontainer {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.select-heir-button {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
@@ -535,6 +725,16 @@ h2 {
|
||||
.loading, .no-heirs {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: #666;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.overviewcontainer {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.imagecontainer {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
<template>
|
||||
<div class="home-logged-in">
|
||||
<header class="dashboard-header">
|
||||
<h1>Willkommen zurück!</h1>
|
||||
<p class="dashboard-subtitle">Schön, dass du wieder da bist.</p>
|
||||
<div class="dashboard-toolbar">
|
||||
<section class="dashboard-hero surface-card">
|
||||
<div class="dashboard-hero__copy">
|
||||
<span class="dashboard-kicker">Dein Bereich</span>
|
||||
<h1>Willkommen zurück!</h1>
|
||||
<p class="dashboard-subtitle">
|
||||
Dein persönlicher Einstieg in Community, Termine, Falukant und laufende Aktivitäten.
|
||||
</p>
|
||||
</div>
|
||||
<div class="dashboard-toolbar surface-card">
|
||||
<button
|
||||
v-if="!editMode"
|
||||
type="button"
|
||||
@@ -42,7 +47,25 @@
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</header>
|
||||
</section>
|
||||
|
||||
<section class="dashboard-overview">
|
||||
<article class="overview-card surface-card">
|
||||
<span class="overview-card__label">Aktive Widgets</span>
|
||||
<strong>{{ widgets.length }}</strong>
|
||||
<p>Dein Dashboard ist modular aufgebaut und kann jederzeit umsortiert werden.</p>
|
||||
</article>
|
||||
<article class="overview-card surface-card">
|
||||
<span class="overview-card__label">Verfügbare Module</span>
|
||||
<strong>{{ widgetTypeOptions.length }}</strong>
|
||||
<p>Du kannst Community-, Kalender-, News- und Falukant-Module kombinieren.</p>
|
||||
</article>
|
||||
<article class="overview-card surface-card">
|
||||
<span class="overview-card__label">Bearbeitungsmodus</span>
|
||||
<strong>{{ editMode ? 'Aktiv' : 'Aus' }}</strong>
|
||||
<p>{{ editMode ? 'Widgets können gerade ergänzt und angepasst werden.' : 'Inhalte bleiben fokussiert und ruhig lesbar.' }}</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div
|
||||
v-if="loadError"
|
||||
@@ -58,11 +81,20 @@
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
ref="dashboardGridRef"
|
||||
class="dashboard-grid"
|
||||
@dragover.prevent
|
||||
@drop.prevent="onAnyDrop($event)"
|
||||
class="dashboard-shell"
|
||||
>
|
||||
<div class="dashboard-shell__header">
|
||||
<div>
|
||||
<h2>Deine Übersicht</h2>
|
||||
<p>Widgets lassen sich verschieben und im Bearbeitungsmodus anpassen.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref="dashboardGridRef"
|
||||
class="dashboard-grid"
|
||||
@dragover.prevent
|
||||
@drop.prevent="onAnyDrop($event)"
|
||||
>
|
||||
<template v-for="(w, index) in widgets" :key="w.id">
|
||||
<div
|
||||
class="dashboard-grid-cell"
|
||||
@@ -103,6 +135,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="widgets.length === 0 && !loading" class="dashboard-empty">
|
||||
@@ -306,98 +339,170 @@ export default {
|
||||
|
||||
<style scoped>
|
||||
.home-logged-in {
|
||||
max-width: 1200px;
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
padding: 8px 0 24px;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
margin-bottom: 24px;
|
||||
.dashboard-hero {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 26px;
|
||||
margin-bottom: 18px;
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(248, 162, 43, 0.18), transparent 28%),
|
||||
linear-gradient(180deg, rgba(255, 251, 246, 0.96) 0%, rgba(250, 243, 233, 0.98) 100%);
|
||||
}
|
||||
|
||||
.dashboard-header h1 {
|
||||
color: #333;
|
||||
margin: 0 0 4px 0;
|
||||
.dashboard-hero__copy {
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.dashboard-kicker {
|
||||
display: inline-block;
|
||||
margin-bottom: 10px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(248, 162, 43, 0.14);
|
||||
color: #8a5411;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.dashboard-hero h1 {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.dashboard-subtitle {
|
||||
color: #666;
|
||||
margin: 0 0 16px 0;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
max-width: 58ch;
|
||||
}
|
||||
|
||||
.dashboard-overview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.overview-card {
|
||||
padding: 18px 20px;
|
||||
}
|
||||
|
||||
.overview-card__label {
|
||||
display: inline-block;
|
||||
margin-bottom: 12px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.overview-card strong {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 1.9rem;
|
||||
line-height: 1;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.overview-card p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.dashboard-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
align-self: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
min-width: 300px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.btn-edit,
|
||||
.btn-done {
|
||||
padding: 8px 14px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid transparent;
|
||||
background: var(--color-primary-orange);
|
||||
color: var(--color-text-on-orange);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.btn-edit:hover,
|
||||
.btn-done:hover {
|
||||
background: var(--color-primary-orange-light);
|
||||
color: var(--color-text-secondary);
|
||||
border-color: var(--color-text-secondary);
|
||||
color: #2b1f14;
|
||||
}
|
||||
|
||||
.widget-add-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn-add-again {
|
||||
padding: 8px 14px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-text-secondary);
|
||||
background: #fff;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
min-height: 40px;
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
border-color: var(--color-border-strong);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn-add-again:hover {
|
||||
background: var(--color-primary-orange-light);
|
||||
border-color: var(--color-primary-orange);
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
}
|
||||
|
||||
.widget-type-select {
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-text-secondary);
|
||||
background: #fff;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.9rem;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.dashboard-message {
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
padding: 16px 18px;
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.dashboard-error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
background: rgba(177, 59, 53, 0.12);
|
||||
color: #7a241f;
|
||||
border: 1px solid rgba(177, 59, 53, 0.18);
|
||||
}
|
||||
|
||||
.dashboard-shell {
|
||||
padding: 20px;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 252, 247, 0.94) 0%, rgba(248, 241, 231, 0.96) 100%);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.dashboard-shell__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.dashboard-shell__header h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.dashboard-shell__header p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
grid-auto-rows: 200px;
|
||||
gap: 20px;
|
||||
grid-auto-rows: 220px;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.dashboard-grid-cell {
|
||||
@@ -415,9 +520,9 @@ export default {
|
||||
}
|
||||
|
||||
.dashboard-grid-cell.drop-target {
|
||||
outline: 2px dashed #0d6efd;
|
||||
outline: 2px dashed rgba(248, 162, 43, 0.82);
|
||||
outline-offset: 4px;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.dashboard-grid-cell.drag-source {
|
||||
@@ -426,13 +531,14 @@ export default {
|
||||
|
||||
.dashboard-widget-edit {
|
||||
min-height: 200px;
|
||||
padding: 12px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.widget-edit-fields {
|
||||
@@ -441,40 +547,58 @@ export default {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.widget-edit-input {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.btn-remove {
|
||||
align-self: flex-start;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid transparent;
|
||||
background: var(--color-primary-orange);
|
||||
color: var(--color-text-on-orange);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
min-height: 36px;
|
||||
background: rgba(177, 59, 53, 0.12);
|
||||
color: #7a241f;
|
||||
border-color: rgba(177, 59, 53, 0.18);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn-remove:hover {
|
||||
background: var(--color-primary-orange-light);
|
||||
color: var(--color-text-secondary);
|
||||
border-color: var(--color-text-secondary);
|
||||
background: rgba(177, 59, 53, 0.18);
|
||||
}
|
||||
|
||||
.dashboard-empty {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border: 1px dashed #dee2e6;
|
||||
color: var(--color-text-secondary);
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px dashed var(--color-border-strong);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.home-logged-in {
|
||||
padding-bottom: 18px;
|
||||
}
|
||||
|
||||
.dashboard-hero {
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.dashboard-toolbar {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-overview {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dashboard-shell {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,44 +8,56 @@
|
||||
<Character3D gender="male" />
|
||||
</div>
|
||||
<div class="actions">
|
||||
<div>
|
||||
<h2>{{ $t('home.nologin.welcome') }}</h2>
|
||||
<p>{{ $t('home.nologin.description') }}</p>
|
||||
<section class="actions-panel actions-panel--story surface-card">
|
||||
<div class="panel-intro">
|
||||
<span class="panel-kicker">Dein Einstieg</span>
|
||||
<h2>{{ $t('home.nologin.welcome') }}</h2>
|
||||
<p>{{ $t('home.nologin.description') }}</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
YourPart ist eine wachsende Online‑Plattform, die Community‑Funktionen, Echtzeit‑Chat, Foren,
|
||||
ein soziales Netzwerk mit Bildergalerie sowie das Aufbauspiel <em>Falukant</em> vereint.
|
||||
Aktuell befindet sich die Seite in der Beta‑Phase – wir erweitern Funktionen, Inhalte und
|
||||
Stabilität
|
||||
kontinuierlich.
|
||||
</p>
|
||||
<div class="story-highlight">
|
||||
<p>
|
||||
YourPart verbindet Community, Echtzeit-Chat, Foren, Bildergalerie und das Aufbauspiel
|
||||
<em>Falukant</em> in einer Plattform. Der Fokus liegt auf Austausch, spielerischer Tiefe und
|
||||
einer wachsenden Produktwelt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h3>{{ $t('home.nologin.expected.title') }}</h3>
|
||||
<ul>
|
||||
<div class="story-block">
|
||||
<h3>{{ $t('home.nologin.expected.title') }}</h3>
|
||||
<ul class="feature-list">
|
||||
<li v-html="$t('home.nologin.expected.items.chat')"></li>
|
||||
<li v-html="$t('home.nologin.expected.items.social')"></li>
|
||||
<li v-html="$t('home.nologin.expected.items.forum')"></li>
|
||||
<li v-html="$t('home.nologin.expected.items.falukant')"></li>
|
||||
<li v-html="$t('home.nologin.expected.items.minigames')"></li>
|
||||
<li v-html="$t('home.nologin.expected.items.multilingual')"></li>
|
||||
</ul>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h3>{{ $t('home.nologin.falukantShort.title') }}</h3>
|
||||
<p>{{ $t('home.nologin.falukantShort.text') }}</p>
|
||||
<div class="story-columns">
|
||||
<article>
|
||||
<h3>{{ $t('home.nologin.falukantShort.title') }}</h3>
|
||||
<p>{{ $t('home.nologin.falukantShort.text') }}</p>
|
||||
</article>
|
||||
<article>
|
||||
<h3>{{ $t('home.nologin.privacyBeta.title') }}</h3>
|
||||
<p>{{ $t('home.nologin.privacyBeta.text') }}</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<h3>{{ $t('home.nologin.privacyBeta.title') }}</h3>
|
||||
<p>{{ $t('home.nologin.privacyBeta.text') }}</p>
|
||||
|
||||
<h3>{{ $t('home.nologin.getStarted.title') }}</h3>
|
||||
<p>{{ $t('home.nologin.getStarted.text', { register: $t('home.nologin.login.register') }) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
<div>
|
||||
<div class="story-cta">
|
||||
<h3>{{ $t('home.nologin.getStarted.title') }}</h3>
|
||||
<p>{{ $t('home.nologin.getStarted.text', { register: $t('home.nologin.login.register') }) }}</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="actions-panel actions-panel--access surface-card">
|
||||
<div class="login-panel">
|
||||
<span class="panel-kicker">Direkt starten</span>
|
||||
<h2>{{ $t('home.nologin.login.submit') }}</h2>
|
||||
<div class="login-fields">
|
||||
<input v-model="username" size="20" type="text" :placeholder="$t('home.nologin.login.name')"
|
||||
:title="$t('home.nologin.login.namedescription')" @keydown.enter="focusPassword">
|
||||
</div>
|
||||
<div>
|
||||
<input v-model="password" size="20" type="password"
|
||||
:placeholder="$t('home.nologin.login.password')"
|
||||
:title="$t('home.nologin.login.passworddescription')" @keydown.enter="doLogin"
|
||||
@@ -57,20 +69,26 @@
|
||||
<span>{{ $t('home.nologin.login.stayLoggedIn') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<button type="button" class="primary-action" @click="doLogin">{{ $t('home.nologin.login.submit') }}</button>
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" @click="doLogin">{{ $t('home.nologin.login.submit') }}</button>
|
||||
|
||||
<div class="access-split">
|
||||
<article class="access-card">
|
||||
<h3>{{ $t('home.nologin.randomchat') }}</h3>
|
||||
<p>Ohne lange Vorbereitung direkt in spontane Begegnungen und offene Gespraeche starten.</p>
|
||||
<button type="button" class="secondary-action" @click="openRandomChat">{{ $t('home.nologin.startrandomchat') }}</button>
|
||||
</article>
|
||||
<article class="access-card">
|
||||
<h3>Konto und Zugang</h3>
|
||||
<p>Neu hier oder Passwort vergessen? Von hier aus gelangst du direkt in Registrierung und Wiederherstellung.</p>
|
||||
<div class="access-links">
|
||||
<span @click="openPasswordResetDialog" class="link">{{
|
||||
$t('home.nologin.login.lostpassword') }}</span>
|
||||
<span @click="openRegisterDialog" class="link">{{ $t('home.nologin.login.register') }}</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div>
|
||||
<h2>{{ $t('home.nologin.randomchat') }}</h2>
|
||||
<button @click="openRandomChat">{{ $t('home.nologin.startrandomchat') }}</button>
|
||||
</div>
|
||||
<div>
|
||||
<span @click="openPasswordResetDialog" class="link">{{
|
||||
$t('home.nologin.login.lostpassword') }}</span> | <span id="o1p5iry1"
|
||||
@click="openRegisterDialog" class="link">{{ $t('home.nologin.login.register') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="mascot">
|
||||
<Character3D gender="female" />
|
||||
@@ -138,19 +156,22 @@ export default {
|
||||
|
||||
<style scoped>
|
||||
.beta-banner {
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffeeba;
|
||||
color: #856404;
|
||||
width: min(100%, var(--content-max-width));
|
||||
background: linear-gradient(180deg, #fff2cf 0%, #fde7b2 100%);
|
||||
border: 1px solid rgba(201, 130, 31, 0.24);
|
||||
color: #8a5a12;
|
||||
padding: 10px 14px;
|
||||
margin: 0 0 12px 0;
|
||||
margin: 0 0 14px 0;
|
||||
text-align: center;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.home-structure {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
gap: 2em;
|
||||
gap: 1.4rem;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
@@ -169,7 +190,7 @@ export default {
|
||||
align-items: stretch;
|
||||
background: linear-gradient(180deg, #fff5e8 0%, #fce7ca 100%);
|
||||
border: 1px solid rgba(248, 162, 43, 0.16);
|
||||
border-radius: 20px;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 10px 24px rgba(93, 64, 55, 0.08);
|
||||
overflow: hidden;
|
||||
align-self: center;
|
||||
@@ -181,26 +202,124 @@ export default {
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2em;
|
||||
gap: 1rem;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.actions>div {
|
||||
.actions-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background-color: #FFF4F0;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
display: flex;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 251, 246, 0.96) 0%, rgba(248, 240, 231, 0.96) 100%);
|
||||
color: #5D4037;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
padding: 0.5rem;
|
||||
padding: 1.2rem 1.25rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.actions>div>h2 {
|
||||
color: var(--color-primary-orange);
|
||||
.actions-panel h2,
|
||||
.actions-panel h3 {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.panel-kicker {
|
||||
display: inline-block;
|
||||
margin-bottom: 0.7rem;
|
||||
padding: 0.3rem 0.65rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(248, 162, 43, 0.12);
|
||||
color: #8a5411;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.panel-intro,
|
||||
.story-highlight,
|
||||
.story-block,
|
||||
.story-columns,
|
||||
.story-cta,
|
||||
.login-panel,
|
||||
.access-split {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.story-highlight {
|
||||
padding: 1rem 1.1rem;
|
||||
margin: 0.8rem 0 1rem;
|
||||
border-radius: var(--radius-lg);
|
||||
background: rgba(248, 162, 43, 0.08);
|
||||
border: 1px solid rgba(248, 162, 43, 0.12);
|
||||
}
|
||||
|
||||
.story-block {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
padding-left: 1.1rem;
|
||||
}
|
||||
|
||||
.feature-list li + li {
|
||||
margin-top: 0.55rem;
|
||||
}
|
||||
|
||||
.story-columns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.9rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.story-columns article,
|
||||
.story-cta,
|
||||
.access-card {
|
||||
padding: 1rem 1.05rem;
|
||||
border-radius: var(--radius-lg);
|
||||
background: rgba(255, 255, 255, 0.68);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
padding: 1rem 1.05rem;
|
||||
border-radius: var(--radius-lg);
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border: 1px solid var(--color-border);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.login-fields {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.primary-action,
|
||||
.secondary-action {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.primary-action {
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
|
||||
.access-split {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.access-card p {
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
|
||||
.access-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.stay-logged-in-row {
|
||||
@@ -299,8 +418,13 @@ export default {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.actions>div {
|
||||
.actions-panel {
|
||||
min-height: 260px;
|
||||
}
|
||||
|
||||
.story-columns,
|
||||
.access-split {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,36 @@
|
||||
<template>
|
||||
<div class="contenthidden">
|
||||
<div class="contentscroll">
|
||||
<div class="contentscroll match3-view">
|
||||
<!-- Spiel-Titel -->
|
||||
<div class="game-title">
|
||||
<section class="game-title surface-card">
|
||||
<span class="game-title__eyebrow">Minispiele</span>
|
||||
<h1>{{ $t('minigames.match3.title') }}</h1>
|
||||
<p>{{ $t('minigames.match3.campaignDescription') }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="play-focus surface-card">
|
||||
<div class="play-focus__main">
|
||||
<span class="play-focus__eyebrow">Naechster Schritt</span>
|
||||
<h2>{{ playFocusTitle }}</h2>
|
||||
<p>{{ playFocusDescription }}</p>
|
||||
</div>
|
||||
<div class="play-focus__stats">
|
||||
<span class="play-focus__pill">Level {{ currentLevel }}</span>
|
||||
<span class="play-focus__pill">{{ completedObjectivesCount }}/{{ totalObjectivesCount || 0 }} Ziele</span>
|
||||
<span class="play-focus__pill">{{ safeMovesLeft }} Zuege uebrig</span>
|
||||
</div>
|
||||
<div class="play-focus__actions">
|
||||
<button class="btn btn-primary" @click="isPaused ? resumeGame() : pauseGame()">
|
||||
{{ isPaused ? $t('minigames.match3.resume') : $t('minigames.match3.pause') }}
|
||||
</button>
|
||||
<button class="btn btn-secondary" @click="toggleLevelDescription">
|
||||
{{ levelDescriptionExpanded ? 'Ziele einklappen' : 'Ziele anzeigen' }}
|
||||
</button>
|
||||
<button class="btn btn-secondary" @click="restartLevel">
|
||||
{{ $t('minigames.match3.restartLevel') }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Kampagnen-Status -->
|
||||
<div class="game-layout">
|
||||
@@ -45,13 +70,13 @@
|
||||
|
||||
<div class="game-content">
|
||||
<!-- Verbleibende Züge -->
|
||||
<div class="moves-left-display">
|
||||
<div class="moves-left-display surface-card">
|
||||
<span class="moves-left-label">{{ $t('minigames.match3.movesLeft') }}:</span>
|
||||
<span class="moves-left-value">{{ safeMovesLeft }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Level-Info -->
|
||||
<div class="level-info-card" v-if="currentLevelData">
|
||||
<div class="level-info-card surface-card" v-if="currentLevelData">
|
||||
<div class="level-header">
|
||||
<div class="level-header-content">
|
||||
<h3 class="level-title">
|
||||
@@ -6010,6 +6035,42 @@ export default {
|
||||
},
|
||||
safeMovesLeft() {
|
||||
return this.movesLeft || 0;
|
||||
},
|
||||
totalObjectivesCount() {
|
||||
return this.currentLevelData?.objectives?.length || 0;
|
||||
},
|
||||
completedObjectivesCount() {
|
||||
if (!this.currentLevelData?.objectives?.length) {
|
||||
return 0;
|
||||
}
|
||||
return this.currentLevelData.objectives.filter((objective) => objective.completed).length;
|
||||
},
|
||||
nextPendingObjective() {
|
||||
return this.currentLevelData?.objectives?.find((objective) => !objective.completed) || null;
|
||||
},
|
||||
playFocusTitle() {
|
||||
if (this.isPaused) {
|
||||
return 'Spiel ist pausiert';
|
||||
}
|
||||
if (!this.currentLevelData) {
|
||||
return 'Level wird vorbereitet';
|
||||
}
|
||||
if (this.nextPendingObjective) {
|
||||
return this.nextPendingObjective.description || 'Aktuelles Ziel abschliessen';
|
||||
}
|
||||
return 'Level sauber zu Ende spielen';
|
||||
},
|
||||
playFocusDescription() {
|
||||
if (this.isPaused) {
|
||||
return 'Setze das Level fort oder starte es kontrolliert neu, ohne den aktuellen Kontext zu verlieren.';
|
||||
}
|
||||
if (!this.currentLevelData) {
|
||||
return 'Sobald das Level geladen ist, erscheinen hier das naechste Ziel und die passende Hauptaktion.';
|
||||
}
|
||||
if (this.nextPendingObjective) {
|
||||
return `Konzentriere dich zuerst auf dieses Ziel. Bereits erledigt: ${this.completedObjectivesCount} von ${this.totalObjectivesCount}.`;
|
||||
}
|
||||
return 'Alle sichtbaren Ziele sind erledigt. Jetzt zaehlt nur noch der saubere Abschluss des Levels.';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6019,26 +6080,94 @@ export default {
|
||||
/* Minimalistischer Style - nur für Match3Game */
|
||||
/* Verwendet globale Scroll-Klassen: .contenthidden und .contentscroll */
|
||||
|
||||
.match3-view {
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.game-title {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
padding-top: 20px;
|
||||
margin: 16px auto 30px;
|
||||
max-width: 980px;
|
||||
padding: 28px;
|
||||
background: linear-gradient(135deg, rgba(255, 247, 233, 0.98), rgba(245, 237, 225, 0.95));
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.game-title__eyebrow {
|
||||
display: inline-flex;
|
||||
margin-bottom: 10px;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-primary-soft);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.game-title h1 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.game-title p {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
color: #666;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.play-focus {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
max-width: 980px;
|
||||
margin: 0 auto 20px;
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.play-focus__eyebrow {
|
||||
display: inline-flex;
|
||||
margin-bottom: 4px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.play-focus h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.play-focus p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.play-focus__stats,
|
||||
.play-focus__actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.play-focus__pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 34px;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(248, 162, 43, 0.12);
|
||||
color: #8a5411;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Spiel-Layout */
|
||||
.game-layout {
|
||||
display: flex;
|
||||
@@ -6062,12 +6191,12 @@ export default {
|
||||
|
||||
/* Verbleibende Züge Anzeige */
|
||||
.moves-left-display {
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 5px;
|
||||
background: rgba(255, 251, 246, 0.96);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 8px 14px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
box-shadow: var(--shadow-soft);
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -6078,7 +6207,7 @@ export default {
|
||||
.moves-left-label {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.moves-left-value {
|
||||
@@ -6089,17 +6218,17 @@ export default {
|
||||
|
||||
/* Statistik-Bereich */
|
||||
.stats-card {
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 5px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
background: rgba(255, 251, 246, 0.96);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 8px;
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.stats-header {
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #eee;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
margin-bottom: 10px;
|
||||
user-select: none;
|
||||
transition: background-color 0.2s ease;
|
||||
@@ -6107,7 +6236,7 @@ export default {
|
||||
}
|
||||
|
||||
.stats-header:hover {
|
||||
background-color: #f8f9fa;
|
||||
background-color: rgba(248, 162, 43, 0.08);
|
||||
}
|
||||
|
||||
.stats-header-content {
|
||||
@@ -6121,7 +6250,7 @@ export default {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
color: var(--color-text-primary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -6174,7 +6303,7 @@ export default {
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Statistik-Werte Farben */
|
||||
@@ -6185,12 +6314,12 @@ export default {
|
||||
|
||||
/* Level-Info */
|
||||
.level-info-card {
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 5px;
|
||||
background: rgba(255, 251, 246, 0.96);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 8px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
box-shadow: var(--shadow-soft);
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
}
|
||||
@@ -6198,7 +6327,7 @@ export default {
|
||||
.level-header {
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #eee;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
margin-bottom: 10px;
|
||||
user-select: none;
|
||||
transition: background-color 0.2s ease;
|
||||
@@ -6206,7 +6335,7 @@ export default {
|
||||
}
|
||||
|
||||
.level-header:hover {
|
||||
background-color: #f8f9fa;
|
||||
background-color: rgba(248, 162, 43, 0.08);
|
||||
}
|
||||
|
||||
.level-header-content {
|
||||
@@ -6220,7 +6349,7 @@ export default {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
color: var(--color-text-primary);
|
||||
flex-grow: 1;
|
||||
text-align: left;
|
||||
pointer-events: none;
|
||||
@@ -6233,7 +6362,7 @@ export default {
|
||||
.level-info-card p {
|
||||
margin: 0 0 15px 0;
|
||||
text-align: left;
|
||||
color: #666;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@@ -6248,8 +6377,8 @@ export default {
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
padding: 8px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
background-color: rgba(255, 255, 255, 0.64);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.objective-icon {
|
||||
@@ -6265,7 +6394,7 @@ export default {
|
||||
.objective-progress {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: #6c757d;
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -6273,10 +6402,10 @@ export default {
|
||||
.game-board-container {
|
||||
display: inline-block;
|
||||
padding: 20px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #ddd;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
background: rgba(255, 251, 246, 0.96);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: var(--shadow-soft);
|
||||
margin-bottom: 20px;
|
||||
position: relative; /* Für absolute Positionierung der Animationen */
|
||||
}
|
||||
@@ -6461,6 +6590,17 @@ export default {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.game-title {
|
||||
margin-top: 12px;
|
||||
padding: 22px 18px;
|
||||
}
|
||||
|
||||
.play-focus {
|
||||
padding: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 10px 20px;
|
||||
@@ -6875,4 +7015,3 @@ export default {
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
<template>
|
||||
<div class="calendar-view">
|
||||
<h2>{{ $t('personal.calendar.title') }}</h2>
|
||||
<section class="calendar-hero surface-card">
|
||||
<div>
|
||||
<span class="calendar-kicker">Planung</span>
|
||||
<h2>{{ $t('personal.calendar.title') }}</h2>
|
||||
<p>Termine, Geburtstage und eigene Eintraege in einer strukturierten Uebersicht.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="calendar-toolbar">
|
||||
<div class="calendar-toolbar surface-card">
|
||||
<div class="nav-buttons">
|
||||
<button @click="openNewEventDialog()" class="btn-new-event">
|
||||
+ {{ $t('personal.calendar.newEntry') }}
|
||||
@@ -27,7 +33,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Selection info -->
|
||||
<div v-if="selectedDates.length > 1" class="selection-info">
|
||||
<div v-if="selectedDates.length > 1" class="selection-info surface-card">
|
||||
{{ $t('personal.calendar.selectedDays', { count: selectedDates.length }) }}
|
||||
<button @click="createEventFromSelection" class="btn-create-from-selection">
|
||||
{{ $t('personal.calendar.createEventForSelection') }}
|
||||
@@ -839,16 +845,39 @@ export default {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.calendar-view {
|
||||
padding: 20px;
|
||||
max-width: 1200px;
|
||||
padding: 0 0 24px;
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: 20px;
|
||||
.calendar-hero {
|
||||
padding: 24px 26px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.calendar-kicker {
|
||||
display: inline-block;
|
||||
margin-bottom: 10px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(120, 195, 138, 0.14);
|
||||
color: #42634e;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.calendar-hero h2 {
|
||||
margin-bottom: 8px;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.calendar-hero p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
// Toolbar
|
||||
.calendar-toolbar {
|
||||
display: flex;
|
||||
@@ -857,6 +886,7 @@ h2 {
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.nav-buttons {
|
||||
@@ -1471,7 +1501,7 @@ h2 {
|
||||
.category-btn {
|
||||
padding: 6px 12px;
|
||||
border: 2px solid;
|
||||
border-radius: 20px;
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
font-size: 0.85em;
|
||||
font-weight: 500;
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
.hero {
|
||||
padding: 32px;
|
||||
border-radius: 20px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: linear-gradient(135deg, #f7e0bb 0%, #f6c27d 45%, #e8924d 100%);
|
||||
box-shadow: 0 20px 60px rgba(106, 56, 20, 0.18);
|
||||
}
|
||||
@@ -83,7 +83,7 @@
|
||||
|
||||
.grid article {
|
||||
padding: 24px;
|
||||
border-radius: 18px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: #fff7ef;
|
||||
border: 1px solid rgba(64, 38, 26, 0.08);
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
.hero {
|
||||
padding: 32px;
|
||||
border-radius: 20px;
|
||||
border-radius: var(--radius-lg);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(255, 255, 255, 0.7), transparent 35%),
|
||||
linear-gradient(135deg, #d4f0e6 0%, #7dd0be 40%, #2e8b83 100%);
|
||||
@@ -81,7 +81,7 @@
|
||||
|
||||
.cards article {
|
||||
padding: 24px;
|
||||
border-radius: 18px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: #effaf6;
|
||||
border: 1px solid rgba(23, 50, 58, 0.08);
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
.hero {
|
||||
padding: 32px;
|
||||
border-radius: 20px;
|
||||
border-radius: var(--radius-lg);
|
||||
background:
|
||||
radial-gradient(circle at right top, rgba(255, 255, 255, 0.78), transparent 30%),
|
||||
linear-gradient(135deg, #eef6c8 0%, #bddd74 45%, #6b9d34 100%);
|
||||
@@ -85,7 +85,7 @@
|
||||
|
||||
.features article {
|
||||
padding: 24px;
|
||||
border-radius: 18px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: #f7fbe9;
|
||||
border: 1px solid rgba(31, 47, 29, 0.08);
|
||||
}
|
||||
|
||||
@@ -1,45 +1,66 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2>{{ $t("settings.account.title") }}</h2>
|
||||
<div>
|
||||
<label><span>{{ $t("settings.account.username") }} </span><input type="text" v-model="username"
|
||||
:placeholder="$t('settings.account.username')" /></label>
|
||||
</div>
|
||||
<div>
|
||||
<label><span>{{ $t("settings.account.email") }} </span><input type="text" v-model="email"
|
||||
:placeholder="$t('settings.account.email')" /></label>
|
||||
</div>
|
||||
<div>
|
||||
<label><span>{{ $t("settings.account.newpassword") }} </span><input type="password" v-model="newpassword"
|
||||
:placeholder="$t('settings.account.newpassword')" autocomplete="new-password" /></label>
|
||||
</div>
|
||||
<div>
|
||||
<label><span>{{ $t("settings.account.newpasswordretype") }} </span><input type="password"
|
||||
v-model="newpasswordretype" :placeholder="$t('settings.account.newpasswordretype')" autocomplete="new-password" /></label>
|
||||
</div>
|
||||
<div>
|
||||
<label><span>{{ $t("settings.account.oldpassword") }} </span><input type="password"
|
||||
v-model="oldpassword" :placeholder="$t('settings.account.oldpassword')" autocomplete="current-password" /></label>
|
||||
</div>
|
||||
<div>
|
||||
<button @click="changeAccount">{{ $t("settings.account.changeaction") }}</button>
|
||||
</div>
|
||||
<div>
|
||||
<label><input type="checkbox" v-model="showInSearch" /> {{ $t("settings.account.showinsearch") }}</label>
|
||||
</div>
|
||||
<div class="account-settings">
|
||||
<section class="account-settings__hero surface-card">
|
||||
<span class="account-settings__eyebrow">Einstellungen</span>
|
||||
<h2>{{ $t("settings.account.title") }}</h2>
|
||||
<p>Benutzername, E-Mail, Passwort und Sichtbarkeit an einer Stelle pflegen.</p>
|
||||
</section>
|
||||
|
||||
<section class="account-settings__panel surface-card">
|
||||
<div class="account-settings__grid">
|
||||
<label class="account-settings__field">
|
||||
<span>{{ $t("settings.account.username") }}</span>
|
||||
<input type="text" v-model="username" :placeholder="$t('settings.account.username')" />
|
||||
</label>
|
||||
|
||||
<label class="account-settings__field">
|
||||
<span>{{ $t("settings.account.email") }}</span>
|
||||
<input type="text" v-model="email" :placeholder="$t('settings.account.email')" />
|
||||
</label>
|
||||
|
||||
<label class="account-settings__field">
|
||||
<span>{{ $t("settings.account.newpassword") }}</span>
|
||||
<input type="password" v-model="newpassword" :placeholder="$t('settings.account.newpassword')"
|
||||
autocomplete="new-password" :class="{ 'field-error': newpassword && !isNewPasswordValid }" />
|
||||
<span v-if="newpassword && !isNewPasswordValid" class="form-error">Das neue Passwort sollte mindestens 8 Zeichen haben.</span>
|
||||
</label>
|
||||
|
||||
<label class="account-settings__field">
|
||||
<span>{{ $t("settings.account.newpasswordretype") }}</span>
|
||||
<input type="password" v-model="newpasswordretype"
|
||||
:placeholder="$t('settings.account.newpasswordretype')" autocomplete="new-password"
|
||||
:class="{ 'field-error': newpasswordretype && !passwordsMatch }" />
|
||||
<span v-if="newpasswordretype && !passwordsMatch" class="form-error">Die Passwoerter stimmen nicht ueberein.</span>
|
||||
</label>
|
||||
|
||||
<label class="account-settings__field account-settings__field--full">
|
||||
<span>{{ $t("settings.account.oldpassword") }}</span>
|
||||
<input type="password" v-model="oldpassword" :placeholder="$t('settings.account.oldpassword')"
|
||||
autocomplete="current-password" :class="{ 'field-error': requiresOldPassword && !oldpassword.trim() }" />
|
||||
<span v-if="requiresOldPassword && !oldpassword.trim()" class="form-error">Zum Passwortwechsel wird das aktuelle Passwort benoetigt.</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="account-settings__toggle">
|
||||
<input type="checkbox" v-model="showInSearch" />
|
||||
<span>{{ $t("settings.account.showinsearch") }}</span>
|
||||
</label>
|
||||
|
||||
<div class="account-settings__actions">
|
||||
<button @click="changeAccount">{{ $t("settings.account.changeaction") }}</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import apiClient from '@/utils/axios.js';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { showApiError, showError, showSuccess } from '@/utils/feedback.js';
|
||||
|
||||
export default {
|
||||
name: "AccountSettingsView",
|
||||
components: {},
|
||||
computed: {
|
||||
...mapGetters(['user']),
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
username: "",
|
||||
@@ -50,6 +71,18 @@ export default {
|
||||
oldpassword: "",
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(['user']),
|
||||
requiresOldPassword() {
|
||||
return this.newpassword.trim().length > 0;
|
||||
},
|
||||
isNewPasswordValid() {
|
||||
return this.newpassword.length === 0 || this.newpassword.length >= 8;
|
||||
},
|
||||
passwordsMatch() {
|
||||
return this.newpassword === this.newpasswordretype;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async changeAccount() {
|
||||
try {
|
||||
@@ -57,15 +90,19 @@ export default {
|
||||
const hasNewPassword = this.newpassword && this.newpassword.trim() !== '';
|
||||
|
||||
if (hasNewPassword) {
|
||||
if (!this.isNewPasswordValid) {
|
||||
showError(this, 'Das neue Passwort ist noch zu kurz.');
|
||||
return;
|
||||
}
|
||||
// Validiere Passwort-Wiederholung nur wenn ein neues Passwort eingegeben wurde
|
||||
if (this.newpassword !== this.newpasswordretype) {
|
||||
alert('Die Passwörter stimmen nicht überein.');
|
||||
if (!this.passwordsMatch) {
|
||||
showError(this, 'Die Passwoerter stimmen nicht ueberein.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Prüfe ob das alte Passwort eingegeben wurde
|
||||
if (!this.oldpassword || this.oldpassword.trim() === '') {
|
||||
alert('Bitte geben Sie Ihr aktuelles Passwort ein, um das Passwort zu ändern.');
|
||||
showError(this, 'Bitte geben Sie Ihr aktuelles Passwort ein, um das Passwort zu aendern.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -89,7 +126,7 @@ export default {
|
||||
// API-Aufruf zum Speichern der Account-Einstellungen
|
||||
await apiClient.post('/api/settings/set-account', accountData);
|
||||
|
||||
alert('Account-Einstellungen erfolgreich gespeichert!');
|
||||
showSuccess(this, 'Account-Einstellungen erfolgreich gespeichert.');
|
||||
|
||||
// Leere die Passwort-Felder nach erfolgreichem Speichern
|
||||
this.newpassword = '';
|
||||
@@ -98,17 +135,12 @@ export default {
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Speichern der Account-Einstellungen:', error);
|
||||
if (error.response && error.response.data && error.response.data.error) {
|
||||
alert('Fehler: ' + error.response.data.error);
|
||||
} else {
|
||||
alert('Ein Fehler ist aufgetreten beim Speichern der Account-Einstellungen.');
|
||||
}
|
||||
showApiError(this, error, 'Ein Fehler ist beim Speichern der Account-Einstellungen aufgetreten.');
|
||||
}
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
const response = await apiClient.post('/api/settings/account', { userId: this.user.id });
|
||||
console.log(response.data);
|
||||
this.username = response.data.username;
|
||||
this.showInSearch = response.data.showinsearch;
|
||||
this.email = response.data.email;
|
||||
@@ -117,18 +149,101 @@ export default {
|
||||
this.newpassword = '';
|
||||
this.newpasswordretype = '';
|
||||
this.oldpassword = '';
|
||||
|
||||
console.log(this.showInSearch);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
label {
|
||||
white-space: nowrap;
|
||||
.account-settings {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
max-width: 960px;
|
||||
}
|
||||
label > span {
|
||||
width: 15em;
|
||||
display: inline-block;
|
||||
|
||||
.account-settings__hero,
|
||||
.account-settings__panel {
|
||||
background: linear-gradient(180deg, rgba(255, 252, 247, 0.97), rgba(250, 244, 235, 0.95));
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
</style>
|
||||
|
||||
.account-settings__hero {
|
||||
padding: 26px 28px;
|
||||
}
|
||||
|
||||
.account-settings__eyebrow {
|
||||
display: inline-flex;
|
||||
margin-bottom: 8px;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-primary-soft);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.account-settings__hero p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.account-settings__panel {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.account-settings__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.account-settings__field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.account-settings__field span {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.account-settings__field--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.account-settings__toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.account-settings__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.account-settings__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.account-settings__hero,
|
||||
.account-settings__panel {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.account-settings__actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.account-settings__actions button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,38 +1,46 @@
|
||||
<template>
|
||||
<h2>{{ $t('socialnetwork.diary.title') }}</h2>
|
||||
|
||||
<div class="new-entry-section">
|
||||
<h3>{{ isEditing ? $t('socialnetwork.diary.editEntry') : $t('socialnetwork.diary.newEntry') }}</h3>
|
||||
<textarea v-model="newEntryText" placeholder="Write your diary entry..."></textarea>
|
||||
<div class="form-actions">
|
||||
<button @click="saveEntry">{{ isEditing ? $t('socialnetwork.diary.update') : $t('socialnetwork.diary.save')
|
||||
}}</button>
|
||||
<button v-if="isEditing" @click="cancelEdit">{{ $t('socialnetwork.diary.cancel') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="diaryEntries.length === 0">{{ $t('socialnetwork.diary.noEntries') }}</div>
|
||||
<div v-else class="diary-entries">
|
||||
<div v-for="entry in diaryEntries" :key="entry.id" class="diary-entry">
|
||||
<p v-html="sanitizedText(entry)"></p>
|
||||
<div class="entry-info">
|
||||
<span class="entry-timestamp">{{ new Date(entry.createdAt).toLocaleString() }}</span>
|
||||
<span class="entry-actions">
|
||||
<span @click="editEntry(entry)" class="button" :title="$t('socialnetwork.diary.edit')">✎</span>
|
||||
<span @click="deleteEntry(entry.id)" class="button" :title="$t('socialnetwork.diary.delete')">✖</span>
|
||||
</span>
|
||||
<div class="diary-view">
|
||||
<section class="diary-hero surface-card">
|
||||
<div>
|
||||
<span class="diary-kicker">Persoenliche Eintraege</span>
|
||||
<h2>{{ $t('socialnetwork.diary.title') }}</h2>
|
||||
<p>Gedanken, Notizen und kurze Updates in einer ruhigen, persoenlichen Ansicht.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class=" pagination">
|
||||
<button @click="loadDiaryEntries(currentPage - 1)" v-if="currentPage !== 1">{{
|
||||
$t('socialnetwork.diary.prevPage') }}</button>
|
||||
<span>{{ $t('socialnetwork.diary.page') }} {{ currentPage }} / {{ totalPages }}</span>
|
||||
<button @click="loadDiaryEntries(currentPage + 1)" v-if="currentPage < totalPages">{{
|
||||
$t('socialnetwork.diary.nextPage') }}</button>
|
||||
<section class="new-entry-section surface-card">
|
||||
<h3>{{ isEditing ? $t('socialnetwork.diary.editEntry') : $t('socialnetwork.diary.newEntry') }}</h3>
|
||||
<textarea v-model="newEntryText" placeholder="Write your diary entry..."></textarea>
|
||||
<div class="form-actions">
|
||||
<button @click="saveEntry">{{ isEditing ? $t('socialnetwork.diary.update') : $t('socialnetwork.diary.save')
|
||||
}}</button>
|
||||
<button v-if="isEditing" @click="cancelEdit">{{ $t('socialnetwork.diary.cancel') }}</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="diaryEntries.length === 0" class="diary-empty surface-card">{{ $t('socialnetwork.diary.noEntries') }}</div>
|
||||
<section v-else class="diary-entries">
|
||||
<article v-for="entry in diaryEntries" :key="entry.id" class="diary-entry surface-card">
|
||||
<p v-html="sanitizedText(entry)"></p>
|
||||
<div class="entry-info">
|
||||
<span class="entry-timestamp">{{ new Date(entry.createdAt).toLocaleString() }}</span>
|
||||
<span class="entry-actions">
|
||||
<span @click="editEntry(entry)" class="button" :title="$t('socialnetwork.diary.edit')">✎</span>
|
||||
<span @click="deleteEntry(entry.id)" class="button" :title="$t('socialnetwork.diary.delete')">✖</span>
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div class="pagination">
|
||||
<button @click="loadDiaryEntries(currentPage - 1)" v-if="currentPage !== 1">{{
|
||||
$t('socialnetwork.diary.prevPage') }}</button>
|
||||
<span>{{ $t('socialnetwork.diary.page') }} {{ currentPage }} / {{ totalPages }}</span>
|
||||
<button @click="loadDiaryEntries(currentPage + 1)" v-if="currentPage < totalPages">{{
|
||||
$t('socialnetwork.diary.nextPage') }}</button>
|
||||
</div>
|
||||
<ChooseDialog ref="chooseDialog" />
|
||||
</div>
|
||||
<ChooseDialog ref="chooseDialog" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -137,13 +145,38 @@ export default {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.diary-view {
|
||||
max-width: 820px;
|
||||
margin: 0 auto;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.diary-hero,
|
||||
.new-entry-section {
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 16px;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.diary-kicker {
|
||||
display: inline-block;
|
||||
margin-bottom: 10px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(248, 162, 43, 0.14);
|
||||
color: #8a5411;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.diary-hero p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
height: 140px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
@@ -152,13 +185,12 @@ textarea {
|
||||
}
|
||||
|
||||
.diary-entry {
|
||||
border-bottom: 1px solid #ccc;
|
||||
margin-bottom: 1em;
|
||||
padding-bottom: 1em;
|
||||
padding: 18px 20px;
|
||||
}
|
||||
|
||||
.entry-info {
|
||||
color: gray;
|
||||
color: var(--color-text-muted);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
@@ -176,13 +208,23 @@ textarea {
|
||||
|
||||
.pagination {
|
||||
margin-top: 1em;
|
||||
background-color: #7BBE55;
|
||||
color: #fff;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 0.5em 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.diary-entries {
|
||||
width: 400px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.diary-empty {
|
||||
padding: 22px;
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,22 +1,32 @@
|
||||
<template>
|
||||
<h2 class="link" @click="openForum()">{{ $t('socialnetwork.forum.title') }} {{ forumName }}</h2>
|
||||
<h3 v-if="forumTopic">{{ forumTopic }}</h3>
|
||||
<ul class="messages">
|
||||
<li v-for="message in messages" :key="message.id">
|
||||
<div v-html="sanitizedMessage(message)"></div>
|
||||
<div class="footer">
|
||||
<span class="link" @click="openProfile(message.lastMessageUser.hashedId)">
|
||||
{{ message.lastMessageUser.username }}
|
||||
</span>
|
||||
<span>{{ new Date(message.createdAt).toLocaleString() }}</span>
|
||||
<div class="forum-topic-view">
|
||||
<section class="forum-topic-hero surface-card">
|
||||
<div>
|
||||
<div class="forum-topic-back link" @click="openForum()">{{ $t('socialnetwork.forum.title') }} {{ forumName }}</div>
|
||||
<h2 v-if="forumTopic">{{ forumTopic }}</h2>
|
||||
<p>Diskussionen, Antworten und neue Beitraege in einer fokussierten Leseflaeche.</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<div class="editor-container">
|
||||
<EditorContent v-if="editor" :editor="editor" class="editor" />
|
||||
<section class="forum-topic-messages">
|
||||
<ul class="messages">
|
||||
<li v-for="message in messages" :key="message.id" class="surface-card">
|
||||
<div v-html="sanitizedMessage(message)"></div>
|
||||
<div class="footer">
|
||||
<span class="link" @click="openProfile(message.lastMessageUser.hashedId)">
|
||||
{{ message.lastMessageUser.username }}
|
||||
</span>
|
||||
<span>{{ new Date(message.createdAt).toLocaleString() }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<div class="editor-container surface-card">
|
||||
<EditorContent v-if="editor" :editor="editor" class="editor" />
|
||||
</div>
|
||||
<button @click="saveNewMessage">{{ $t('socialnetwork.forum.createNewMesssage') }}</button>
|
||||
</div>
|
||||
<button @click="saveNewMessage">{{ $t('socialnetwork.forum.createNewMesssage') }}</button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -98,6 +108,27 @@ export default {
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.forum-topic-view {
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.forum-topic-hero {
|
||||
padding: 24px 26px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.forum-topic-back {
|
||||
margin-bottom: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.forum-topic-hero p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.messages {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
@@ -105,14 +136,13 @@ export default {
|
||||
}
|
||||
|
||||
.messages > li {
|
||||
border: 1px solid #7BBE55;
|
||||
margin-bottom: 0.25em;
|
||||
padding: 0.5em;
|
||||
margin-bottom: 0.75em;
|
||||
padding: 1rem 1.1rem;
|
||||
}
|
||||
|
||||
.messages > li > .footer {
|
||||
color: #F9A22C;
|
||||
font-size: 0.7em;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8em;
|
||||
margin-top: 0.5em;
|
||||
display: flex;
|
||||
}
|
||||
@@ -127,10 +157,10 @@ export default {
|
||||
|
||||
.editor-container {
|
||||
margin-top: 1rem;
|
||||
border: 1px solid #ccc;
|
||||
padding: 0;
|
||||
min-height: 260px;
|
||||
background-color: white;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor {
|
||||
@@ -141,7 +171,7 @@ export default {
|
||||
.editor :deep(.ProseMirror) {
|
||||
min-height: 260px;
|
||||
outline: none;
|
||||
padding: 10px;
|
||||
padding: 14px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -1,42 +1,54 @@
|
||||
<template>
|
||||
<h2>{{ $t('socialnetwork.forum.title') }} {{ forumName }}</h2>
|
||||
<div class="creationtoggler">
|
||||
<button @click="createNewTopic">
|
||||
{{ $t(!inCreation
|
||||
? 'socialnetwork.forum.showNewTopic'
|
||||
: 'socialnetwork.forum.hideNewTopic') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="forum-view">
|
||||
<section class="forum-hero surface-card">
|
||||
<div>
|
||||
<span class="forum-kicker">Community-Forum</span>
|
||||
<h2>{{ $t('socialnetwork.forum.title') }} {{ forumName }}</h2>
|
||||
<p>Themen, Diskussionen und neue Beitraege an einem strukturierten Ort.</p>
|
||||
</div>
|
||||
<div class="creationtoggler">
|
||||
<button @click="createNewTopic">
|
||||
{{ $t(!inCreation
|
||||
? 'socialnetwork.forum.showNewTopic'
|
||||
: 'socialnetwork.forum.hideNewTopic') }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="inCreation">
|
||||
<div>
|
||||
<section v-if="inCreation" class="forum-creation surface-card">
|
||||
<label class="newtitle">
|
||||
{{ $t('socialnetwork.forum.topic') }}
|
||||
<span>{{ $t('socialnetwork.forum.topic') }}</span>
|
||||
<input type="text" v-model="newTitle" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="editor-container">
|
||||
<EditorContent v-if="editor" :editor="editor" class="editor" />
|
||||
</div>
|
||||
<button @click="saveNewTopic">
|
||||
{{ $t('socialnetwork.forum.createNewTopic') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="editor-container">
|
||||
<EditorContent v-if="editor" :editor="editor" class="editor" />
|
||||
</div>
|
||||
<button @click="saveNewTopic">
|
||||
{{ $t('socialnetwork.forum.createNewTopic') }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div v-else-if="titles.length > 0">
|
||||
<!-- hier kommt deine bestehende TABLE + PAGINATION hin -->
|
||||
<table>
|
||||
<!-- Kopfzeile, Spalten etc. -->
|
||||
</table>
|
||||
<div class="pagination">
|
||||
<button @click="goToPage(page-1)" :disabled="page<=1">‹</button>
|
||||
<span>{{ page }} / {{ totalPages }}</span>
|
||||
<button @click="goToPage(page+1)" :disabled="page>=totalPages">›</button>
|
||||
</div>
|
||||
</div>
|
||||
<section v-else-if="titles.length > 0" class="forum-topics surface-card">
|
||||
<ul class="topic-list">
|
||||
<li v-for="topic in titles" :key="topic.id" class="topic-card">
|
||||
<button type="button" class="topic-card__main" @click="openTopic(topic.id)">
|
||||
<strong>{{ topic.title }}</strong>
|
||||
<span class="topic-card__meta">
|
||||
{{ topic.user?.username || topic.owner?.username || 'Community' }}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="pagination">
|
||||
<button @click="goToPage(page-1)" :disabled="page<=1">‹</button>
|
||||
<span>{{ page }} / {{ totalPages }}</span>
|
||||
<button @click="goToPage(page+1)" :disabled="page>=totalPages">›</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-else>
|
||||
{{ $t('socialnetwork.forum.noTitles') }}
|
||||
<div v-else class="forum-empty surface-card">
|
||||
{{ $t('socialnetwork.forum.noTitles') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -156,20 +168,60 @@ export default {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.creationtoggler {
|
||||
margin-bottom: 1em;
|
||||
.forum-view {
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.forum-hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 18px;
|
||||
padding: 24px 26px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.forum-kicker {
|
||||
display: inline-block;
|
||||
margin-bottom: 10px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(248, 162, 43, 0.14);
|
||||
color: #8a5411;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.forum-hero p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.creationtoggler {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.forum-creation,
|
||||
.forum-topics,
|
||||
.forum-empty {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.newtitle {
|
||||
display: flex;
|
||||
gap: 1em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.newtitle input {
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 0.6em;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
margin: 1em 0;
|
||||
border: 1px solid #ccc;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 0;
|
||||
min-height: 260px;
|
||||
background-color: white;
|
||||
@@ -189,16 +241,62 @@ export default {
|
||||
.editor :deep(.ProseMirror p) { margin: 0 0 .6rem; }
|
||||
.editor :deep(.ProseMirror p:first-child) { margin-top: 0; }
|
||||
.editor :deep(.ProseMirror-focused) { outline: 2px solid rgba(100,150,255,.35); }
|
||||
|
||||
.topic-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.topic-card + .topic-card {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.topic-card__main {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: none;
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.topic-card__main strong {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.topic-card__meta {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.82rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.5em;
|
||||
margin: 1em 0;
|
||||
}
|
||||
.pagination button {
|
||||
padding: 0.5em 1em;
|
||||
}
|
||||
|
||||
.pagination span {
|
||||
padding: 0.5em;
|
||||
}
|
||||
|
||||
.forum-empty {
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.forum-hero {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.topic-card__main {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,13 +1,29 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2>{{ $t('friends.title') }}</h2>
|
||||
<div class="friends-view">
|
||||
<section class="friends-hero surface-card">
|
||||
<div>
|
||||
<span class="friends-kicker">Community</span>
|
||||
<h2>{{ $t('friends.title') }}</h2>
|
||||
<p>Freundschaften, offene Anfragen und laufende Kontakte an einem Ort.</p>
|
||||
</div>
|
||||
<div class="friends-stats">
|
||||
<div class="friends-stat surface-card">
|
||||
<strong>{{ tabs[0].data.length }}</strong>
|
||||
<span>Bestehend</span>
|
||||
</div>
|
||||
<div class="friends-stat surface-card">
|
||||
<strong>{{ tabs[1].data.length + tabs[2].data.length }}</strong>
|
||||
<span>Offen</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="tabs-container">
|
||||
<div class="tab" v-for="(tab, index) in tabs" :key="tab.name" :class="{ active: activeTab === index }"
|
||||
@click="selectTab(index)">
|
||||
{{ $t(tab.label) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-for="(tab, index) in tabs" v-show="activeTab === index" :key="tab.name">
|
||||
<div v-for="(tab, index) in tabs" v-show="activeTab === index" :key="tab.name" class="friends-panel surface-card">
|
||||
<v-data-table :items="paginatedData(tab.data, tab.pagination.page)" :headers="headers"
|
||||
:items-per-page="tab.pagination.itemsPerPage" class="elevation-1">
|
||||
<template v-slot:body="{ items }">
|
||||
@@ -167,25 +183,85 @@ export default {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.friends-view {
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.friends-hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 18px;
|
||||
padding: 24px 26px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.friends-kicker {
|
||||
display: inline-block;
|
||||
margin-bottom: 10px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(120, 195, 138, 0.14);
|
||||
color: #42634e;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.friends-hero p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.friends-stats {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.friends-stat {
|
||||
min-width: 120px;
|
||||
padding: 14px 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.friends-stat strong {
|
||||
display: block;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.friends-stat span {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tabs-container {
|
||||
display: flex;
|
||||
border-bottom: 1px solid #999;
|
||||
padding: 5px 0;
|
||||
gap: 8px;
|
||||
padding: 0 0 12px;
|
||||
border-bottom: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 2px 4px;
|
||||
padding: 8px 14px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
background-color: #fff;
|
||||
color: #333;
|
||||
background-color: rgba(255, 255, 255, 0.7);
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: bold;
|
||||
border: 1px solid #999;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
.tab:not(.active):hover {
|
||||
background-color: #ddd;
|
||||
background-color: rgba(248, 162, 43, 0.12);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
@@ -194,6 +270,10 @@ export default {
|
||||
border-color: #F9A22C;
|
||||
}
|
||||
|
||||
.friends-panel {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.font-color-gender-male {
|
||||
color: #1E90FF;
|
||||
}
|
||||
@@ -205,4 +285,11 @@ export default {
|
||||
.font-color-gender-nonbinary {
|
||||
color: #DAA520;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.friends-hero {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
<template>
|
||||
<h2>{{ $t('socialnetwork.gallery.title') }}</h2>
|
||||
<div class="gallery-view">
|
||||
<div class="sidebar">
|
||||
<div class="gallery-page">
|
||||
<section class="gallery-hero surface-card">
|
||||
<div>
|
||||
<span class="gallery-kicker">Bilder und Ordner</span>
|
||||
<h2>{{ $t('socialnetwork.gallery.title') }}</h2>
|
||||
<p>Eigene Inhalte organisieren, sichtbar machen und in Ordnern strukturieren.</p>
|
||||
</div>
|
||||
</section>
|
||||
<div class="gallery-view">
|
||||
<div class="sidebar surface-card">
|
||||
<h3>{{ $t('socialnetwork.gallery.folders') }}</h3>
|
||||
<ul class="tree">
|
||||
<folder-item v-for="folder in [folders]" :key="folder.id" :folder="folder"
|
||||
@@ -13,7 +20,7 @@
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="upload-section">
|
||||
<div class="upload-section surface-card">
|
||||
<div class="upload-header" @click="toggleUploadSection">
|
||||
<span>
|
||||
<i class="icon-upload-toggle">{{ isUploadVisible ? '▲' : '▼' }}</i>
|
||||
@@ -63,9 +70,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="image-list">
|
||||
<div class="image-list surface-card">
|
||||
<h3>{{ $t('socialnetwork.gallery.images') }}</h3>
|
||||
<ul v-if="images.length > 0">
|
||||
<ul v-if="images.length > 0" class="image-grid">
|
||||
<li v-for="image in images" :key="image.id" @click="openImageDialog(image)">
|
||||
<img :src="image.url || image.placeholder" alt="Loading..." />
|
||||
<p>{{ image.title }}</p>
|
||||
@@ -75,6 +82,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -265,35 +273,95 @@ export default {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gallery-page {
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.gallery-hero {
|
||||
padding: 24px 26px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.gallery-kicker {
|
||||
display: inline-block;
|
||||
margin-bottom: 10px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(120, 195, 138, 0.14);
|
||||
color: #42634e;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.gallery-hero p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.gallery-view {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 200px;
|
||||
margin-right: 20px;
|
||||
width: 240px;
|
||||
margin-right: 0;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.upload-section {
|
||||
margin-bottom: 20px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.image-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: wrap;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.image-list li {
|
||||
margin: 4px;
|
||||
.image-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 14px;
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.image-grid li {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.image-grid p {
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.image-list li img {
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
object-fit: cover;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.icon-upload-toggle {
|
||||
float: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -302,51 +370,23 @@ export default {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.folder-item {
|
||||
padding: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.folder-item.selected {
|
||||
background-color: lightgray;
|
||||
}
|
||||
|
||||
.image-list > ul {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.image-list > ul > li {
|
||||
display: inline-block;
|
||||
padding: 2px;
|
||||
border: 1px solid #F9A22C;
|
||||
}
|
||||
|
||||
.image-list > ul > li > p {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.image-list li img {
|
||||
max-width: 200px;
|
||||
max-height: 200px;
|
||||
object-fit: contain;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icon {
|
||||
cursor: pointer;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.edit-icon {
|
||||
color: green;
|
||||
}
|
||||
|
||||
.delete-icon {
|
||||
color: red;
|
||||
.upload-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tree {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.gallery-view {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
<template>
|
||||
<h2>{{ $t('socialnetwork.guestbook.title') }}</h2>
|
||||
<div>
|
||||
<div v-if="guestbookEntries.length === 0">{{ $t('socialnetwork.profile.guestbook.noEntries') }}
|
||||
<div class="guestbook-view">
|
||||
<section class="guestbook-hero surface-card">
|
||||
<div>
|
||||
<span class="guestbook-kicker">Gaestebuch</span>
|
||||
<h2>{{ $t('socialnetwork.guestbook.title') }}</h2>
|
||||
<p>Nachrichten, Rueckmeldungen und kleine Einblicke aus deinem Netzwerk.</p>
|
||||
</div>
|
||||
</section>
|
||||
<div v-if="guestbookEntries.length === 0" class="guestbook-empty surface-card">{{ $t('socialnetwork.profile.guestbook.noEntries') }}
|
||||
</div>
|
||||
<div v-else class="guestbook-entries">
|
||||
<div v-for="entry in guestbookEntries" :key="entry.id" class="guestbook-entry">
|
||||
<article v-for="entry in guestbookEntries" :key="entry.id" class="guestbook-entry surface-card">
|
||||
<img v-if="entry.image" :src="entry.image.url" alt="Entry Image"
|
||||
style="max-width: 400px; max-height: 400px;" />
|
||||
class="guestbook-image" />
|
||||
<p v-html="sanitizedContent(entry)"></p>
|
||||
<div class="entry-info">
|
||||
<span class="entry-timestamp">{{ new Date(entry.createdAt).toLocaleString() }}</span>
|
||||
@@ -14,7 +20,7 @@
|
||||
<span @click="openProfile(entry.senderUsername)">{{ entry.sender }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div class="pagination">
|
||||
<button @click="loadGuestbookEntries(currentPage - 1)" v-if="currentPage !== 1">{{
|
||||
@@ -85,10 +91,72 @@ export default {
|
||||
</script>
|
||||
|
||||
<style lang="css" scoped>
|
||||
.guestbook-view {
|
||||
max-width: 820px;
|
||||
margin: 0 auto;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.guestbook-hero,
|
||||
.guestbook-empty {
|
||||
padding: 22px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.guestbook-kicker {
|
||||
display: inline-block;
|
||||
margin-bottom: 10px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(120, 195, 138, 0.14);
|
||||
color: #42634e;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.guestbook-hero p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.guestbook-entries {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.guestbook-entry {
|
||||
padding: 18px 20px;
|
||||
}
|
||||
|
||||
.guestbook-image {
|
||||
max-width: 100%;
|
||||
max-height: 400px;
|
||||
border-radius: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.entry-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.entry-user span {
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 1em;
|
||||
background-color: #7BBE55;
|
||||
color: #fff;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 0.5em 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,36 +1,52 @@
|
||||
<template>
|
||||
<div class="search-view">
|
||||
<h2>{{ $t('socialnetwork.usersearch.title') }}</h2>
|
||||
<form @submit.prevent="performSearch">
|
||||
<div class="form-group">
|
||||
<label for="username">{{ $t('socialnetwork.usersearch.username') }}:</label>
|
||||
<input type="text" id="username" v-model="searchCriteria.username"
|
||||
:placeholder="$t('socialnetwork.usersearch.username')" />
|
||||
<section class="search-hero surface-card">
|
||||
<div>
|
||||
<span class="search-kicker">Community-Suche</span>
|
||||
<h2>{{ $t('socialnetwork.usersearch.title') }}</h2>
|
||||
<p>Mit Namen, Alter und Geschlecht gezielt passende Kontakte in der Community finden.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ageFrom">{{ $t('socialnetwork.usersearch.age_from') }}:</label>
|
||||
<input type="number" id="ageFrom" v-model="searchCriteria.ageFrom" :min="14" :max="150"
|
||||
:placeholder="$t('socialnetwork.usersearch.age_from')" class="age-input" />
|
||||
<label for="ageTo">{{ $t('socialnetwork.usersearch.age_to') }}:</label>
|
||||
<input type="number" id="ageTo" v-model="searchCriteria.ageTo" :min="14" :max="150"
|
||||
:placeholder="$t('socialnetwork.usersearch.age_to')" class="age-input" />
|
||||
<section class="search-form surface-card">
|
||||
<form @submit.prevent="performSearch">
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label for="username">{{ $t('socialnetwork.usersearch.username') }}</label>
|
||||
<input type="text" id="username" v-model="searchCriteria.username"
|
||||
:placeholder="$t('socialnetwork.usersearch.username')" />
|
||||
</div>
|
||||
|
||||
<div class="form-group form-group--age">
|
||||
<label for="ageFrom">{{ $t('socialnetwork.usersearch.age_from') }}</label>
|
||||
<div class="age-range">
|
||||
<input type="number" id="ageFrom" v-model="searchCriteria.ageFrom" :min="14" :max="150"
|
||||
:placeholder="$t('socialnetwork.usersearch.age_from')" class="age-input" />
|
||||
<span class="age-separator">bis</span>
|
||||
<input type="number" id="ageTo" v-model="searchCriteria.ageTo" :min="14" :max="150"
|
||||
:placeholder="$t('socialnetwork.usersearch.age_to')" class="age-input" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="gender">{{ $t('socialnetwork.usersearch.gender') }}</label>
|
||||
<multiselect v-model="searchCriteria.gender" :options="genderOptions" :multiple="true"
|
||||
:close-on-select="false" :placeholder="$t('socialnetwork.usersearch.gender')" label="name"
|
||||
track-by="name" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="search-button">{{ $t('socialnetwork.usersearch.search_button') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="search-results surface-card" v-if="searchResults.length">
|
||||
<div class="results-header">
|
||||
<h3>{{ $t('socialnetwork.usersearch.results_title') }}</h3>
|
||||
<span class="results-count">{{ searchResults.length }} Treffer</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="gender">{{ $t('socialnetwork.usersearch.gender') }}:</label>
|
||||
<multiselect v-model="searchCriteria.gender" :options="genderOptions" :multiple="true"
|
||||
:close-on-select="false" :placeholder="$t('socialnetwork.usersearch.gender')" label="name"
|
||||
track-by="name" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<button type="submit" class="search-button">{{ $t('socialnetwork.usersearch.search_button') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="search-results" v-if="searchResults.length">
|
||||
<h3>{{ $t('socialnetwork.usersearch.results_title') }}</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -47,8 +63,8 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else class="no-results">
|
||||
</section>
|
||||
<div v-else class="no-results surface-card">
|
||||
{{ $t('socialnetwork.usersearch.no_results') }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -114,83 +130,117 @@ export default {
|
||||
|
||||
<style scoped>
|
||||
.search-view {
|
||||
max-width: 600px;
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
.search-hero,
|
||||
.search-form,
|
||||
.search-results,
|
||||
.no-results {
|
||||
padding: 22px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.search-kicker {
|
||||
display: inline-block;
|
||||
margin-bottom: 10px;
|
||||
text-align: center;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(120, 195, 138, 0.14);
|
||||
color: #42634e;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.search-hero p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
label {
|
||||
width: 120px;
|
||||
font-weight: bold;
|
||||
margin-right: 10px;
|
||||
text-align: right;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
input,
|
||||
.multiselect__input {
|
||||
flex: 1;
|
||||
padding: 5px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ccc;
|
||||
.age-range {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.age-input {
|
||||
width: 70px;
|
||||
margin-right: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-results {
|
||||
margin-top: 20px;
|
||||
.age-separator {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.88rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.search-results ul {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
.form-actions {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.search-results li {
|
||||
padding: 8px;
|
||||
background: #f9f9f9;
|
||||
border-bottom: 1px solid #ddd;
|
||||
.results-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.results-count {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
margin: 0.5em 0;
|
||||
padding: 0;
|
||||
border-collapse: collapse;
|
||||
background: rgba(255, 255, 255, 0.62);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
thead {
|
||||
color: #7BBE55;
|
||||
color: #42634e;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding-right: 1em;
|
||||
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
th, td:not:last-child {
|
||||
border-bottom: 1px solid #7E471B;
|
||||
tbody tr + tr td {
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.clickable {
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
color: #888;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.g-male {
|
||||
@@ -200,4 +250,14 @@ th, td:not:last-child {
|
||||
.g-female {
|
||||
color: #ff3377;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.age-range {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,53 +1,59 @@
|
||||
<template>
|
||||
<h2>{{ $t('socialnetwork.vocab.chapterTitle', { title: chapter?.title || '' }) }}</h2>
|
||||
<div class="vocab-chapter-view">
|
||||
<section class="vocab-chapter-hero surface-card">
|
||||
<span class="vocab-chapter-hero__eyebrow">Vokabeltrainer</span>
|
||||
<h2>{{ $t('socialnetwork.vocab.chapterTitle', { title: chapter?.title || '' }) }}</h2>
|
||||
<p>Kapitelinhalt durchsuchen, Vokabeln pflegen und direkt in die Uebung wechseln.</p>
|
||||
</section>
|
||||
|
||||
<div class="box">
|
||||
<div v-if="loading">{{ $t('general.loading') }}</div>
|
||||
<div v-else-if="!chapter">{{ $t('socialnetwork.vocab.notFound') }}</div>
|
||||
<div v-else>
|
||||
<div v-show="!practiceOpen">
|
||||
<div class="row">
|
||||
<button @click="back">{{ $t('general.back') }}</button>
|
||||
<button v-if="vocabs.length" @click="openPractice">{{ $t('socialnetwork.vocab.practice.open') }}</button>
|
||||
<button @click="openSearch">{{ $t('socialnetwork.vocab.search.open') }}</button>
|
||||
</div>
|
||||
|
||||
<div class="row" v-if="chapter.isOwner">
|
||||
<h3>{{ $t('socialnetwork.vocab.addVocab') }}</h3>
|
||||
<div class="grid">
|
||||
<label>
|
||||
{{ $t('socialnetwork.vocab.learningWord') }}
|
||||
<input v-model="learning" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
{{ $t('socialnetwork.vocab.referenceWord') }}
|
||||
<input v-model="reference" type="text" />
|
||||
</label>
|
||||
<section class="box surface-card">
|
||||
<div v-if="loading">{{ $t('general.loading') }}</div>
|
||||
<div v-else-if="!chapter">{{ $t('socialnetwork.vocab.notFound') }}</div>
|
||||
<div v-else>
|
||||
<div v-show="!practiceOpen">
|
||||
<div class="row row--actions">
|
||||
<button @click="back">{{ $t('general.back') }}</button>
|
||||
<button v-if="vocabs.length" @click="openPractice">{{ $t('socialnetwork.vocab.practice.open') }}</button>
|
||||
<button @click="openSearch">{{ $t('socialnetwork.vocab.search.open') }}</button>
|
||||
</div>
|
||||
|
||||
<div class="editor-card" v-if="chapter.isOwner">
|
||||
<h3>{{ $t('socialnetwork.vocab.addVocab') }}</h3>
|
||||
<div class="grid">
|
||||
<label>
|
||||
<span>{{ $t('socialnetwork.vocab.learningWord') }}</span>
|
||||
<input v-model="learning" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ $t('socialnetwork.vocab.referenceWord') }}</span>
|
||||
<input v-model="reference" type="text" />
|
||||
</label>
|
||||
</div>
|
||||
<button :disabled="saving || !canSave" @click="add">
|
||||
{{ saving ? $t('socialnetwork.vocab.saving') : $t('socialnetwork.vocab.add') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="vocabs.length === 0" class="empty-state">{{ $t('socialnetwork.vocab.noVocabs') }}</div>
|
||||
<div v-else class="table-wrap">
|
||||
<table class="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('socialnetwork.vocab.learningWord') }}</th>
|
||||
<th>{{ $t('socialnetwork.vocab.referenceWord') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="v in vocabs" :key="v.id">
|
||||
<td>{{ v.learning }}</td>
|
||||
<td>{{ v.reference }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button :disabled="saving || !canSave" @click="add">
|
||||
{{ saving ? $t('socialnetwork.vocab.saving') : $t('socialnetwork.vocab.add') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div v-if="vocabs.length === 0">{{ $t('socialnetwork.vocab.noVocabs') }}</div>
|
||||
<table v-else class="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('socialnetwork.vocab.learningWord') }}</th>
|
||||
<th>{{ $t('socialnetwork.vocab.referenceWord') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="v in vocabs" :key="v.id">
|
||||
<td>{{ v.learning }}</td>
|
||||
<td>{{ v.reference }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<VocabPracticeDialog ref="practiceDialog" />
|
||||
@@ -147,30 +153,120 @@ export default {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.box {
|
||||
background: #f6f6f6;
|
||||
padding: 12px;
|
||||
border: 1px solid #ccc;
|
||||
display: inline-block;
|
||||
.vocab-chapter-view {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.vocab-chapter-hero,
|
||||
.box {
|
||||
background: linear-gradient(180deg, rgba(255, 252, 247, 0.97), rgba(250, 244, 235, 0.95));
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.vocab-chapter-hero,
|
||||
.box {
|
||||
padding: 22px 24px;
|
||||
}
|
||||
|
||||
.vocab-chapter-hero__eyebrow {
|
||||
display: inline-flex;
|
||||
margin-bottom: 8px;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-secondary-soft);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.vocab-chapter-hero p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.row {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.row--actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.editor-card {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
margin: 18px 0 20px;
|
||||
padding: 18px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(255, 255, 255, 0.62);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.grid label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.grid span {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 18px;
|
||||
border: 1px dashed var(--color-border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-secondary);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tbl {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: rgba(255, 255, 255, 0.68);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
.tbl th,
|
||||
.tbl td {
|
||||
border: 1px solid #ccc;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.tbl th {
|
||||
background: rgba(248, 162, 43, 0.12);
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.vocab-chapter-hero,
|
||||
.box {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
<template>
|
||||
<div class="vocab-course-list">
|
||||
<h2>{{ $t('socialnetwork.vocab.courses.title') }}</h2>
|
||||
<section class="vocab-courses-hero surface-card">
|
||||
<div>
|
||||
<span class="vocab-courses-kicker">Kurse</span>
|
||||
<h2>{{ $t('socialnetwork.vocab.courses.title') }}</h2>
|
||||
<p>Oeffentliche und eigene Lernkurse filtern, finden und direkt weiterlernen.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="box">
|
||||
<div class="box surface-card">
|
||||
<div class="actions">
|
||||
<button @click="showCreateDialog = true">{{ $t('socialnetwork.vocab.courses.create') }}</button>
|
||||
<button @click="loadMyCourses">{{ $t('socialnetwork.vocab.courses.myCourses') }}</button>
|
||||
@@ -361,14 +367,37 @@ export default {
|
||||
|
||||
<style scoped>
|
||||
.vocab-course-list {
|
||||
padding: 20px;
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
padding: 0 0 24px;
|
||||
}
|
||||
|
||||
.vocab-courses-hero {
|
||||
padding: 24px 26px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.vocab-courses-kicker {
|
||||
display: inline-block;
|
||||
margin-bottom: 10px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(120, 195, 138, 0.14);
|
||||
color: #42634e;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.vocab-courses-hero p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.box {
|
||||
background: #f6f6f6;
|
||||
padding: 12px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
padding: 18px;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.actions {
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
<template>
|
||||
<div class="vocab-course-view">
|
||||
<div v-if="loading">{{ $t('general.loading') }}</div>
|
||||
<div v-if="loading" class="surface-card course-state">{{ $t('general.loading') }}</div>
|
||||
<div v-else-if="course">
|
||||
<h2>{{ course.title }}</h2>
|
||||
<p v-if="course.description">{{ course.description }}</p>
|
||||
<section class="course-hero surface-card">
|
||||
<div>
|
||||
<span class="course-kicker">Lernkurs</span>
|
||||
<h2>{{ course.title }}</h2>
|
||||
<p v-if="course.description">{{ course.description }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="course-info">
|
||||
<div class="course-info surface-card">
|
||||
<span>{{ $t('socialnetwork.vocab.courses.difficulty') }}: {{ course.difficultyLevel }}</span>
|
||||
<span v-if="course.isPublic">{{ $t('socialnetwork.vocab.courses.public') }}</span>
|
||||
<span v-if="course.shareCode && isOwner" class="share-code">
|
||||
@@ -18,7 +23,7 @@
|
||||
<button @click="editCourse">{{ $t('socialnetwork.vocab.courses.edit') }}</button>
|
||||
</div>
|
||||
|
||||
<div v-if="course.lessons && course.lessons.length > 0" class="lessons-list">
|
||||
<div v-if="course.lessons && course.lessons.length > 0" class="lessons-list surface-card">
|
||||
<div class="current-lesson-section" v-if="currentLesson">
|
||||
<button @click="openLesson(currentLesson.id)" class="btn-current-lesson">
|
||||
{{ $t('socialnetwork.vocab.courses.continueCurrentLesson') }}
|
||||
@@ -75,7 +80,7 @@
|
||||
</table>
|
||||
</div>
|
||||
<div v-else>
|
||||
<p>{{ $t('socialnetwork.vocab.courses.noLessons') }}</p>
|
||||
<p class="surface-card course-state">{{ $t('socialnetwork.vocab.courses.noLessons') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -84,28 +89,29 @@
|
||||
<div class="dialog" @click.stop>
|
||||
<h3>{{ $t('socialnetwork.vocab.courses.addLesson') }}</h3>
|
||||
<form @submit.prevent="addLesson">
|
||||
<div class="form-group">
|
||||
<div class="form-group form-field">
|
||||
<label>{{ $t('socialnetwork.vocab.courses.lessonNumber') }}</label>
|
||||
<input type="number" v-model.number="newLesson.lessonNumber" min="1" required />
|
||||
<input type="number" v-model.number="newLesson.lessonNumber" min="1" required :class="{ 'field-error': lessonFormTouched && !isLessonNumberValid }" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="form-group form-field">
|
||||
<label>{{ $t('socialnetwork.vocab.courses.title') }}</label>
|
||||
<input v-model="newLesson.title" required />
|
||||
<input v-model="newLesson.title" required :class="{ 'field-error': lessonFormTouched && !isLessonTitleValid }" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="form-group form-field">
|
||||
<label>{{ $t('socialnetwork.vocab.courses.description') }}</label>
|
||||
<textarea v-model="newLesson.description"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="form-group form-field">
|
||||
<label>{{ $t('socialnetwork.vocab.courses.chapter') }}</label>
|
||||
<select v-model="newLesson.chapterId" required>
|
||||
<select v-model="newLesson.chapterId" required :class="{ 'field-error': lessonFormTouched && !isLessonChapterValid }">
|
||||
<option value="">{{ $t('socialnetwork.vocab.courses.selectChapter') }}</option>
|
||||
<option v-for="chapter in chapters" :key="chapter.id" :value="chapter.id">{{ chapter.title }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit">{{ $t('general.create') }}</button>
|
||||
<button type="button" @click="showAddLessonDialog = false">{{ $t('general.cancel') }}</button>
|
||||
<span v-if="lessonFormTouched && !canCreateLesson" class="form-error">Bitte Nummer, Titel und Kapitel vollstaendig angeben.</span>
|
||||
<div class="form-actions form-actions-row">
|
||||
<button type="submit" :disabled="!canCreateLesson">{{ $t('general.create') }}</button>
|
||||
<button type="button" @click="showAddLessonDialog = false" class="button-secondary">{{ $t('general.cancel') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -116,6 +122,7 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import apiClient from '@/utils/axios.js';
|
||||
import { showApiError, showSuccess } from '@/utils/feedback.js';
|
||||
|
||||
export default {
|
||||
name: 'VocabCourseView',
|
||||
@@ -132,6 +139,7 @@ export default {
|
||||
progress: [],
|
||||
chapters: [],
|
||||
showAddLessonDialog: false,
|
||||
lessonFormTouched: false,
|
||||
newLesson: {
|
||||
lessonNumber: 1,
|
||||
title: '',
|
||||
@@ -163,6 +171,18 @@ export default {
|
||||
|
||||
// Alle Lektionen abgeschlossen - zeige die letzte Lektion
|
||||
return sortedLessons[sortedLessons.length - 1];
|
||||
},
|
||||
isLessonNumberValid() {
|
||||
return Number(this.newLesson.lessonNumber) > 0;
|
||||
},
|
||||
isLessonTitleValid() {
|
||||
return this.newLesson.title.trim().length >= 3;
|
||||
},
|
||||
isLessonChapterValid() {
|
||||
return Boolean(this.newLesson.chapterId);
|
||||
},
|
||||
canCreateLesson() {
|
||||
return this.isLessonNumberValid && this.isLessonTitleValid && this.isLessonChapterValid;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -232,9 +252,14 @@ export default {
|
||||
return false;
|
||||
},
|
||||
async addLesson() {
|
||||
this.lessonFormTouched = true;
|
||||
if (!this.canCreateLesson) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await apiClient.post(`/api/vocab/courses/${this.courseId}/lessons`, this.newLesson);
|
||||
this.showAddLessonDialog = false;
|
||||
this.lessonFormTouched = false;
|
||||
this.newLesson = {
|
||||
lessonNumber: 1,
|
||||
title: '',
|
||||
@@ -242,9 +267,10 @@ export default {
|
||||
chapterId: null
|
||||
};
|
||||
await this.loadCourse();
|
||||
showSuccess(this, 'Lektion erfolgreich angelegt.');
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Hinzufügen der Lektion:', e);
|
||||
alert(e.response?.data?.error || 'Fehler beim Hinzufügen der Lektion');
|
||||
showApiError(this, e, 'Fehler beim Hinzufuegen der Lektion');
|
||||
}
|
||||
},
|
||||
async deleteLesson(lessonId) {
|
||||
@@ -254,9 +280,10 @@ export default {
|
||||
try {
|
||||
await apiClient.delete(`/api/vocab/lessons/${lessonId}`);
|
||||
await this.loadCourse();
|
||||
showSuccess(this, 'Lektion erfolgreich geloescht.');
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Löschen der Lektion:', e);
|
||||
alert(e.response?.data?.error || 'Fehler beim Löschen der Lektion');
|
||||
showApiError(this, e, 'Fehler beim Loeschen der Lektion');
|
||||
}
|
||||
},
|
||||
openLesson(lessonId) {
|
||||
@@ -278,15 +305,47 @@ export default {
|
||||
|
||||
<style scoped>
|
||||
.vocab-course-view {
|
||||
padding: 20px;
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
padding: 0 0 24px;
|
||||
}
|
||||
|
||||
.course-hero,
|
||||
.course-info,
|
||||
.lessons-list,
|
||||
.course-state {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.course-hero {
|
||||
padding: 24px 26px;
|
||||
}
|
||||
|
||||
.course-kicker {
|
||||
display: inline-block;
|
||||
margin-bottom: 10px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(248, 162, 43, 0.14);
|
||||
color: #8a5411;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.course-hero p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.course-info {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin: 15px 0;
|
||||
margin: 0 0 16px;
|
||||
color: #666;
|
||||
flex-wrap: wrap;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.share-code {
|
||||
@@ -307,7 +366,14 @@ export default {
|
||||
}
|
||||
|
||||
.lessons-list {
|
||||
margin-top: 30px;
|
||||
margin-top: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.course-state {
|
||||
padding: 18px;
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.current-lesson-section {
|
||||
|
||||
@@ -1,32 +1,36 @@
|
||||
<template>
|
||||
<h2>{{ $t('socialnetwork.vocab.languageTitle', { name: language?.name || '' }) }}</h2>
|
||||
<div class="vocab-language-view">
|
||||
<section class="vocab-language-hero surface-card">
|
||||
<div v-if="loading">{{ $t('general.loading') }}</div>
|
||||
<div v-else-if="!language">{{ $t('socialnetwork.vocab.notFound') }}</div>
|
||||
<div v-else>
|
||||
<span class="vocab-language-kicker">Sprache</span>
|
||||
<h2>{{ $t('socialnetwork.vocab.languageTitle', { name: language?.name || '' }) }}</h2>
|
||||
<p>Kapitel, Suchfunktionen und Freigaben fuer diese Sprache an einem Ort.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="box">
|
||||
<div v-if="loading">{{ $t('general.loading') }}</div>
|
||||
<div v-else-if="!language">{{ $t('socialnetwork.vocab.notFound') }}</div>
|
||||
<div v-else>
|
||||
<div class="row">
|
||||
<strong>{{ $t('socialnetwork.vocab.languageName') }}:</strong>
|
||||
<div class="box surface-card" v-if="language">
|
||||
<div class="row row--meta">
|
||||
<strong>{{ $t('socialnetwork.vocab.languageName') }}</strong>
|
||||
<span>{{ language.name }}</span>
|
||||
</div>
|
||||
|
||||
<div class="row" v-if="language.isOwner && language.shareCode">
|
||||
<strong>{{ $t('socialnetwork.vocab.shareCode') }}:</strong>
|
||||
<div class="row row--meta" v-if="language.isOwner && language.shareCode">
|
||||
<strong>{{ $t('socialnetwork.vocab.shareCode') }}</strong>
|
||||
<code>{{ language.shareCode }}</code>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="row row--actions">
|
||||
<button @click="goSubscribe">{{ $t('socialnetwork.vocab.subscribeByCode') }}</button>
|
||||
<button @click="openSearch">{{ $t('socialnetwork.vocab.search.open') }}</button>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="row">
|
||||
<h3>{{ $t('socialnetwork.vocab.chapters') }}</h3>
|
||||
</div>
|
||||
|
||||
<div class="row" v-if="language.isOwner">
|
||||
<div class="row row--create" v-if="language.isOwner">
|
||||
<label>
|
||||
{{ $t('socialnetwork.vocab.newChapter') }}
|
||||
<input v-model="newChapterTitle" type="text" />
|
||||
@@ -39,11 +43,12 @@
|
||||
<div v-if="chaptersLoading">{{ $t('general.loading') }}</div>
|
||||
<div v-else>
|
||||
<div v-if="chapters.length === 0">{{ $t('socialnetwork.vocab.noChapters') }}</div>
|
||||
<ul v-else>
|
||||
<ul v-else class="chapter-list">
|
||||
<li v-for="c in chapters" :key="c.id">
|
||||
<span class="click" @click="openChapter(c.id)">
|
||||
{{ c.title }} <span class="count">({{ c.vocabCount }})</span>
|
||||
</span>
|
||||
<button type="button" class="chapter-card" @click="openChapter(c.id)">
|
||||
<span>{{ c.title }}</span>
|
||||
<span class="count">{{ c.vocabCount }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -139,22 +144,81 @@ export default {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.vocab-language-view {
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.vocab-language-hero {
|
||||
padding: 24px 26px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.vocab-language-kicker {
|
||||
display: inline-block;
|
||||
margin-bottom: 10px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(248, 162, 43, 0.14);
|
||||
color: #8a5411;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.vocab-language-hero p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.box {
|
||||
background: #f6f6f6;
|
||||
padding: 12px;
|
||||
border: 1px solid #ccc;
|
||||
padding: 20px;
|
||||
}
|
||||
.row {
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.row--meta {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
.row--actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.row--create {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.click {
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.chapter-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.chapter-card {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: none;
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.count {
|
||||
color: #666;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
@@ -1,40 +1,50 @@
|
||||
<template>
|
||||
<h2>{{ $t('socialnetwork.vocab.newLanguageTitle') }}</h2>
|
||||
<div class="vocab-new-language-view">
|
||||
<section class="vocab-new-language-hero surface-card">
|
||||
<span class="vocab-new-language-hero__eyebrow">Vokabeltrainer</span>
|
||||
<h2>{{ $t('socialnetwork.vocab.newLanguageTitle') }}</h2>
|
||||
<p>Neue Sprache anlegen, Freigabecode erzeugen und direkt in die Bearbeitung wechseln.</p>
|
||||
</section>
|
||||
|
||||
<div class="box">
|
||||
<label class="label">
|
||||
{{ $t('socialnetwork.vocab.languageName') }}
|
||||
<input v-model="name" type="text" />
|
||||
</label>
|
||||
<section class="box surface-card">
|
||||
<label class="label form-field">
|
||||
<span>{{ $t('socialnetwork.vocab.languageName') }}</span>
|
||||
<input v-model="name" type="text" :class="{ 'field-error': nameTouched && !canSave }" />
|
||||
<span class="form-hint">Ein kurzer, klarer Sprachname reicht fuer den Start.</span>
|
||||
<span v-if="nameTouched && !canSave" class="form-error">Der Name sollte mindestens 2 Zeichen haben.</span>
|
||||
</label>
|
||||
|
||||
<div class="actions">
|
||||
<button :disabled="saving || !canSave" @click="create">
|
||||
{{ saving ? $t('socialnetwork.vocab.saving') : $t('socialnetwork.vocab.create') }}
|
||||
</button>
|
||||
<button :disabled="saving" @click="cancel">{{ $t('Cancel') }}</button>
|
||||
</div>
|
||||
|
||||
<div v-if="created" class="created">
|
||||
<div><strong>{{ $t('socialnetwork.vocab.created') }}</strong></div>
|
||||
<div>
|
||||
{{ $t('socialnetwork.vocab.shareCode') }}:
|
||||
<code>{{ created.shareCode }}</code>
|
||||
<div class="actions form-actions-row">
|
||||
<button :disabled="saving || !canSave" @click="create">
|
||||
{{ saving ? $t('socialnetwork.vocab.saving') : $t('socialnetwork.vocab.create') }}
|
||||
</button>
|
||||
<button :disabled="saving" @click="cancel" class="button-secondary">{{ $t('Cancel') }}</button>
|
||||
</div>
|
||||
<div class="hint">{{ $t('socialnetwork.vocab.shareHint') }}</div>
|
||||
<button @click="openLanguage(created.id)">{{ $t('socialnetwork.vocab.openLanguage') }}</button>
|
||||
</div>
|
||||
|
||||
<div v-if="created" class="created">
|
||||
<div><strong>{{ $t('socialnetwork.vocab.created') }}</strong></div>
|
||||
<div>
|
||||
{{ $t('socialnetwork.vocab.shareCode') }}:
|
||||
<code>{{ created.shareCode }}</code>
|
||||
</div>
|
||||
<div class="hint">{{ $t('socialnetwork.vocab.shareHint') }}</div>
|
||||
<button @click="openLanguage(created.id)">{{ $t('socialnetwork.vocab.openLanguage') }}</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions } from 'vuex';
|
||||
import apiClient from '@/utils/axios.js';
|
||||
import { showApiError, showSuccess } from '@/utils/feedback.js';
|
||||
|
||||
export default {
|
||||
name: 'VocabNewLanguageView',
|
||||
data() {
|
||||
return {
|
||||
name: '',
|
||||
nameTouched: false,
|
||||
saving: false,
|
||||
created: null,
|
||||
};
|
||||
@@ -53,22 +63,20 @@ export default {
|
||||
this.$router.push(`/socialnetwork/vocab/${id}`);
|
||||
},
|
||||
async create() {
|
||||
this.nameTouched = true;
|
||||
if (!this.canSave) {
|
||||
return;
|
||||
}
|
||||
this.saving = true;
|
||||
try {
|
||||
const res = await apiClient.post('/api/vocab/languages', { name: this.name });
|
||||
this.created = res.data;
|
||||
// Menü sofort lokal aktualisieren (zusätzlich zum serverseitigen reloadmenu event)
|
||||
try { await this.loadMenu(); } catch (_) {}
|
||||
this.$root.$refs.messageDialog?.open(
|
||||
this.$t('socialnetwork.vocab.createdMessage'),
|
||||
this.$t('socialnetwork.vocab.createdTitle')
|
||||
);
|
||||
showSuccess(this, this.$t('socialnetwork.vocab.createdMessage'), this.$t('socialnetwork.vocab.createdTitle'));
|
||||
} catch (e) {
|
||||
console.error('Create vocab language failed:', e);
|
||||
this.$root.$refs.messageDialog?.open(
|
||||
this.$t('socialnetwork.vocab.createError'),
|
||||
this.$t('error.title')
|
||||
);
|
||||
showApiError(this, e, this.$t('socialnetwork.vocab.createError'));
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
@@ -78,29 +86,88 @@ export default {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.vocab-new-language-view {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.vocab-new-language-hero,
|
||||
.box {
|
||||
background: #f6f6f6;
|
||||
padding: 12px;
|
||||
border: 1px solid #ccc;
|
||||
background: linear-gradient(180deg, rgba(255, 252, 247, 0.97), rgba(250, 244, 235, 0.95));
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.vocab-new-language-hero,
|
||||
.box {
|
||||
padding: 22px 24px;
|
||||
}
|
||||
|
||||
.vocab-new-language-hero__eyebrow {
|
||||
display: inline-flex;
|
||||
margin-bottom: 8px;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-secondary-soft);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.vocab-new-language-hero p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.label span {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.created {
|
||||
margin-top: 12px;
|
||||
padding: 10px;
|
||||
background: #fff;
|
||||
border: 1px solid #bbb;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 18px;
|
||||
padding: 18px;
|
||||
background: rgba(255, 255, 255, 0.68);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.hint {
|
||||
margin-top: 6px;
|
||||
color: #555;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
code {
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-primary-soft);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.vocab-new-language-hero,
|
||||
.box {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.actions button,
|
||||
.created button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
<template>
|
||||
<h2>{{ $t('socialnetwork.vocab.subscribeTitle') }}</h2>
|
||||
<div class="vocab-subscribe-view">
|
||||
<section class="vocab-subscribe-hero surface-card">
|
||||
<span class="vocab-subscribe-hero__eyebrow">Vokabeltrainer</span>
|
||||
<h2>{{ $t('socialnetwork.vocab.subscribeTitle') }}</h2>
|
||||
<p>{{ $t('socialnetwork.vocab.subscribeHint') }}</p>
|
||||
</section>
|
||||
|
||||
<div class="box">
|
||||
<p>{{ $t('socialnetwork.vocab.subscribeHint') }}</p>
|
||||
<section class="box surface-card">
|
||||
<label class="label">
|
||||
<span>{{ $t('socialnetwork.vocab.shareCode') }}</span>
|
||||
<input v-model="shareCode" type="text" />
|
||||
</label>
|
||||
|
||||
<label class="label">
|
||||
{{ $t('socialnetwork.vocab.shareCode') }}
|
||||
<input v-model="shareCode" type="text" />
|
||||
</label>
|
||||
|
||||
<div class="actions">
|
||||
<button :disabled="saving || !canSave" @click="subscribe">
|
||||
{{ saving ? $t('socialnetwork.vocab.saving') : $t('socialnetwork.vocab.subscribe') }}
|
||||
</button>
|
||||
<button :disabled="saving" @click="back">{{ $t('general.back') }}</button>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button :disabled="saving || !canSave" @click="subscribe">
|
||||
{{ saving ? $t('socialnetwork.vocab.saving') : $t('socialnetwork.vocab.subscribe') }}
|
||||
</button>
|
||||
<button :disabled="saving" @click="back">{{ $t('general.back') }}</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -75,19 +79,69 @@ export default {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.vocab-subscribe-view {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.vocab-subscribe-hero,
|
||||
.box {
|
||||
background: #f6f6f6;
|
||||
padding: 12px;
|
||||
border: 1px solid #ccc;
|
||||
background: linear-gradient(180deg, rgba(255, 252, 247, 0.97), rgba(250, 244, 235, 0.95));
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.vocab-subscribe-hero,
|
||||
.box {
|
||||
padding: 22px 24px;
|
||||
}
|
||||
|
||||
.vocab-subscribe-hero__eyebrow {
|
||||
display: inline-flex;
|
||||
margin-bottom: 8px;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-secondary-soft);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.vocab-subscribe-hero p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.label span {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.vocab-subscribe-hero,
|
||||
.box {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
@@ -1,27 +1,101 @@
|
||||
<template>
|
||||
<h2>{{ $t('socialnetwork.vocab.title') }}</h2>
|
||||
<div class="vocab-view">
|
||||
<section class="vocab-hero surface-card">
|
||||
<div>
|
||||
<span class="vocab-kicker">Sprachenlernen</span>
|
||||
<h2>{{ $t('socialnetwork.vocab.title') }}</h2>
|
||||
<p>{{ $t('socialnetwork.vocab.description') }}</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button @click="goNewLanguage">{{ $t('socialnetwork.vocab.newLanguage') }}</button>
|
||||
<button @click="goCourses">{{ $t('socialnetwork.vocab.courses.title') }}</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="box">
|
||||
<p>{{ $t('socialnetwork.vocab.description') }}</p>
|
||||
<section class="vocab-summary-grid">
|
||||
<article class="summary-card surface-card">
|
||||
<span class="summary-card__label">Sprachen gesamt</span>
|
||||
<strong>{{ languages.length }}</strong>
|
||||
<p>Alle aktiven Sprachbereiche, in denen du Inhalte nutzt oder verwaltest.</p>
|
||||
</article>
|
||||
<article class="summary-card surface-card">
|
||||
<span class="summary-card__label">Eigene Bereiche</span>
|
||||
<strong>{{ ownedLanguages.length }}</strong>
|
||||
<p>Hier legst du Inhalte, Kapitel und Lernmaterial aktiv selbst an.</p>
|
||||
</article>
|
||||
<article class="summary-card surface-card">
|
||||
<span class="summary-card__label">Abonniert</span>
|
||||
<strong>{{ subscribedLanguages.length }}</strong>
|
||||
<p>Diese Bereiche sind eher fuer Lernen und Fortschritt statt Verwaltung gedacht.</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div class="actions">
|
||||
<button @click="goNewLanguage">{{ $t('socialnetwork.vocab.newLanguage') }}</button>
|
||||
<button @click="goCourses">{{ $t('socialnetwork.vocab.courses.title') }}</button>
|
||||
</div>
|
||||
<section class="vocab-task-grid">
|
||||
<article class="task-card surface-card">
|
||||
<span class="task-card__eyebrow">Schnellstart</span>
|
||||
<h3>Neue Sprache anlegen</h3>
|
||||
<p>Der beste Einstieg, wenn du Inhalte selbst strukturieren und pflegen willst.</p>
|
||||
<button type="button" @click="goNewLanguage">{{ $t('socialnetwork.vocab.newLanguage') }}</button>
|
||||
</article>
|
||||
<article class="task-card surface-card">
|
||||
<span class="task-card__eyebrow">Weiterlernen</span>
|
||||
<h3>Kurse und Kapitel oeffnen</h3>
|
||||
<p>Springe direkt in bestehende Lernpfade und arbeite mit vorhandenen Kursen weiter.</p>
|
||||
<button type="button" class="button-secondary" @click="goCourses">{{ $t('socialnetwork.vocab.courses.title') }}</button>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div v-if="loading">{{ $t('general.loading') }}</div>
|
||||
<div v-else>
|
||||
<div v-if="languages.length === 0">
|
||||
<section class="vocab-box surface-card">
|
||||
<div v-if="loading" class="vocab-state">{{ $t('general.loading') }}</div>
|
||||
<div v-else-if="languages.length === 0" class="vocab-state">
|
||||
{{ $t('socialnetwork.vocab.none') }}
|
||||
</div>
|
||||
<ul v-else>
|
||||
<li v-for="l in languages" :key="l.id">
|
||||
<span class="langname" @click="openLanguage(l.id)">{{ l.name }}</span>
|
||||
<span class="role" v-if="l.isOwner">({{ $t('socialnetwork.vocab.owner') }})</span>
|
||||
<span class="role" v-else>({{ $t('socialnetwork.vocab.subscribed') }})</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-else class="language-sections">
|
||||
<section class="language-section">
|
||||
<div class="language-section__header">
|
||||
<div>
|
||||
<h3>Eigene Sprachen</h3>
|
||||
<p>Direkter Einstieg in Bearbeitung, Kapitel und Kursverwaltung.</p>
|
||||
</div>
|
||||
<span class="language-section__count">{{ ownedLanguages.length }}</span>
|
||||
</div>
|
||||
<ul v-if="ownedLanguages.length" class="language-list">
|
||||
<li v-for="l in ownedLanguages" :key="l.id" class="language-card">
|
||||
<button type="button" class="language-card__main" @click="openLanguage(l.id)">
|
||||
<div class="language-card__info">
|
||||
<span class="langname">{{ l.name }}</span>
|
||||
<span class="language-card__hint">Verwalten und Inhalte pflegen</span>
|
||||
</div>
|
||||
<span class="role">{{ $t('socialnetwork.vocab.owner') }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="language-empty">Noch keine eigenen Sprachbereiche vorhanden.</p>
|
||||
</section>
|
||||
|
||||
<section class="language-section">
|
||||
<div class="language-section__header">
|
||||
<div>
|
||||
<h3>Abonnierte Sprachen</h3>
|
||||
<p>Gut fuer schnellen Wiedereinstieg ins Lernen ohne Verwaltungsaufwand.</p>
|
||||
</div>
|
||||
<span class="language-section__count">{{ subscribedLanguages.length }}</span>
|
||||
</div>
|
||||
<ul v-if="subscribedLanguages.length" class="language-list">
|
||||
<li v-for="l in subscribedLanguages" :key="l.id" class="language-card">
|
||||
<button type="button" class="language-card__main" @click="openLanguage(l.id)">
|
||||
<div class="language-card__info">
|
||||
<span class="langname">{{ l.name }}</span>
|
||||
<span class="language-card__hint">Lernen, ueben und Fortschritt ansehen</span>
|
||||
</div>
|
||||
<span class="role">{{ $t('socialnetwork.vocab.subscribed') }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="language-empty">Keine abonnierten Sprachen vorhanden.</p>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -39,6 +113,12 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(['user']),
|
||||
ownedLanguages() {
|
||||
return this.languages.filter((language) => language.isOwner);
|
||||
},
|
||||
subscribedLanguages() {
|
||||
return this.languages.filter((language) => !language.isOwner);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
goNewLanguage() {
|
||||
@@ -69,22 +149,197 @@ export default {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.box {
|
||||
background: #f6f6f6;
|
||||
padding: 12px;
|
||||
border: 1px solid #ccc;
|
||||
.vocab-view {
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.vocab-hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 18px;
|
||||
padding: 24px 26px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.vocab-kicker {
|
||||
display: inline-block;
|
||||
margin-bottom: 10px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(248, 162, 43, 0.14);
|
||||
color: #8a5411;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.vocab-hero p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.vocab-box {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.vocab-summary-grid,
|
||||
.vocab-task-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.vocab-task-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.summary-card,
|
||||
.task-card {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.summary-card strong {
|
||||
display: block;
|
||||
margin: 6px 0 10px;
|
||||
font-size: 1.8rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.summary-card p,
|
||||
.task-card p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.summary-card__label,
|
||||
.task-card__eyebrow {
|
||||
display: inline-flex;
|
||||
margin-bottom: 4px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.task-card h3 {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.task-card button {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin: 10px 0;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.vocab-state {
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.language-sections {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.language-section {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.language-section__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.language-section__header h3 {
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.language-section__header p,
|
||||
.language-empty {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.language-section__count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(248, 162, 43, 0.12);
|
||||
color: #8a5411;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.language-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.language-card__main {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: none;
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.language-card__info {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.langname {
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.language-card__hint {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.role {
|
||||
margin-left: 6px;
|
||||
color: #666;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.82rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.vocab-hero {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.vocab-summary-grid,
|
||||
.vocab-task-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
@@ -30,6 +30,9 @@ export default defineConfig(({ mode }) => {
|
||||
}),
|
||||
...(combinedEnv.VITE_SOCKET_IO_URL && {
|
||||
'import.meta.env.VITE_SOCKET_IO_URL': JSON.stringify(combinedEnv.VITE_SOCKET_IO_URL)
|
||||
}),
|
||||
...(combinedEnv.VITE_PUBLIC_BASE_URL && {
|
||||
'import.meta.env.VITE_PUBLIC_BASE_URL': JSON.stringify(combinedEnv.VITE_PUBLIC_BASE_URL)
|
||||
})
|
||||
},
|
||||
optimizeDeps: {
|
||||
@@ -56,8 +59,35 @@ export default defineConfig(({ mode }) => {
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
// Stelle sicher, dass three nicht externalisiert wird
|
||||
manualChunks: undefined
|
||||
manualChunks(id) {
|
||||
if (!id.includes('node_modules')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (id.includes('@tiptap')) {
|
||||
return 'vendor-editor';
|
||||
}
|
||||
|
||||
if (id.includes('vuetify')) {
|
||||
return 'vendor-vuetify';
|
||||
}
|
||||
|
||||
if (id.includes('socket.io-client')) {
|
||||
return 'vendor-realtime';
|
||||
}
|
||||
|
||||
if (id.includes('axios') || id.includes('dompurify')) {
|
||||
return 'vendor-utils';
|
||||
}
|
||||
|
||||
if (id.includes('vue-router') || id.includes('vuex') || id.includes('vue-i18n')) {
|
||||
return 'vendor-vue-ecosystem';
|
||||
}
|
||||
|
||||
if (id.includes('vue')) {
|
||||
return 'vendor-vue';
|
||||
}
|
||||
}
|
||||
},
|
||||
external: [] // Explizit keine externen Module
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user