Add password reset localization and chat configuration

- Implemented German and English localization for password reset functionality.
- Added WebSocket URL resolution logic in chat services to support various environments and configurations.
- Created centralized chat configuration for event keys and payload mappings.
- Developed RoomsView component for admin chat room management, including create, edit, and delete functionalities.
This commit is contained in:
Torsten Schulz (local)
2025-08-18 07:44:56 +02:00
parent 23f698d8fd
commit 19ee6ba0a1
50 changed files with 3117 additions and 359 deletions

View File

@@ -16,6 +16,7 @@
<ImprintDialog ref="imprintDialog" />
<ShowImageDialog ref="showImageDialog" />
<MessageDialog ref="messageDialog" />
<MultiChatDialog ref="multiChatDialog" />
</div>
</template>
@@ -37,6 +38,7 @@ import ErrorDialog from './dialogues/standard/ErrorDialog.vue';
import ImprintDialog from './dialogues/standard/ImprintDialog.vue';
import ShowImageDialog from './dialogues/socialnetwork/ShowImageDialog.vue';
import MessageDialog from './dialogues/standard/MessageDialog.vue';
import MultiChatDialog from './dialogues/chat/MultiChatDialog.vue';
export default {
name: 'App',
@@ -63,6 +65,7 @@ export default {
ImprintDialog,
ShowImageDialog,
MessageDialog,
MultiChatDialog,
},
created() {
this.$i18n.locale = this.$store.getters.language;

View File

@@ -0,0 +1,6 @@
import apiClient from "@/utils/axios.js";
export const fetchPublicRooms = async () => {
const response = await apiClient.get("/api/chat/rooms");
return response.data; // expecting array of { id, title, ... }
};

View File

@@ -29,10 +29,14 @@ export default {
...mapState(['daemonSocket']),
},
mounted() {
this.daemonSocket.addEventListener('workerStatus', () => { console.log('----'); });
if (this.daemonSocket && this.daemonSocket.addEventListener) {
this.daemonSocket.addEventListener('workerStatus', this.handleDaemonMessage);
}
},
beforeUnmount() {
this.daemonSocket.removeEventListener('workerStatus', this.handleDaemonMessage);
if (this.daemonSocket && this.daemonSocket.removeEventListener) {
this.daemonSocket.removeEventListener('workerStatus', this.handleDaemonMessage);
}
},
methods: {
openImprintDialog() {
@@ -48,7 +52,9 @@ export default {
this.$store.dispatch('dialogs/toggleDialogMinimize', dialogName);
},
async showFalukantDaemonStatus() {
this.daemonSocket.send('{"event": "getWorkerStatus"}');
if (this.daemonSocket && this.daemonSocket.send) {
this.daemonSocket.send('{"event": "getWorkerStatus"}');
}
},
handleDaemonMessage(event) {
const status = JSON.parse(event.data);

View File

@@ -110,6 +110,7 @@ import { createApp } from 'vue';
import apiClient from '@/utils/axios.js';
import RandomChatDialog from '../dialogues/chat/RandomChatDialog.vue';
import MultiChatDialog from '../dialogues/chat/MultiChatDialog.vue';
// Wichtig: die zentrale Instanzen importieren
import store from '@/store';
@@ -119,7 +120,8 @@ import i18n from '@/i18n';
export default {
name: 'AppNavigation',
components: {
RandomChatDialog
RandomChatDialog,
MultiChatDialog
},
data() {
return {
@@ -160,6 +162,22 @@ export default {
methods: {
...mapActions(['loadMenu', 'logout']),
openMultiChat() {
// Räume können später dynamisch geladen werden, hier als Platzhalter ein Beispiel:
const exampleRooms = [
{ id: 1, title: 'Allgemein' },
{ id: 2, title: 'Rollenspiel' }
];
const ref = this.$root.$refs.multiChatDialog;
if (ref && typeof ref.open === 'function') {
ref.open(exampleRooms);
} else if (ref?.$refs?.dialog && typeof ref.$refs.dialog.open === 'function') {
ref.$refs.dialog.open();
} else {
console.error('MultiChatDialog nicht bereit oder ohne open()');
}
},
async fetchForums() {
try {
const res = await apiClient.get('/api/forum');
@@ -192,8 +210,6 @@ export default {
// Datei erstellen und ans body anhängen
const container = document.createElement('div');
document.body.appendChild(container);
// Programmatisch ein neues App-Instance randomChatauen, mit Store, Router & i18n
this.$root.$refs.randomChatDialog.open(contact);
},
/**
@@ -211,7 +227,19 @@ export default {
// 2) view → Dialog/Window
if (item.view) {
this.$root.$refs[item.class].open();
const dialogRef = this.$root.$refs[item.class];
if (!dialogRef) {
console.error(`Dialog-Ref '${item.class}' nicht gefunden! Bitte prüfe Ref und Menü-Konfiguration.`);
return;
}
// Robust öffnen: erst open(), sonst auf inneres DialogWidget zurückgreifen
if (typeof dialogRef.open === 'function') {
dialogRef.open();
} else if (dialogRef.$refs?.dialog && typeof dialogRef.$refs.dialog.open === 'function') {
dialogRef.$refs.dialog.open();
} else {
console.error(`Dialog '${item.class}' gefunden, aber keine open()-Methode verfügbar.`);
}
return;
}

View File

@@ -1,5 +1,5 @@
<template>
<DialogWidget ref="dialog" title="passwordReset.title" :show-close=true :buttons="buttons" @close="closeDialog" name="PasswordReset">
<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>
@@ -18,7 +18,7 @@ export default {
data() {
return {
email: '',
buttons: [{ text: this.$t("passwordReset.reset") }]
buttons: [{ text: 'passwordReset.reset', action: 'reset' }]
};
},
methods: {

View File

@@ -0,0 +1,148 @@
<template>
<DialogWidget ref="dialog" :title="$t('chat.multichat.title')" :modal="true" width="40em" height="32em" name="MultiChat">
<div class="multi-chat-top">
<select v-model="selectedRoom" class="room-select">
<option v-for="room in rooms" :key="room.id" :value="room.id">{{ room.title }}</option>
</select>
<div class="options-popdown">
<label>
<input type="checkbox" v-model="autoscroll" />
{{ $t('chat.multichat.autoscroll') }}
</label>
<!-- Weitere Optionen können hier ergänzt werden -->
</div>
</div>
<div class="multi-chat-output" ref="output" @mouseenter="mouseOverOutput = true" @mouseleave="mouseOverOutput = false">
<div v-for="msg in messages" :key="msg.id" class="chat-message">
<span class="user">{{ msg.user }}:</span> <span class="text">{{ msg.text }}</span>
</div>
</div>
<div class="multi-chat-input">
<input v-model="input" @keyup.enter="sendMessage" class="chat-input" :placeholder="$t('chat.multichat.placeholder')" />
<button @click="sendMessage" class="send-btn">{{ $t('chat.multichat.send') }}</button>
<button @click="shout" class="mini-btn">{{ $t('chat.multichat.shout') }}</button>
<button @click="action" class="mini-btn">{{ $t('chat.multichat.action') }}</button>
<button @click="roll" class="mini-btn">{{ $t('chat.multichat.roll') }}</button>
</div>
</DialogWidget>
</template>
<script>
import DialogWidget from '@/components/DialogWidget.vue';
export default {
name: 'MultiChat',
components: { DialogWidget },
data() {
return {
rooms: [],
selectedRoom: null,
autoscroll: true,
mouseOverOutput: false,
messages: [],
input: ''
};
},
watch: {
messages() {
this.$nextTick(this.handleAutoscroll);
},
autoscroll(val) {
if (val) this.handleAutoscroll();
}
},
methods: {
open(rooms = []) {
this.rooms = rooms;
this.selectedRoom = rooms.length ? rooms[0].id : null;
this.autoscroll = true;
this.messages = [];
this.input = '';
this.$refs.dialog.open();
},
handleAutoscroll() {
if (this.autoscroll && !this.mouseOverOutput) {
const out = this.$refs.output;
if (out) out.scrollTop = out.scrollHeight;
}
},
sendMessage() {
if (!this.input.trim()) return;
this.messages.push({ id: Date.now(), user: 'Ich', text: this.input });
this.input = '';
},
shout() {
// Schreien-Logik
},
action() {
// Aktion-Logik
},
roll() {
// Würfeln-Logik
}
}
};
</script>
<style scoped>
.multi-chat-top {
display: flex;
align-items: center;
gap: 1em;
margin-bottom: 0.5em;
}
.room-select {
min-width: 10em;
}
.options-popdown {
background: #f5f5f5;
border-radius: 4px;
padding: 0.3em 0.8em;
font-size: 0.95em;
}
.multi-chat-output {
background: #222;
color: #fff;
height: 16em;
overflow-y: auto;
margin-bottom: 0.5em;
padding: 0.7em;
border-radius: 4px;
font-size: 1em;
}
.chat-message {
margin-bottom: 0.3em;
}
.user {
font-weight: bold;
color: #90caf9;
}
.multi-chat-input {
display: flex;
align-items: center;
gap: 0.5em;
}
.chat-input {
flex: 1;
padding: 0.4em 0.7em;
border-radius: 3px;
border: 1px solid #bbb;
}
.send-btn {
padding: 0.3em 1.1em;
border-radius: 3px;
background: #1976d2;
color: #fff;
border: none;
cursor: pointer;
}
.mini-btn {
padding: 0.2em 0.7em;
font-size: 0.95em;
border-radius: 3px;
background: #eee;
color: #333;
border: 1px solid #bbb;
cursor: pointer;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,26 +1,26 @@
<template>
<DialogWidget ref="dialog" title="randomchat.title" icon="dice24.png" :show-close="true" :buttons="buttons"
<DialogWidget ref="dialog" title="chat.randomchat.title" icon="dice24.png" :show-close="true" :buttons="buttons"
:modal="false" :isTitleTranslated="true" @close="closeDialog" name="RandomChat">
<div v-if="chatIsRunning" class="randomchat">
<div class="headline">
{{ $t("randomchat.agerange") }}
{{ $t("chat.randomchat.agerange") }}
<input type="number" v-model="agefromsearch" min="18" max="150" size="5" />
-
<input type="number" v-model="agetosearch" min="18" max="150" size="5" />
<span class="multiselect">
{{ $t("randomchat.gendersearch") }}
{{ $t("chat.randomchat.gendersearch") }}
<div>
<label><input type="checkbox" v-model="searchmale" />{{ $t("randomchat.gender.male") }}</label>
<label><input type="checkbox" v-model="searchfemale" />{{ $t("randomchat.gender.female")
<label><input type="checkbox" v-model="searchmale" />{{ $t("chat.randomchat.gender.male") }}</label>
<label><input type="checkbox" v-model="searchfemale" />{{ $t("chat.randomchat.gender.female")
}}</label>
</div>
</span>
<label><input type="checkbox" v-model="camonlysearch" />{{ $t("randomchat.camonly") }}</label>
<label><input type="checkbox" v-model="showcam" />{{ $t("randomchat.showcam") }}</label>
<img v-if="isLoggedIn" src="/images/icons/friendsadd16.png" :tooltip="$t('randomchat.addfriend')" />
<label><input type="checkbox" v-model="autosearch" />{{ $t("randomchat.autosearch") }}</label>
<button @click="nextUser" v-if="partner != null">{{ $t("randomchat.jumptonext") }}</button>
<button @click="startSearch" v-if="partner == null && !searching">{{ $t("randomchat.startsearch")
<label><input type="checkbox" v-model="camonlysearch" />{{ $t("chat.randomchat.camonly") }}</label>
<label><input type="checkbox" v-model="showcam" />{{ $t("chat.randomchat.showcam") }}</label>
<img v-if="isLoggedIn" src="/images/icons/friendsadd16.png" :tooltip="$t('chat.randomchat.addfriend')" />
<label><input type="checkbox" v-model="autosearch" />{{ $t("chat.randomchat.autosearch") }}</label>
<button @click="nextUser" v-if="partner != null">{{ $t("chat.randomchat.jumptonext") }}</button>
<button @click="startSearch" v-if="partner == null && !searching">{{ $t("chat.randomchat.startsearch")
}}</button>
</div>
<div class="output">
@@ -28,7 +28,7 @@
</div>
<div class="inputline">
<label>
{{ $t("randomchat.input") }}&nbsp;
{{ $t("chat.randomchat.input") }}&nbsp;
<input type="text" v-model="inputtext" @keyup.enter="sendMessage" />
</label>
<img src="/images/icons/enter16.png" @click="sendMessage" />
@@ -37,20 +37,20 @@
</div>
<div v-else>
<div>
<label>{{ $t("randomchat.age") }}
<label>{{ $t("chat.randomchat.age") }}
<input type="number" v-model="age" min="18" max="150" value="18" />
</label>
</div>
<div>
<label>{{ $t("randomchat.gender.title") }}
<label>{{ $t("chat.randomchat.gender.title") }}
<select v-model="gender">
<option value="f">{{ $t("randomchat.gender.female") }}</option>
<option value="m">{{ $t("randomchat.gender.male") }}</option>
<option value="f">{{ $t("chat.randomchat.gender.female") }}</option>
<option value="m">{{ $t("chat.randomchat.gender.male") }}</option>
</select>
</label>
</div>
<div>
<button @click="startRandomChat()">{{ $t("randomchat.start") }}</button>
<button @click="startRandomChat()">{{ $t("chat.randomchat.start") }}</button>
</div>
</div>
</DialogWidget>
@@ -60,6 +60,7 @@
import DialogWidget from '@/components/DialogWidget.vue';
import { mapGetters } from 'vuex';
import axios from 'axios';
import DOMPurify from 'dompurify';
export default {
name: 'RandomChatDialog',
@@ -69,7 +70,8 @@ export default {
computed: {
...mapGetters(['isLoggedIn', 'user']),
buttons() {
return [{ text: this.$t('randomchat.close') }];
// Use translation key; DialogWidget will translate when isTitleTranslated=true
return [{ text: 'chat.randomchat.close' }];
},
},
data() {
@@ -113,26 +115,6 @@ export default {
this.$refs.dialog.open();
},
async closeDialog() {
// ① Stoppe alle laufenden Intervalle
if (this.searchInterval) {
clearInterval(this.searchInterval);
this.searchInterval = null;
}
if (this.messagesInterval) {
clearInterval(this.messagesInterval);
this.messagesInterval = null;
}
// ② verlasse Chat auf Server-Seite
this.$refs.dialog.close();
await axios.post('/api/chat/exit', { id: this.userId });
await this.removeUserFromChat();
// Reset-Status
this.chatIsRunning = false;
this.partner = null;
this.messages = [];
},
async registerUser() {
try {
const response = await axios.post('/api/chat/register', {
@@ -146,9 +128,29 @@ export default {
},
async closeDialog() {
// Stop intervals first
if (this.searchInterval) {
clearInterval(this.searchInterval);
this.searchInterval = null;
}
if (this.messagesInterval) {
clearInterval(this.messagesInterval);
this.messagesInterval = null;
}
// Inform backend and cleanup
try {
if (this.userId) {
await axios.post('/api/chat/exit', { id: this.userId });
await this.removeUserFromChat();
}
} catch (e) {
// ignore
}
// Reset state and close widget
this.chatIsRunning = false;
this.partner = null;
this.messages = [];
this.$refs.dialog.close();
await axios.post('/api/chat/exit', { id: this.userId });
await this.removeUserFromChat();
},
async startRandomChat() {
@@ -160,7 +162,7 @@ export default {
async startSearch() {
this.searching = true;
await this.findMatch();
this.messages.push({ type: 'system', tr: 'randomchat.waitingForMatch' });
this.messages.push({ type: 'system', tr: 'chat.randomchat.waitingForMatch' });
this.searchInterval = setInterval(this.findMatch, 500);
},
@@ -174,10 +176,10 @@ export default {
if (response.data.status === 'matched') {
this.searching = false;
clearInterval(this.searchInterval);
const initText = this.$t('randomchat.chatpartner')
const initText = this.$t('chat.randomchat.chatpartner')
.replace(
'<gender>',
this.$t(`randomchat.partnergender${response.data.user.gender}`)
this.$t(`chat.randomchat.partnergender${response.data.user.gender}`)
)
.replace('<age>', response.data.user.age);
this.messages = [{ type: 'system', text: initText }];
@@ -220,7 +222,7 @@ export default {
activities.forEach((act) => {
if (act.activity === 'otheruserleft') {
this.partner = null;
this.messages.push({ type: 'system', tr: 'randomchat.userleftchat' });
this.messages.push({ type: 'system', tr: 'chat.randomchat.userleftchat' });
}
});
this.messages.push(...newMsgs);
@@ -237,10 +239,10 @@ export default {
async nextUser() {
await axios.post('/api/chat/leave', { id: this.userId });
this.partner = null;
this.messages.push({ type: 'system', tr: 'randomchat.selfstopped' });
this.messages.push({ type: 'system', tr: 'chat.randomchat.selfstopped' });
if (this.autosearch) {
this.searchInterval = setInterval(this.findMatch, 500);
this.messages.push({ type: 'system', tr: 'randomchat.waitingForMatch' });
this.messages.push({ type: 'system', tr: 'chat.randomchat.waitingForMatch' });
}
},
@@ -250,7 +252,7 @@ export default {
return `<span class="rc-system">${txt}</span>`;
}
const cls = message.type === 'self' ? 'rc-self' : 'rc-partner';
const who = message.type === 'self' ? this.$t('randomchat.self') : this.$t('randomchat.partner');
const who = message.type === 'self' ? this.$t('chat.randomchat.self') : this.$t('chat.randomchat.partner');
return `<span class="${cls}">${who}: </span>${message.text}`;
},
},

View File

@@ -67,7 +67,7 @@
<div v-for="entry in guestbookEntries" :key="entry.id" class="guestbook-entry">
<img v-if="entry.image" :src="entry.image.url" alt="Entry Image"
style="max-width: 400px; max-height: 400px;" />
<p v-html="entry.contentHtml"></p>
<p v-html="sanitizedContent(entry)"></p>
<div class="entry-info">
<span class="entry-timestamp">{{ new Date(entry.createdAt).toLocaleString() }}</span>
<span class="entry-user">
@@ -96,6 +96,7 @@ import apiClient from '@/utils/axios.js';
import FolderItem from '../../components/FolderItem.vue';
import { Editor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import DOMPurify from 'dompurify';
export default {
name: 'UserProfileDialog',
@@ -369,7 +370,10 @@ export default {
} else {
this.friendshipState = 'waiting';
}
}
},
sanitizedContent(entry) {
return DOMPurify.sanitize(entry.contentHtml);
},
}
};
</script>

View File

@@ -11,13 +11,14 @@
@ok="handleOk"
name="DataPrivacyDialog"
>
<div v-html="dataPrivacyContent"></div>
<div v-html="sanitizedContent"></div>
</DialogWidget>
</template>
<script>
import DialogWidget from '../../components/DialogWidget.vue';
import content from '../../content/content.js';
import DOMPurify from 'dompurify';
export default {
name: 'DataPrivacyDialog',
@@ -29,6 +30,11 @@ export default {
dataPrivacyContent: content.dataPrivacy[this.$i18n.locale]
};
},
computed: {
sanitizedContent() {
return DOMPurify.sanitize(this.dataPrivacyContent);
}
},
watch: {
'$i18n.locale'(newLocale) {
this.dataPrivacyContent = content.dataPrivacy[newLocale];

View File

@@ -11,13 +11,14 @@
@ok="handleOk"
name="ImprintDialog"
>
<div v-html="imprintContent"></div>
<div v-html="sanitizedContent"></div>
</DialogWidget>
</template>
<script>
import DialogWidget from '../../components/DialogWidget.vue';
import content from '../../content/content.js';
import DOMPurify from 'dompurify';
export default {
name: 'ImprintDialog',
@@ -29,6 +30,11 @@ export default {
imprintContent: content.imprint[this.$i18n.locale]
};
},
computed: {
sanitizedContent() {
return DOMPurify.sanitize(this.imprintContent);
}
},
watch: {
'$i18n.locale'(newLocale) {
this.imprintContent = content.imprint[newLocale];

View File

@@ -14,6 +14,7 @@ import enAdmin from './locales/en/admin.json';
import enSocialNetwork from './locales/en/socialnetwork.json';
import enFriends from './locales/en/friends.json';
import enFalukant from './locales/en/falukant.json';
import enPasswordReset from './locales/en/passwordReset.json';
import deGeneral from './locales/de/general.json';
import deHeader from './locales/de/header.json';
@@ -28,6 +29,7 @@ import deAdmin from './locales/de/admin.json';
import deSocialNetwork from './locales/de/socialnetwork.json';
import deFriends from './locales/de/friends.json';
import deFalukant from './locales/de/falukant.json';
import dePasswordReset from './locales/de/passwordReset.json';
const messages = {
en: {
@@ -37,6 +39,7 @@ const messages = {
...enHome,
...enChat,
...enRegister,
...enPasswordReset,
...enError,
...enActivate,
...enSettings,
@@ -53,6 +56,7 @@ const messages = {
...deHome,
...deChat,
...deRegister,
...dePasswordReset,
...deError,
...deActivate,
...deSettings,

View File

@@ -91,7 +91,8 @@
"start game": "Spiel starten",
"open room": "Raum öffnen",
"systemmessage": "Systemnachricht"
}
},
"confirmDelete": "Soll dieser Chatraum wirklich gelöscht werden?"
}
}
}

View File

@@ -1,30 +1,72 @@
{
"randomchat": {
"title": "Zufallschat",
"age": "Alter",
"gender": {
"title": "Dein Geschlecht",
"male": "Männlich",
"female": "Weiblich"
"chat": {
"multichat": {
"title": "Multi-Chat",
"autoscroll": "Automatisch scrollen",
"options": "Optionen",
"send": "Senden",
"shout": "Schreien",
"action": "Aktion",
"roll": "Würfeln",
"colorpicker": "Farbe wählen",
"colorpicker_preview": "Vorschau: Diese Nachricht nutzt die gewählte Farbe.",
"hex": "HEX",
"invalid_hex": "Ungültiger Hex-Wert",
"hue": "Farbton",
"saturation": "Sättigung",
"lightness": "Helligkeit",
"ok": "Ok",
"cancel": "Abbrechen",
"placeholder": "Nachricht eingeben...",
"action_select_user": "Bitte Benutzer auswählen",
"action_to": "Aktion an {to}",
"action_phrases": {
"left_room": "wechselt zu Raum",
"leaves_room": "verlässt Raum",
"left_chat": "hat den Chat verlassen."
},
"system": {
"room_entered": "Du hast den Raum \"{room}\" betreten.",
"user_entered_room": "{user} hat den Raum betreten.",
"user_left_room": "{user} hat den Raum verlassen."
,
"color_changed_self": "Du hast deine Farbe zu {color} geändert.",
"color_changed_user": "{user} hat seine/ihre Farbe zu {color} geändert."
},
"status": {
"connecting": "Verbinden…",
"connected": "Verbunden",
"disconnected": "Getrennt",
"error": "Fehler bei der Verbindung"
}
},
"start": "Loslegen",
"agerange": "Alter",
"gendersearch": "Geschlechter",
"camonly": "Nur mit Cam",
"showcam": "Eigene Cam anzeigen",
"addfriend": "Zu Freunden hinzufügen",
"close": "Chat beenden",
"autosearch": "Automatisch suchen",
"input": "Ihr Text",
"waitingForMatch": "Warten auf einen Teilnehmer...",
"chatpartner": "Du chattest jetzt mit einer <gender> Person im Alter von <age> Jahren.",
"partnergenderm": "männlichen",
"partnergenderf": "weiblichen",
"self": "Du",
"partner": "Partner",
"jumptonext": "Diesen Chat beenden",
"userleftchat": "Der Gesprächstpartner hat den Chat verlassen.",
"startsearch": "Suche nächstes Gespräch",
"selfstopped": "Du hast das Gespräch verlassen."
"randomchat": {
"title": "Zufallschat",
"age": "Alter",
"gender": {
"title": "Dein Geschlecht",
"male": "Männlich",
"female": "Weiblich"
},
"start": "Loslegen",
"agerange": "Alter",
"gendersearch": "Geschlechter",
"camonly": "Nur mit Cam",
"showcam": "Eigene Cam anzeigen",
"addfriend": "Zu Freunden hinzufügen",
"close": "Chat beenden",
"autosearch": "Automatisch suchen",
"input": "Ihr Text",
"waitingForMatch": "Warten auf einen Teilnehmer...",
"chatpartner": "Du chattest jetzt mit einer <gender> Person im Alter von <age> Jahren.",
"partnergenderm": "männlichen",
"partnergenderf": "weiblichen",
"self": "Du",
"partner": "Partner",
"jumptonext": "Diesen Chat beenden",
"userleftchat": "Der Gesprächstpartner hat den Chat verlassen.",
"startsearch": "Suche nächstes Gespräch",
"selfstopped": "Du hast das Gespräch verlassen."
}
}
}

View File

@@ -41,5 +41,12 @@
"transmale": "Trans-Mann",
"transfemale": "Trans-Frau",
"nonbinary": "Nichtbinär"
},
"common": {
"edit": "Bearbeiten",
"delete": "Löschen",
"create": "Erstellen",
"yes": "Ja",
"no": "Nein"
}
}

View File

@@ -0,0 +1,9 @@
{
"passwordReset": {
"title": "Passwort zurücksetzen",
"email": "E-Mail",
"reset": "Zurücksetzen",
"success": "Falls die E-Mail existiert, wurde eine Anleitung zum Zurücksetzen gesendet.",
"failure": "Passwort-Zurücksetzen fehlgeschlagen. Bitte später erneut versuchen."
}
}

View File

@@ -1,5 +1,46 @@
{
"randomchat": {
"chat": {
"multichat": {
"title": "Multi Chat",
"autoscroll": "Auto scroll",
"options": "Options",
"send": "Send",
"shout": "Shout",
"action": "Action",
"roll": "Roll",
"colorpicker": "Pick color",
"colorpicker_preview": "Preview: This message uses the chosen color.",
"hex": "HEX",
"invalid_hex": "Invalid hex value",
"hue": "Hue",
"saturation": "Saturation",
"lightness": "Lightness",
"ok": "Ok",
"cancel": "Cancel",
"placeholder": "Type a message…",
"action_select_user": "Please select a user",
"action_to": "Action to {to}",
"action_phrases": {
"left_room": "switches to room",
"leaves_room": "leaves room",
"left_chat": "has left the chat."
},
"system": {
"room_entered": "You entered the room \"{room}\".",
"user_entered_room": "{user} has entered the room.",
"user_left_room": "{user} has left the room."
,
"color_changed_self": "You changed your color to {color}.",
"color_changed_user": "{user} changed their color to {color}."
},
"status": {
"connecting": "Connecting…",
"connected": "Connected",
"disconnected": "Disconnected",
"error": "Connection error"
}
},
"randomchat": {
"title": "Random Chat",
"close": "Close",
"age": "Age",
@@ -16,6 +57,16 @@
"autosearch": "Auto Search",
"input": "Input",
"start": "Start",
"waitingForMatch": "Waiting for a match..."
"waitingForMatch": "Waiting for a match...",
"chatpartner": "You are now chatting with a <gender> person aged <age> years.",
"partnergenderm": "male",
"partnergenderf": "female",
"self": "You",
"partner": "Partner",
"jumptonext": "End this chat",
"userleftchat": "The chat partner has left the chat.",
"startsearch": "Search next conversation",
"selfstopped": "You left the conversation."
}
}
}

View File

@@ -0,0 +1,9 @@
{
"passwordReset": {
"title": "Reset Password",
"email": "Email",
"reset": "Reset",
"success": "If the email exists, we've sent reset instructions.",
"failure": "Password reset failed. Please try again later."
}
}

View File

@@ -1,6 +1,6 @@
import AdminInterestsView from '../views/admin/InterestsView.vue';
import AdminContactsView from '../views/admin/ContactsView.vue';
import ChatRoomsView from '../views/admin/ChatRoomsView.vue';
import RoomsView from '../views/admin/RoomsView.vue';
import ForumAdminView from '../dialogues/admin/ForumAdminView.vue';
import AdminFalukantEditUserView from '../views/admin/falukant/EditUserView.vue'
@@ -26,7 +26,7 @@ const adminRoutes = [
{
path: '/admin/chatrooms',
name: 'AdminChatRooms',
component: ChatRoomsView,
component: RoomsView,
meta: { requiresAuth: true }
},
{

View File

@@ -0,0 +1,87 @@
// Small helper to resolve the Chat WebSocket URL from env or sensible defaults
export function getChatWsUrl() {
// Prefer explicit env var
const override = (typeof window !== 'undefined' && window.localStorage) ? window.localStorage.getItem('chatWsOverride') : '';
if (override && typeof override === 'string' && override.trim()) {
return override.trim();
}
const envUrl = import.meta?.env?.VITE_CHAT_WS_URL;
if (envUrl && typeof envUrl === 'string' && envUrl.trim()) {
return envUrl.trim();
}
// Fallback: use current origin host with ws/wss and default port/path if provided by backend
const isHttps = typeof window !== 'undefined' && window.location.protocol === 'https:';
const proto = isHttps ? 'wss' : 'ws';
// If a reverse proxy exposes the chat at a path, you can change '/chat' here.
const host = typeof window !== 'undefined' ? window.location.hostname : 'localhost';
const port = (typeof window !== 'undefined' && window.location.port) ? `:${window.location.port}` : '';
// On localhost, prefer dedicated chat port 1235 by default
// Prefer IPv4 for localhost to avoid browsers resolving to ::1 (IPv6) where the server may not listen
if (host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]') {
return `${proto}://127.0.0.1:1235`;
}
// Default to same origin (no hardcoded chat port). Adjust via VITE_CHAT_WS_URL if needed.
const defaultUrl = `${proto}://${host}${port}`;
return defaultUrl;
}
// Provide a list of candidate WS URLs to try, in order of likelihood.
export function getChatWsCandidates() {
const override = (typeof window !== 'undefined' && window.localStorage) ? window.localStorage.getItem('chatWsOverride') : '';
if (override && typeof override === 'string' && override.trim()) {
return [override.trim()];
}
const envUrl = import.meta?.env?.VITE_CHAT_WS_URL;
if (envUrl && typeof envUrl === 'string' && envUrl.trim()) {
return [envUrl.trim()];
}
const isHttps = typeof window !== 'undefined' && window.location.protocol === 'https:';
const proto = isHttps ? 'wss' : 'ws';
const host = typeof window !== 'undefined' ? window.location.hostname : 'localhost';
const port = (typeof window !== 'undefined' && window.location.port) ? `:${window.location.port}` : '';
const candidates = [];
// Common local setups: include IPv4 and IPv6 loopback variants (root only)
if (host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]') {
// Prefer IPv6 loopback first when available
const localHosts = ['[::1]', '127.0.0.1', 'localhost'];
for (const h of localHosts) {
const base = `${proto}://${h}:1235`;
candidates.push(base);
candidates.push(`${base}/`);
}
}
// Same-origin root and common WS paths
const sameOriginBases = [`${proto}://${host}${port}`];
// If localhost-ish, also try 127.0.0.1 for same-origin port
if ((host === 'localhost' || host === '::1' || host === '[::1]') && port) {
sameOriginBases.push(`${proto}://[::1]${port}`);
sameOriginBases.push(`${proto}://127.0.0.1${port}`);
}
for (const base of sameOriginBases) {
candidates.push(base);
candidates.push(`${base}/`);
}
return candidates;
}
// Return optional subprotocols for the WebSocket handshake.
export function getChatWsProtocols() {
try {
const ls = (typeof window !== 'undefined' && window.localStorage) ? window.localStorage.getItem('chatWsProtocols') : '';
if (ls && ls.trim()) {
// Accept JSON array or comma-separated
if (ls.trim().startsWith('[')) return JSON.parse(ls);
return ls.split(',').map(s => s.trim()).filter(Boolean);
}
} catch (_) {}
const env = import.meta?.env?.VITE_CHAT_WS_PROTOCOLS;
if (env && typeof env === 'string' && env.trim()) {
try {
if (env.trim().startsWith('[')) return JSON.parse(env);
} catch (_) {}
return env.split(',').map(s => s.trim()).filter(Boolean);
}
// Default to the 'chat' subprotocol so the server can gate connections accordingly
return ['chat'];
}

View File

@@ -0,0 +1,87 @@
// Centralized config for YourChat protocol mapping and WS endpoint
// Override via .env (VITE_* variables)
const env = import.meta.env || {};
export const CHAT_WS_URL = env.VITE_CHAT_WS_URL
|| (env.VITE_CHAT_WS_HOST || env.VITE_CHAT_WS_PORT
? `ws://${env.VITE_CHAT_WS_HOST || 'localhost'}:${env.VITE_CHAT_WS_PORT || '1235'}`
: `ws://localhost:1235`);
// Event/type keys
export const CHAT_EVENT_KEY = env.VITE_CHAT_EVENT_KEY || 'type';
export const CHAT_EVT_TOKEN = env.VITE_CHAT_EVT_TOKEN || 'token';
export const CHAT_EVT_AUTH = env.VITE_CHAT_EVT_AUTH || 'auth'; // optional, YourChat nutzt Token
export const CHAT_EVT_JOIN = env.VITE_CHAT_EVT_JOIN || 'join';
export const CHAT_EVT_MESSAGE = env.VITE_CHAT_EVT_MESSAGE || 'message';
export const CHAT_EVT_SYSTEM = env.VITE_CHAT_EVT_SYSTEM || 'system';
// Field names for payloads
export const CHAT_JOIN_ROOM_KEY = env.VITE_CHAT_JOIN_ROOM_KEY || 'newroom';
export const CHAT_JOIN_LEAVE_ROOM_KEY = env.VITE_CHAT_JOIN_LEAVE_ROOM_KEY || 'password'; // YourChat erwartet password Feld; leave optional nicht vorgesehen
export const CHAT_MESSAGE_ROOM_KEY = env.VITE_CHAT_MESSAGE_ROOM_KEY || null; // YourChat braucht kein roomId im message payload
export const CHAT_MSG_TEXT_KEY = env.VITE_CHAT_MSG_TEXT_KEY || 'message';
export const CHAT_MSG_FROM_KEY = env.VITE_CHAT_MSG_FROM_KEY || 'userName';
export const CHAT_MSG_ID_KEY = env.VITE_CHAT_MSG_ID_KEY || 'id';
export const CHAT_TOKEN_KEY = env.VITE_CHAT_TOKEN_KEY || 'token';
// Auth payload mapping
export const CHAT_AUTH_PAYLOAD_KEY = env.VITE_CHAT_AUTH_PAYLOAD_KEY || 'data';
export const CHAT_AUTH_USER_ID_KEY = env.VITE_CHAT_AUTH_USER_ID_KEY || 'userId';
export const CHAT_AUTH_AUTHCODE_KEY = env.VITE_CHAT_AUTH_AUTHCODE_KEY || 'authCode';
export function buildAuthPayload(user) {
const payload = {};
payload[CHAT_EVENT_KEY] = CHAT_EVT_AUTH;
const dataObj = {};
if (user?.id != null) dataObj[CHAT_AUTH_USER_ID_KEY] = user.id;
if (user?.authCode != null) dataObj[CHAT_AUTH_AUTHCODE_KEY] = user.authCode;
payload[CHAT_AUTH_PAYLOAD_KEY] = dataObj;
return payload;
}
export function buildJoinPayload(roomName, password = '') {
const payload = {};
payload[CHAT_EVENT_KEY] = CHAT_EVT_JOIN;
if (roomName != null) payload[CHAT_JOIN_ROOM_KEY] = roomName;
if (CHAT_JOIN_LEAVE_ROOM_KEY) payload[CHAT_JOIN_LEAVE_ROOM_KEY] = password;
return payload;
}
export function buildMessagePayload(text) {
const payload = {};
payload[CHAT_EVENT_KEY] = CHAT_EVT_MESSAGE;
if (CHAT_MESSAGE_ROOM_KEY && typeof CHAT_MESSAGE_ROOM_KEY === 'string') payload[CHAT_MESSAGE_ROOM_KEY] = null;
payload[CHAT_MSG_TEXT_KEY] = text;
return payload;
}
export function parseIncomingMessage(data) {
// Try JSON, else return as system text
let obj = data;
if (typeof data === 'string') {
try { obj = JSON.parse(data); } catch (_) { return { type: 'system', text: String(data) }; }
}
const type = obj[CHAT_EVENT_KEY] || obj.type;
if (type === CHAT_EVT_TOKEN) {
return { type: 'token', token: obj.message };
}
if (type === CHAT_EVT_MESSAGE || type === 'message') {
return {
type: 'message',
id: obj[CHAT_MSG_ID_KEY] || Date.now(),
from: obj[CHAT_MSG_FROM_KEY] || 'User',
text: obj[CHAT_MSG_TEXT_KEY] || ''
};
}
if (type === CHAT_EVT_SYSTEM || type === 'system') {
return {
type: 'system',
id: obj[CHAT_MSG_ID_KEY] || Date.now(),
text: obj[CHAT_MSG_TEXT_KEY] || ''
};
}
// Fallback: unknown event -> show raw
return { type: 'system', id: Date.now(), text: JSON.stringify(obj) };
}

View File

@@ -1,175 +0,0 @@
<template>
<DialogWidget ref="dialog" :title="$t(room && room.id ? 'admin.chatrooms.edit' : 'admin.chatrooms.create')"
:show-close="true" :buttons="buttons" name="RoomDialog" :modal="true" :isTitleTranslated="true"
@close="closeDialog">
<form class="dialog-form" @submit.prevent="save">
<label>
{{ $t('admin.chatrooms.title') }}
<input v-model="localRoom.title" required />
</label>
<label>
{{ $t('admin.chatrooms.type') }}
<select v-model="localRoom.roomTypeId" required>
<option v-for="type in roomTypes" :key="type.id" :value="type.id">{{ type.tr }}</option>
</select>
</label>
<label>
<input type="checkbox" v-model="localRoom.isPublic" />
{{ $t('admin.chatrooms.isPublic') }}
</label>
<label>
<input type="checkbox" v-model="showGenderRestriction" />
{{ $t('admin.chatrooms.genderRestriction.show') }}
</label>
<label v-if="showGenderRestriction">
{{ $t('admin.chatrooms.genderRestriction.label') }}
<select v-model="localRoom.genderRestrictionId">
<option v-for="g in genderRestrictions" :key="g.id" :value="g.id">{{ g.tr }}</option>
</select>
</label>
<label>
<input type="checkbox" v-model="showMinAge" />
{{ $t('admin.chatrooms.minAge.show') }}
</label>
<label v-if="showMinAge">
{{ $t('admin.chatrooms.minAge.label') }}
<input v-model.number="localRoom.minAge" type="number" />
</label>
<label>
<input type="checkbox" v-model="showMaxAge" />
{{ $t('admin.chatrooms.maxAge.show') }}
</label>
<label v-if="showMaxAge">
{{ $t('admin.chatrooms.maxAge.label') }}
<input v-model.number="localRoom.maxAge" type="number" />
</label>
<label>
<input type="checkbox" v-model="showPassword" />
{{ $t('admin.chatrooms.password.show') }}
</label>
<label v-if="showPassword">
{{ $t('admin.chatrooms.password.label') }}
<input v-model="localRoom.password" type="password" />
</label>
<label>
<input type="checkbox" v-model="localRoom.friendsOfOwnerOnly" />
{{ $t('admin.chatrooms.friendsOfOwnerOnly') }}
</label>
<label>
<input type="checkbox" v-model="showRequiredUserRight" />
{{ $t('admin.chatrooms.requiredUserRight.show') }}
</label>
<label v-if="showRequiredUserRight">
{{ $t('admin.chatrooms.requiredUserRight.label') }}
<select v-model="localRoom.requiredUserRightId">
<option v-for="r in userRights" :key="r.id" :value="r.id">{{ r.tr }}</option>
</select>
</label>
</form>
</DialogWidget>
</template>
<script>
import DialogWidget from '@/components/DialogWidget.vue';
export default {
name: 'RoomDialog',
props: {
modelValue: Boolean,
room: Object
},
data() {
return {
dialog: null,
localRoom: this.room ? { ...this.room } : { title: '', isPublic: true },
roomTypes: [],
genderRestrictions: [],
userRights: [],
showGenderRestriction: !!(this.room && this.room.genderRestrictionId),
showMinAge: !!(this.room && this.room.minAge),
showMaxAge: !!(this.room && this.room.maxAge),
showPassword: !!(this.room && this.room.password),
showRequiredUserRight: !!(this.room && this.room.requiredUserRightId),
buttons: [
{ text: 'common.save', action: this.save },
{ text: 'common.cancel', action: this.closeDialog }
]
},
watch: {
room: {
handler(newVal) {
this.localRoom = newVal ? { ...newVal } : { title: '', isPublic: true };
this.showGenderRestriction = !!(newVal && newVal.genderRestrictionId);
this.showMinAge = !!(newVal && newVal.minAge);
this.showMaxAge = !!(newVal && newVal.maxAge);
this.showPassword = !!(newVal && newVal.password);
this.showRequiredUserRight = !!(newVal && newVal.requiredUserRightId);
},
immediate: true
}
},
mounted() {
this.dialog = this.$refs.dialog;
this.fetchRoomTypes();
this.fetchGenderRestrictions();
this.fetchUserRights();
},
methods: {
async fetchRoomTypes() {
// API-Call, z.B. this.roomTypes = await apiClient.get('/api/room-types')
},
async fetchGenderRestrictions() {
// API-Call, z.B. this.genderRestrictions = await apiClient.get('/api/gender-restrictions')
},
async fetchUserRights() {
// API-Call, z.B. this.userRights = await apiClient.get('/api/user-rights')
},
open(roomData) {
this.localRoom = roomData ? { ...roomData } : { title: '', isPublic: true };
this.dialog.open();
},
closeDialog() {
this.dialog.close();
},
save() {
this.$emit('save', this.localRoom);
this.closeDialog();
}
}
}
</script>
<style scoped>
.room-dialog-content {
padding: 16px;
}
.dialog-title {
font-weight: bold;
font-size: 1.2em;
margin-bottom: 12px;
display: block;
}
.dialog-fields > * {
margin-bottom: 8px;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
}
.save-btn, .cancel-btn {
padding: 6px 18px;
border: none;
border-radius: 3px;
background: #eee;
cursor: pointer;
}
.save-btn {
background: #1976d2;
color: #fff;
}
.cancel-btn {
background: #eee;
color: #333;
}
</style>

View File

@@ -0,0 +1,113 @@
<template>
<div class="admin-chat-rooms">
<h2>{{ $t('admin.chatrooms.title') }}</h2>
<button class="create-btn" @click="openCreateDialog">{{ $t('admin.chatrooms.create') }}</button>
<table class="rooms-table">
<thead>
<tr>
<th>{{ $t('admin.chatrooms.roomName') }}</th>
<th>{{ $t('admin.chatrooms.type') }}</th>
<th>{{ $t('admin.chatrooms.isPublic') }}</th>
<th>{{ $t('admin.chatrooms.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="room in rooms" :key="room.id">
<td>{{ room.title }}</td>
<td>{{ room.roomTypeTr || room.roomTypeId }}</td>
<td>{{ room.isPublic ? $t('common.yes') : $t('common.no') }}</td>
<td>
<button @click="editRoom(room)">{{ $t('common.edit') }}</button>
<button @click="deleteRoom(room)">{{ $t('common.delete') }}</button>
</td>
</tr>
</tbody>
</table>
<RoomDialog ref="roomDialog" :room="selectedRoom" @save="saveRoom" />
<ChooseDialog ref="chooseDialog" />
</div>
</template>
<script>
import RoomDialog from '@/dialogues/admin/RoomDialog.vue';
import ChooseDialog from '@/dialogues/standard/ChooseDialog.vue';
import apiClient from '@/utils/axios.js';
export default {
name: 'RoomsView',
components: { RoomDialog, ChooseDialog },
data() {
return {
rooms: [],
selectedRoom: null,
}
},
mounted() {
this.fetchRooms();
},
methods: {
openCreateDialog() {
this.selectedRoom = null;
this.$refs.roomDialog.open();
},
editRoom(room) {
this.selectedRoom = { ...room };
this.$refs.roomDialog.open(this.selectedRoom);
},
async deleteRoom(room) {
if (!room.id) return;
const confirmed = await this.$refs.chooseDialog.open({
title: this.$t('common.confirm'),
message: this.$t('admin.chatrooms.confirmDelete')
});
if (!confirmed) return;
await apiClient.delete(`/api/admin/chat/rooms/${room.id}`);
this.fetchRooms();
},
async fetchRooms() {
const res = await apiClient.get('/api/admin/chat/rooms');
this.rooms = res.data;
},
async saveRoom(roomData) {
// Remove forbidden and associated object fields before sending to backend
const { id, ownerId, passwordHash, roomType, genderRestriction, ...cleanData } = roomData;
if (roomData.id) {
await apiClient.put(`/api/admin/chat/rooms/${roomData.id}`, cleanData);
} else {
await apiClient.post('/api/admin/chat/rooms', cleanData);
}
this.fetchRooms();
},
},
}
</script>
<style scoped>
.admin-chat-rooms {
max-width: 900px;
margin: 0 auto;
}
.create-btn {
margin-bottom: 12px;
padding: 6px 18px;
border: none;
border-radius: 3px;
background: #1976d2;
color: #fff;
cursor: pointer;
}
.rooms-table {
width: 100%;
border-collapse: collapse;
}
.rooms-table th, .rooms-table td {
border: 1px solid #ddd;
padding: 8px;
}
.rooms-table th {
background: #f5f5f5;
}
.rooms-table td button {
margin-right: 6px;
}
</style>

View File

@@ -68,13 +68,16 @@ export default {
methods: {
...mapActions(['login']),
openRandomChat() {
this.$refs.randomChatDialog.open();
const dlg = this.$refs.randomChatDialog;
if (dlg && typeof dlg.open === 'function') dlg.open();
},
openRegisterDialog() {
this.$refs.registerDialog.open();
const dlg = this.$refs.registerDialog;
if (dlg && typeof dlg.open === 'function') dlg.open();
},
openPasswordResetDialog() {
this.$refs.passwordResetDialog.open();
const dlg = this.$refs.passwordResetDialog;
if (dlg && typeof dlg.open === 'function') dlg.open();
},
focusPassword() {
this.$refs.passwordInput.focus();

View File

@@ -14,7 +14,7 @@
<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="entry.text"></p>
<p v-html="sanitizedText(entry)"></p>
<div class="entry-info">
<span class="entry-timestamp">{{ new Date(entry.createdAt).toLocaleString() }}</span>
<span class="entry-actions">
@@ -39,6 +39,7 @@
import { mapGetters } from 'vuex';
import apiClient from '@/utils/axios.js';
import ChooseDialog from "@/dialogues/standard/ChooseDialog.vue";
import DOMPurify from 'dompurify';
export default {
name: 'DiaryView',
@@ -59,6 +60,9 @@ export default {
...mapGetters(['user']),
},
methods: {
sanitizedText(entry) {
return DOMPurify.sanitize(entry.text);
},
async loadDiaryEntries(page) {
try {
console.log(page);

View File

@@ -3,7 +3,7 @@
<h3 v-if="forumTopic">{{ forumTopic }}</h3>
<ul class="messages">
<li v-for="message in messages" :key="message.id">
<div v-html="message.text"></div>
<div v-html="sanitizedMessage(message)"></div>
<div class="footer">
<span class="link" @click="openProfile(message.lastMessageUser.hashedId)">
{{ message.lastMessageUser.username }}
@@ -23,6 +23,7 @@
import { Editor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import apiClient from '../../utils/axios'
import DOMPurify from 'dompurify'
export default {
name: 'ForumTopicView',
@@ -87,6 +88,9 @@ export default {
},
openForum() {
this.$router.push(`/socialnetwork/forum/${this.forumId}`);
},
sanitizedMessage(message) {
return DOMPurify.sanitize(message.text);
}
}
}

View File

@@ -7,7 +7,7 @@
<div v-for="entry in guestbookEntries" :key="entry.id" class="guestbook-entry">
<img v-if="entry.image" :src="entry.image.url" alt="Entry Image"
style="max-width: 400px; max-height: 400px;" />
<p v-html="entry.contentHtml"></p>
<p v-html="sanitizedContent(entry)"></p>
<div class="entry-info">
<span class="entry-timestamp">{{ new Date(entry.createdAt).toLocaleString() }}</span>
<span class="entry-user">
@@ -30,6 +30,7 @@
<script>
import { mapActions, mapGetters } from 'vuex';
import apiClient from '@/utils/axios.js';
import DOMPurify from 'dompurify';
export default {
name: 'GuestbookView',
@@ -73,6 +74,9 @@ export default {
console.error('Error fetching image:', error);
}
},
sanitizedContent(entry) {
return DOMPurify.sanitize(entry.contentHtml);
},
},
mounted() {
this.loadGuestbookEntries(1);