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

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

View File

@@ -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>

View File

@@ -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%;
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 ? '&#9650;' : '&#9660;' }}</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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>