Bugs in settings fixed, profile added

This commit is contained in:
Torsten Schulz
2024-09-21 00:25:42 +02:00
parent c5a72d57d8
commit e494fe41db
65 changed files with 3121 additions and 7478 deletions

View File

@@ -22,7 +22,7 @@ button {
cursor: pointer;
background: #F9A22C;
color: #000000;
border: none;
border: 1px solid #F9A22C;
border-radius: 4px;
transition: background 0.05s;
border: 1px solid transparent;
@@ -51,4 +51,19 @@ button:hover {
.link {
color: #F9A22C;
cursor: pointer;
}
h1, h2, h3 {
margin: 0;
}
.multiselect__option--highlight,
.multiselect__option--highlight::after,
.multiselect__tag,
.multiselect__option--highlight[data-select],
.multiselect__option--highlight[data-selected],
.multiselect__option--highlight[data-deselect] {
background: none;
background-color: #F9A22C;
color: #000;
}

View File

@@ -3,10 +3,10 @@
<div class="logo"><img src="/images/icons/logo_color.png"></div>
<div class="window-bar">
<button v-for="dialog in openDialogs" :key="dialog.dialog.name" class="dialog-button"
@click="toggleDialogMinimize(dialog.dialog.name)" :title="dialog.dialog.title">
@click="toggleDialogMinimize(dialog.dialog.name)" :title="dialog.dialog.localTitle">
<img v-if="dialog.dialog.icon" :src="'/images/icons/' + dialog.dialog.icon" />
<span class="button-text">{{ dialog.dialog.isTitleTranslated ? $t(dialog.dialog.title) : dialog.dialog.title
}}</span>
<span class="button-text">{{ dialog.dialog.isTitleTranslated ? $t(dialog.dialog.localTitle) :
dialog.dialog.localTitle }}</span>
</button>
</div>
<div class="static-block">

View File

@@ -60,6 +60,7 @@ nav>ul {
flex-direction: row;
margin: 0;
cursor: pointer;
z-index: 999;
}
ul {

View File

@@ -7,7 +7,7 @@
<span v-if="icon" class="dialog-icon">
<img :src="'/images/icons/' + icon" alt="Icon" />
</span>
<span class="dialog-title">{{ isTitleTranslated ? $t(title) : title }}</span>
<span class="dialog-title">{{ localIsTitleTranslated ? $t(localTitle) : localTitle }}</span>
<span v-if="!modal" class="dialog-minimize" @click="minimize">_</span>
<span v-if="showClose" class="dialog-close" @click="close"></span>
</div>
@@ -73,12 +73,13 @@ export default {
isDragging: false,
dragOffsetX: 0,
dragOffsetY: 0,
localTitle: this.title,
localIsTitleTranslated: this.isTitleTranslated,
};
},
computed: {
dialogWidth() {
const val = this.width || '70%';
console.log(val);
return val;
},
dialogHeight() {
@@ -90,6 +91,9 @@ export default {
if (!newValue) {
this.minimized = false;
}
},
title(newValue) {
this.updateTitle(newValue);
}
},
methods: {
@@ -107,9 +111,13 @@ export default {
this.$store.dispatch('dialogs/removeOpenDialog', this.name);
},
buttonClick(action) {
this.$emit(action);
if (action === 'close') {
this.close();
if (typeof action === 'function') {
action(); // Wenn action eine Funktion ist, rufe sie direkt auf
} else {
this.$emit(action);
if (action === 'close') {
this.close();
}
}
},
handleOverlayClick() {
@@ -131,7 +139,6 @@ export default {
this.dragOffsetY = event.clientY - dialog.offsetTop;
document.addEventListener('mousemove', this.onDrag);
document.addEventListener('mouseup', this.stopDragging);
console.log('dragging started');
},
onDrag(event) {
if (!this.isDragging) return;
@@ -143,13 +150,15 @@ export default {
document.removeEventListener('mousemove', this.onDrag);
document.removeEventListener('mouseup', this.stopDragging);
},
},
mounted() {
this.$store.subscribe((mutation) => {
if (mutation.type === 'dialogs/toggleDialogMinimize' && mutation.payload === this.name) {
this.minimized = !this.minimized;
}
});
updateTitle(newTitle, newIsTitleTranslated) {
this.localTitle = newTitle;
this.localIsTitleTranslated = newIsTitleTranslated;
this.$store.dispatch('dialogs/updateDialogTitle', {
name: this.name,
newTitle: newTitle,
isTitleTranslated: this.localIsTitleTranslated
});
},
}
};
</script>

View File

@@ -0,0 +1,76 @@
<template>
<div ref="quillEditor" class="quill-editor"></div>
</template>
<script>
import Quill from 'quill/core';
import Toolbar from 'quill/modules/toolbar';
import Snow from 'quill/themes/snow';
import Bold from 'quill/formats/bold';
import Italic from 'quill/formats/italic';
import Underline from 'quill/formats/underline';
import List from 'quill/formats/list';
Quill.register({
'modules/toolbar': Toolbar,
'themes/snow': Snow,
'formats/bold': Bold,
'formats/italic': Italic,
'formats/underline': Underline,
'formats/list': List
});
export default {
name: 'QuillEditor',
props: {
content: {
type: String,
default: ''
},
placeholder: {
type: String,
default: 'Compose an epic...'
}
},
data() {
return {
editor: null,
};
},
mounted() {
this.editor = new Quill(this.$refs.quillEditor, {
theme: 'snow',
placeholder: this.placeholder,
modules: {
toolbar: [
[{ 'header': [1, 2, false] }],
['bold', 'italic', 'underline'],
['link', 'image'],
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
[{ 'align': [] }],
[{ 'color': [] }, { 'background': [] }],
['clean']
]
}
});
this.editor.on('text-change', () => {
this.$emit('update:content', this.editor.root.innerHTML);
});
this.editor.root.innerHTML = this.content;
},
beforeUnmount() {
if (this.editor) {
this.editor.off('text-change');
this.editor = null;
}
}
};
</script>
<style scoped>
.quill-editor {
min-height: 200px;
}
</style>

View File

@@ -1,30 +1,58 @@
<template>
<div class="settings-widget">
<template v-for="setting in settings">
<InputStringWidget v-if="setting.datatype == 'string'" :labelTr="`settings.personal.label.${setting.name}`"
:tooltipTr="`settings.personal.tooltip.${setting.name}`" :value=setting.value :list="languagesList()"
@input="handleInput(setting.id, $event)" />
<DateInputWidget v-else-if="setting.datatype == 'date'" :labelTr="`settings.personal.label.${setting.name}`"
:tooltipTr="`settings.personal.tooltip.${setting.name}`" :value=setting.value
@input="handleInput(setting.id, $event)" />
<SelectDropdownWidget v-else-if="setting.datatype == 'singleselect'"
:labelTr="`settings.personal.label.${setting.name}`"
:tooltipTr="`settings.personal.tooltip.${setting.name}`" :value=setting.value
:list="getSettingOptions(setting.name, setting.options)" @input="handleInput(setting.id, $event)" />
<InputNumberWidget v-else-if="setting.datatype == 'int'"
:labelTr="`settings.personal.label.${setting.name}`"
:tooltipTr="`settings.personal.tooltip.${setting.name}`" :value="convertToInt(setting.value)" min="0"
max="200" @input="handleInput(setting.id, $event)" />
<FloatInputWidget v-else-if="setting.datatype == 'float'"
:labelTr="`settings.personal.label.${setting.name}`"
:tooltipTr="`settings.personal.tooltip.${setting.name}`" :value="convertToFloat(setting.value)"
@input="handleInput(setting.id, $event)" />
<CheckboxWidget v-else-if="setting.datatype == 'bool'" :labelTr="`settings.personal.label.${setting.name}`"
:tooltipTr="`settings.personal.tooltip.${setting.name}`" :value="convertToBool(setting.value)"
@input="handleInput(setting.id, $event)" />
<div v-else>{{ setting }}
</div>
</template>
<table>
<tr v-for="setting in settings" :key="setting.id">
<td>
<InputStringWidget v-if="setting.datatype == 'string'"
:labelTr="`settings.personal.label.${setting.name}`"
:tooltipTr="`settings.personal.tooltip.${setting.name}`" :value=setting.value
:list="languagesList()" @input="handleInput(setting.id, $event)" />
<DateInputWidget v-else-if="setting.datatype == 'date'"
:labelTr="`settings.personal.label.${setting.name}`"
:tooltipTr="`settings.personal.tooltip.${setting.name}`" :value=setting.value
@input="handleInput(setting.id, $event)" />
<SelectDropdownWidget v-else-if="setting.datatype == 'singleselect'"
:labelTr="`settings.personal.label.${setting.name}`"
:tooltipTr="`settings.personal.tooltip.${setting.name}`" :value=setting.value
:list="getSettingOptions(setting.name, setting.options)"
@input="handleInput(setting.id, $event)" />
<InputNumberWidget v-else-if="setting.datatype == 'int'"
:labelTr="`settings.personal.label.${setting.name}`"
:tooltipTr="`settings.personal.tooltip.${setting.name}`" :value="convertToInt(setting.value)"
min="0" max="200" @input="handleInput(setting.id, $event)" />
<FloatInputWidget v-else-if="setting.datatype == 'float'"
:labelTr="`settings.personal.label.${setting.name}`"
:tooltipTr="`settings.personal.tooltip.${setting.name}`" :value="convertToFloat(setting.value)"
@input="handleInput(setting.id, $event)" />
<CheckboxWidget v-else-if="setting.datatype == 'bool'"
:labelTr="`settings.personal.label.${setting.name}`"
:tooltipTr="`settings.personal.tooltip.${setting.name}`" :value="convertToBool(setting.value)"
@input="handleInput(setting.id, $event)" />
<MultiselectWidget v-else-if="setting.datatype == 'multiselect'"
:labelTr="`settings.personal.label.${setting.name}`"
:tooltipTr="`settings.personal.tooltip.${setting.name}`" :value="setting.value"
:list="getSettingOptions(setting.name, setting.options)"
@input="handleInput(setting.id, $event)" />
<div v-else>{{ setting }}</div>
<span v-if="setting.unit">&nbsp;{{ setting.unit }}</span>
</td>
<td>
<select v-model="setting.visibility.id"
@change="handleVisibilityChange(setting.id, setting.visibility.id)">
<option v-for="visibility in possibleVisibilities" :key="visibility.id" :value="visibility.id">
{{ $t(`settings.visibility.${visibility.description}`) }}
</option>
</select>
</td>
</tr>
</table>
</div>
</template>
@@ -33,10 +61,11 @@ import apiClient from '@/utils/axios.js';
import { mapGetters } from 'vuex';
import InputStringWidget from '@/components/form/InputStringWidget.vue';
import DateInputWidget from '@/components/form/DateInputWidget.vue';
import SelectDropdownWidget from '@/components/form/SelectDropdownWidget';
import InputNumberWidget from '@/components/form/InputNumberWidget';
import FloatInputWidget from '@/components/form/FloatInputWidget';
import CheckboxWidget from '@/components/form/CheckboxWidget';
import SelectDropdownWidget from '@/components/form/SelectDropdownWidget.vue';
import InputNumberWidget from '@/components/form/InputNumberWidget.vue';
import FloatInputWidget from '@/components/form/FloatInputWidget.vue';
import CheckboxWidget from '@/components/form/CheckboxWidget.vue';
import MultiselectWidget from '@/components/form/MultiselectWidget.vue';
export default {
name: "SettingsWidget",
@@ -46,7 +75,8 @@ export default {
SelectDropdownWidget,
InputNumberWidget,
FloatInputWidget,
CheckboxWidget
CheckboxWidget,
MultiselectWidget
},
props: {
settingsType: {
@@ -54,6 +84,10 @@ export default {
required: true
}
},
data: {
settings: [],
possibleVisibilities: [],
},
computed: {
...mapGetters(['user']),
},
@@ -64,6 +98,8 @@ export default {
async fetchSettings() {
if (this.user && this.user.id) {
try {
const visibilityResponse = await apiClient.get('/api/settings/visibilities');
this.possibleVisibilities = visibilityResponse.data;
const userid = this.user.id;
const response = await apiClient.post('/api/settings/filter', {
userid: userid,
@@ -73,7 +109,7 @@ export default {
} catch (err) {
this.settings = [];
}
}
}
},
getSettingOptions(fieldName, options) {
return options.map((option) => {
@@ -94,6 +130,7 @@ export default {
settingId: settingId,
value: value
});
this.fetchSettings();
} catch (err) {
console.error('Error updating setting:', err);
}
@@ -120,7 +157,17 @@ export default {
} else {
return false;
}
}
},
async handleVisibilityChange(settingId, visibilityId) {
try {
await apiClient.post('/api/settings/update-visibility', {
userParamTypeId: settingId,
visibilityId: visibilityId
});
} catch (err) {
console.error('Error updating visibility:', err);
}
},
},
data() {
return {
@@ -129,3 +176,9 @@ export default {
}
};
</script>
<style lang="scss" scoped>
label {
float: left;
}
</style>

View File

@@ -30,7 +30,7 @@ export default {
},
methods: {
updateValue(checked) {
this.$emit("input", checked);
this.$emit("input", checked || false);
}
}
};

View File

@@ -2,7 +2,7 @@
<label>
<span :style="{ width: width + 'em' }">{{ $t(labelTr) }}</span>
<input type="number" :value="formattedValue" :placeholder="$t(labelTr)" :title="$t(tooltipTr)"
@input="updateValue($event.target.value)" :step="step" />
@change="updateValue($event.target.value)" :step="step" />
<span v-if="postfix">{{ postfix }}</span>
</label>
</template>
@@ -41,7 +41,7 @@ export default {
},
computed: {
formattedValue() {
return this.value != null && typeof this.value === 'float' ? this.value.toFixed(this.decimals) : '';
return this.value != null ? this.value.toFixed(this.decimals) : '';
},
step() {
return Math.pow(10, -this.decimals);

View File

@@ -1,8 +1,8 @@
<template>
<label>
<span :style="{ width: width + 'em' }">{{ $t(labelTr) }}</span>
<input type="number" :value="value" :title="$t(tooltipTr)" :min="min" :max="max"
@input="updateValue($event.target.value)" />
<input type="number" :value="value" :title="$t(tooltipTr)" :min="min" :max="max"
@change="updateValue($event.target.value)" />
</label>
</template>
@@ -38,7 +38,8 @@ export default {
},
methods: {
updateValue(value) {
this.$emit("input", parseFloat(value));
console.log('changed to ', value)
this.$emit("input", parseInt(value));
}
}
};

View File

@@ -2,7 +2,7 @@
<label>
<span :style="{ width: width + 'em' }">{{ $t(labelTr) }}</span>
<input type="text" :value="value" :placeholder="$t(labelTr)" :title="$t(tooltipTr)"
@input="validateAndUpdate($event.target.value)" />
@change="validateAndUpdate($event.target.value)" />
</label>
</template>

View File

@@ -0,0 +1,139 @@
<template>
<label>
<span :style="{ width: width + 'em' }">{{ $t(labelTr) }}</span>
<Multiselect
v-model="selectedOptions"
:options="validList"
:multiple="true"
:close-on-select="false"
:clear-on-select="false"
:preserve-search="true"
:placeholder="$t('select_option')"
:track-by="'value'"
>
<template #option="{ option }">
<span v-if="option && option.value">Option: {{ getTranslation(option) }}</span>
</template>
<template #tag="{ option, remove }">
<span v-if="option && option.captionTr" class="custom-tag">
{{ $t(option.captionTr) }}
<span @click="remove(option)">×</span>
</span>
<span v-else>@e</span>
</template>
</Multiselect>
</label>
</template>
<script>
import Multiselect from 'vue-multiselect';
import 'vue-multiselect/dist/vue-multiselect.min.css';
export default {
name: "MultiselectWidget",
components: { Multiselect },
props: {
labelTr: { type: String, required: true },
value: { type: String, required: false, default: '[]' },
tooltipTr: { type: String, required: true },
width: { type: Number, required: false, default: 10 },
list: {
type: Array,
required: true,
default: () => [] // Standardwert hinzufügen, um undefined zu vermeiden
},
},
data() {
return {
internalValues: this.stringToArray(this.value), // Speichert nur die IDs (Werte)
selectedOptions: this.getOptionsFromIds(this.stringToArray(this.value)) // Hilfsvariable, speichert die vollständigen Objekte
};
},
computed: {
validList() {
return this.validatedList(); // Immer ein Array zurückgeben
}
},
watch: {
value(newValue) {
const ids = this.stringToArray(newValue);
this.internalValues = ids; // Nur die IDs speichern
this.selectedOptions = this.getOptionsFromIds(ids); // Optionen basierend auf IDs setzen
},
selectedOptions(newOptions) {
this.internalValues = newOptions.map(option => option.value); // Nur die IDs extrahieren
this.updateValue();
}
},
methods: {
stringToArray(str) {
try {
const array = JSON.parse(str);
return array.filter(item => item !== null && item !== undefined);
} catch (error) {
console.error('Invalid JSON string in value:', str);
return [];
}
},
updateValue() {
const stringValue = JSON.stringify(this.internalValues); // In JSON-String umwandeln
this.$emit("input", stringValue); // String an das Parent-Element übermitteln
},
getTranslation(option) {
return option.captionTr ? this.$t(option.captionTr) : option.caption;
},
findOption(optionId) {
return this.validatedList().find(opt => opt.value === optionId);
},
getOptionsFromIds(ids) {
return ids.map(id => this.findOption(id)).filter(option => option); // Vollständige Objekte basierend auf IDs abrufen
},
validatedList() {
// Überprüfen, ob die Liste valide ist
if (!this.list || !Array.isArray(this.list)) {
return [];
}
return this.list.filter(option => option && option.value !== null && option.value !== undefined && (option.captionTr || option.caption));
}
}
};
</script>
<style scoped>
label {
display: block;
margin-bottom: 1em;
}
label>span {
display: inline-block;
margin-bottom: 0.5em;
}
.multiselect {
margin-left: 0.5em;
}
.custom-tag {
background-color: #f0f0f0;
border: 1px solid #ccc;
padding: 5px;
border-radius: 4px;
margin-right: 5px;
display: inline-block;
}
.custom-tag span {
margin-left: 8px;
cursor: pointer;
}
.multiselect {
display: inline-block;
width: 7em;
}
.multiselect__tags {
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,110 @@
<template>
<DialogWidget ref="dialog" :title="$t('admin.editcontactrequest.title')" :show-close="true" :buttons="buttons"
@close="closeDialog" name="AnswerContact" :modal="true" :isTitleTranslated="true">
<div class="contact-details">
<h3>Von: {{ contact.email }}</h3>
<p>{{ contact.message }}</p>
</div>
<div class="editor-container">
<Editor v-model="answer" :init="tinymceInitOptions" :api-key="apiKey" />
</div>
</DialogWidget>
<DialogWidget ref="errorDialog" :title="$t('error.title')" :show-close="true" :buttons="errorButtons"
@close="closeErrorDialog" name="ErrorDialog" :modal="true" :isTitleTranslated="false">
<div>
<p>{{ errorMessage }}</p>
</div>
</DialogWidget>
</template>
<script>
import { ref, onBeforeUnmount } from 'vue'
import Editor from '@tinymce/tinymce-vue'
import apiClient from '@/utils/axios.js'
import DialogWidget from '@/components/DialogWidget.vue'
export default {
name: 'AnswerContact',
components: {
DialogWidget,
Editor,
},
data() {
return {
apiKey: import.meta.env.VITE_TINYMCE_API_KEY,
dialog: null,
errorDialog: null,
contact: null,
answer: '',
errorMessage: '',
tinymceInitOptions: {
height: 300,
menubar: false,
plugins: [
'advlist autolink lists link image charmap print preview anchor',
'searchreplace visualblocks code fullscreen',
'insertdatetime media table paste code help wordcount'
],
toolbar:
'undo redo cut copy paste | bold italic forecolor fontfamily fontsize | \
alignleft aligncenter alignright alignjustify | \
bullist numlist outdent indent | removeformat | help'
},
buttons: [
{ text: 'OK', action: this.sendAnswer },
{ text: 'Cancel', action: this.closeDialog }
],
errorButtons: [
{ text: 'OK', action: this.closeErrorDialog }
]
}
},
methods: {
open(contactData) {
this.contact = contactData;
this.dialog.open();
this.answer = '';
},
closeDialog() {
this.dialog.close();
this.answer = '';
},
closeErrorDialog() {
this.errorDialog.close();
},
async sendAnswer() {
try {
await apiClient.post('/api/admin/contacts/answer', {
id: this.contact.id,
answer: this.answer,
});
this.dialog.close();
this.$emit('refresh');
this.answer = '';
} catch (error) {
const errorText = error.response?.data?.error || 'An unexpected error occurred.';
this.errorMessage = errorText;
this.errorDialog.open();
}
}
},
mounted() {
this.dialog = this.$refs.dialog;
this.errorDialog = this.$refs.errorDialog;
},
beforeUnmount() {
// Aufräumarbeiten falls nötig
}
}
</script>
<style scoped>
.contact-details {
margin-bottom: 20px;
}
.editor-container {
margin-top: 20px;
}
</style>

View File

@@ -0,0 +1,149 @@
<template>
<DialogWidget ref="dialog" :title="$t('socialnetwork.profile.pretitle')" :isTitleTranslated="isTitleTranslated"
:show-close="true" :buttons="[{ text: 'Ok', action: 'close' }]" :modal="false" @close="closeDialog">
<div class="dialog-body">
<div>
<ul class="tab-list">
<li v-for="tab in tabs" :key="tab.name" :class="{ active: activeTab === tab.name }"
@click="selectTab(tab.name)">
{{ tab.label }}
</li>
</ul>
<div class="tab-content" v-if="activeTab === 'general'">
<table>
<tr v-for="(value, key) in userProfile.params" :key="key">
<td>{{ $t(`socialnetwork.profile.${key}`) }}</td>
<td>{{ generateValue(key, value) }}</td>
</tr>
</table>
</div>
</div>
</div>
</DialogWidget>
</template>
<script>
import DialogWidget from '@/components/DialogWidget.vue';
import apiClient from '@/utils/axios.js';
export default {
name: 'UserProfileDialog',
components: {
DialogWidget
},
props: {
userId: {
type: String,
required: true
}
},
data() {
return {
isTitleTranslated: true,
userProfile: {},
activeTab: 'general',
userId: '',
tabs: [
{ name: 'general', label: this.$t('socialnetwork.profile.tab.general') },
{ name: 'images', label: this.$t('socialnetwork.profile.tab.images') },
{ name: 'guestbook', label: this.$t('socialnetwork.profile.tab.guestbook') }
],
};
},
methods: {
open() {
this.$refs.dialog.open();
this.loadUserProfile();
},
async loadUserProfile() {
try {
const response = await apiClient.get(`/api/socialnetwork/profile/${this.userId}`);
this.userProfile = response.data;
const newTitle = this.$t('socialnetwork.profile.title').replace('<username>', this.userProfile.username);
this.$refs.dialog.updateTitle(newTitle, false);
} catch (error) {
this.$refs.dialog.updateTitle('socialnetwork.profile.error_title', true);
console.error('Fehler beim Laden des Benutzerprofils:', error);
}
},
closeDialog() {
this.$refs.dialog.close();
},
selectTab(tabName) {
this.activeTab = tabName;
},
generateValue(key, value) {
if (Array.isArray(value.value)) {
const strings = [];
for (const val of value.value) {
strings.push(this.generateValue(key, {type: value.type, value: val}));
}
return strings.join(', ');
}
switch (value.type) {
case 'bool':
return this.$t(`socialnetwork.profile.values.bool.${value.value}`);
case 'multiselect':
case 'singleselect':
return this.$t(`socialnetwork.profile.values.${key}.${value.value}`);
case 'date':
const date = new Date(value.value);
return date.toLocaleDateString();
case 'string':
case 'int':
return value.value;
case 'float':
return new Intl.NumberFormat(navigator.language, {
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(parseFloat(value.value));
default:
return value.value;
}
}
}
};
</script>
<style scoped>
.tab-list {
list-style-type: none;
padding: 0;
display: flex;
margin-bottom: 20px;
border-bottom: 2px solid #ccc;
}
.tab-list li {
padding: 10px 20px;
cursor: pointer;
margin-right: 5px;
border: 1px solid #ccc;
border-bottom: none;
background: #f9f9f9;
}
.tab-list li.active {
background: #ffffff;
border-bottom: 2px solid #ffffff;
font-weight: bold;
}
.tab-content {
padding: 20px;
border: 1px solid #ccc;
background: #ffffff;
overflow: auto;
}
.dialog-body,
.dialog-body > div {
height: 100%;
}
.dialog-body > div {
display: flex;
flex-direction: column;
}
</style>

View File

@@ -11,9 +11,10 @@ import enError from './locales/en/error.json';
import enActivate from './locales/en/activate.json';
import enSettings from './locales/en/settings.json';
import enAdmin from './locales/en/admin.json';
import enSocialNetwork from './locales/en/socialnetwork.json';
import deGeneral from './locales/de/general.json';
import deHeader from './locales/de/header.json';
import deHeader from './locales/de/header.json';
import deNavigation from './locales/de/navigation.json';
import deHome from './locales/de/home.json';
import deChat from './locales/de/chat.json';
@@ -22,6 +23,7 @@ import deError from './locales/de/error.json';
import deActivate from './locales/de/activate.json';
import deSettings from './locales/de/settings.json';
import deAdmin from './locales/de/admin.json';
import deSocialNetwork from './locales/de/socialnetwork.json';
const messages = {
en: {
@@ -35,8 +37,10 @@ const messages = {
...enActivate,
...enSettings,
...enAdmin,
...enSocialNetwork,
},
de: {
'Ok': 'Ok',
...deGeneral,
...deHeader,
...deNavigation,
@@ -47,6 +51,7 @@ const messages = {
...deActivate,
...deSettings,
...deAdmin,
...deSocialNetwork,
}
};

View File

@@ -19,6 +19,9 @@
"actions": "Aktionen",
"open": "Bearbeiten",
"finished": "Abschließen"
},
"editcontactrequest": {
"title": "[Admin] - Kontaktanfrage bearbeiten"
}
}
}

View File

@@ -27,5 +27,7 @@
},
"general": {
"datetimelong": "dd.MM.yyyy HH:mm:ss"
}
},
"OK": "Ok",
"Cancel": "Abbrechen"
}

View File

@@ -27,6 +27,7 @@
"account": "Account",
"personal": "Persönliches",
"view": "Aussehen",
"flirt": "Flirt",
"interests": "Interessen",
"notifications": "Benachrichtigungen",
"sexuality": "Sexualität"

View File

@@ -19,8 +19,13 @@
"tattoos": "Tattoos",
"sexualpreference": "Ausrichtung",
"pubichair": "Schamhaare",
"penislenght": "Penislänge",
"brasize": "BH-Größe"
"penislength": "Penislänge",
"brasize": "BH-Größe",
"willChildren": "Ich möchte Kinder",
"smokes": "Rauchen",
"drinks": "Ich trinke Alkohol",
"hasChildren": "Ich habe Kinder",
"interestedInGender": "Interessiert an"
},
"tooltip": {
"language": "Sprache",
@@ -39,7 +44,7 @@
"tattoos": "Tattoos",
"sexualpreference": "Ausrichtung",
"pubichair": "Schamhaare",
"penislenght": "Penislänge",
"penislength": "Penislänge",
"brasize": "BH-Größe"
},
"gender": {
@@ -109,6 +114,22 @@
"landingstrip": "Landebahn",
"bikinizone": "Nur Bikinizone",
"other": "Andere"
},
"interestedInGender": {
"male": "Männer",
"female": "Frauen"
},
"smokes": {
"often": "Oft",
"socially": "In Gesellschaft",
"daily": "Täglich",
"never": "Nie"
},
"drinks": {
"often": "Oft",
"socially": "In Gesellschaft",
"daily": "Täglich",
"never": "Nie"
}
},
"view": {
@@ -136,6 +157,16 @@
"added": "Das neue Interesse wurde hinzugefügt und wird bearbeitet. Bis zum Abschluss ist es nicht in der Liste der Interessen sichtbar.",
"adderror": "Beim hinzufügen des Interesses ist ein Fehler aufgetreten.",
"errorsetinterest": "Das Interest konnte für Dich nicht gebucht werden."
}
},
"visibility": {
"Invisible": "Nicht anzeigen",
"OnlyFriends": "Nur Freunden anzeigen",
"FriendsAndAdults": "Freunden und Erwachsenen anzeigen",
"AdultsOnly": "Nur Erwachsenen anzeigen",
"All": "Jedem zeigen"
},
"flirt": {
"title": "Flirt"
}
}
}

View File

@@ -0,0 +1,143 @@
{
"socialnetwork": {
"usersearch": {
"title": "Benutzersuche",
"username": "Benutzername",
"age_from": "Alter von",
"age_to": "bis",
"gender": "Geschlecht",
"search_button": "Suchen",
"no_results": "Keine Ergebnisse gefunden",
"results_title": "Suchergebnisse:",
"result": {
"nick": "Spitzname",
"gender": "Geschlecht",
"age": "Alter"
}
},
"profile": {
"pretitle": "Lade Daten. Bitte warten...",
"error_title": "User nicht gefunden",
"title": "Profil von <username>",
"tab": {
"general": "Allgemeines",
"sexuality": "Sexualität",
"images": "Bilder",
"guestbook": "Gästebuch"
},
"values": {
"bool": {
"true": "Ja",
"false": "Nein"
},
"smokes": {
"never": "Nie",
"socially": "In Gesellschaft",
"often": "Oft",
"daily": "Täglich"
},
"drinks": {
"never": "Nie",
"socially": "In Gesellschaft",
"often": "Oft",
"daily": "Täglich"
},
"interestedInGender": {
"male": "Männern",
"female": "Frauen"
},
"sexualpreference": {
"straight": "Heterosexuell",
"gay": "Homosexuell",
"bi": "Bisexuell",
"pan": "Pansexuell",
"asexual": "Asexuell"
},
"pubichair": {
"none": "Keine",
"short": "Kurz",
"medium": "Mittel",
"long": "Lang",
"hairy": "Unrasiert",
"waxed": "Gewachst",
"landingstrip": "Landebahn",
"other": "Anderes",
"bikinizone": "Bikinizone"
},
"gender": {
"male": "Männlich",
"female": "Weiblich",
"transmale": "Trans-Frau",
"transfemale": "Trans-Mann",
"nonbinary": "Nonbinär"
},
"language": {
"de": "Deutsch",
"en": "Englisch"
},
"eyecolor": {
"blue": "Blau",
"green": "Grün",
"brown": "Braun",
"black": "Schwarz",
"grey": "Grau",
"hazel": "Haselnuss",
"amber": "Bernstein",
"red": "Rot",
"other": "Andere"
},
"haircolor": {
"black": "Schwarz",
"brown": "Braun",
"blonde": "Blond",
"red": "Rot",
"grey": "Grau",
"white": "Weiß",
"other": "Andere"
},
"hairlength": {
"short": "Kurz",
"medium": "Mittel",
"long": "Lang",
"bald": "Glatze",
"other": "Andere"
},
"skincolor": {
"light": "Hell",
"medium": "Mittel",
"dark": "Dunkel",
"other": "Andere"
},
"freckles": {
"much": "Viele",
"medium": "Mittel",
"less": "Wenige",
"none": "Keine"
}
},
"interestedInGender": "Interessiert an",
"hasChildren": "Hat Kinder",
"smokes": "Rauchen",
"drinks": "Alkohol",
"willChildren": "Will Kinder",
"sexualpreference": "Sexuelle Ausrichtung",
"pubichair": "Schamhaare",
"penislength": "Penislänge",
"brasize": "BH-Größe",
"piercings": "Piercings",
"tattoos": "Tattoos",
"language": "Sprache",
"gender": "Geschlecht",
"eyecolor": "Augenfarbe",
"haircolor": "Haarfarbe",
"hairlength": "Haarlänge",
"freckles": "Sommersprossen",
"skincolor": "Hautfarbe",
"birthdate": "Geburtsdatum",
"age": "Alter",
"town": "Stadt",
"bodyheight": "Größe",
"weight": "Gewicht"
}
}
}

View File

@@ -0,0 +1,3 @@
{
}

View File

@@ -4,23 +4,31 @@ import HomeView from '../views/HomeView.vue';
import ActivateView from '../views/auth/ActivateView.vue';
import PeronalSettingsView from '../views/settings/PersonalView.vue';
import ViewSettingsView from '../views/settings/ViewView.vue';
import FlirtSettingsView from '../views/settings/FlirtView.vue';
import SexualitySettingsView from '../views/settings/SexualityView.vue';
import AccountSettingsView from '../views/settings/AccountView.vue';
import InterestsView from '../views/settings/InterestsView.vue';
import AdminInterestsView from '../views/admin/InterestsView.vue';
import AdminContactsView from '../views/admin/ContactsView.vue';
import SearchView from '../views/social/SearchView.vue';
const routes = [
{
path: '/',
path: '/',
name: 'Home',
component: HomeView
},
{
path: '/activate',
path: '/activate',
name: 'Activate page',
component: ActivateView
},
{
path: '/socialnetwork/search',
name: 'Search users',
component: SearchView,
meta: { requiresAuth: true }
},
{
path: '/settings/personal',
name: 'Personal settings',
@@ -39,6 +47,12 @@ const routes = [
component: SexualitySettingsView,
meta: { requiresAuth: true }
},
{
path: '/settings/flirt',
name: 'Flirt settings',
component: FlirtSettingsView,
meta: { requiresAuth: true }
},
{
path: '/settings/account',
name: 'Account settings',
@@ -67,7 +81,7 @@ const routes = [
const router = createRouter({
history: createWebHistory(process.env.BASE_URL),
history: createWebHistory(import.meta.env.BASE_URL),
routes
});

View File

@@ -26,6 +26,23 @@ const mutations = {
dialog.dialog.toggleMinimize();
}
minimizing = false;
},
updateDialogTitle(state, { name, newTitle, isTitleTranslated }) {
const dialogIndex = state.openDialogs.findIndex((d) => d.dialog.name === name);
if (dialogIndex !== -1) {
// Update dialog object reactively
const updatedDialog = {
...state.openDialogs[dialogIndex],
dialog: {
...state.openDialogs[dialogIndex].dialog,
localTitle: newTitle,
isTitleTranslated: isTitleTranslated
}
};
// Replace the old dialog with the updated one
state.openDialogs.splice(dialogIndex, 1, updatedDialog);
}
}
};
@@ -38,7 +55,10 @@ const actions = {
},
toggleDialogMinimize({ commit }, dialogName) {
commit('toggleDialogMinimize', dialogName);
}
},
updateDialogTitle({ commit }, { name, newTitle, isTitleTranslated }) {
commit('updateDialogTitle', { name, newTitle, isTitleTranslated });
},
};
export default {

View File

@@ -2,7 +2,7 @@ import axios from 'axios';
import store from '../store';
const apiClient = axios.create({
baseURL: process.env.VUE_APP_API_BASE_URL || 'http://localhost:3001',
baseURL: import.meta.env.VUE_APP_API_BASE_URL || 'http://localhost:3001',
headers: {
'Content-Type': 'application/json'
}

View File

@@ -14,14 +14,15 @@
<td>{{ formatDateTimeLong(contact.createdAt) }}</td>
<td>{{ contact.email }}</td>
<td>
<button @clicked="openRequest(contact)">{{ $t('admin.contacts.open') }}</button>
<button @clicked="finishRequest(contact)">{{ $t('admin.contacts.finished') }}</button>
<button @click="openRequest(contact)">{{ $t('admin.contacts.open') }}</button>
<button @click="finishRequest(contact)">{{ $t('admin.contacts.finished') }}</button>
</td>
</tr>
</tbody>
</table>
</div>
<ErrorDialog ref="errorDialog" />
<AnswerContact ref="answerContactDialog" />
</template>
<script>
@@ -29,11 +30,13 @@ import apiClient from '@/utils/axios.js';
import { mapGetters } from 'vuex';
import ErrorDialog from '@/dialogues/standard/ErrorDialog.vue';
import { formatDateTimeLong } from '@/utils/datetime.js';
import AnswerContact from '../../dialogues/admin/AnswerContact.vue';
export default {
name: 'AdminContactsView',
components: {
ErrorDialog
ErrorDialog,
AnswerContact,
},
data() {
return {
@@ -54,10 +57,10 @@ export default {
}
},
async openRequest(contact) {
this.$refs.answerContactDialog.open(contact);
},
async finishRequest(contact) {
await apiClient.get('/api/admin/opencontacts/finish/${contact.id}');
}
}
}

View File

@@ -0,0 +1,17 @@
<template>
<div>
<h2>{{ $t("settings.flirt.title") }}</h2>
<SettingsWidget :settingsType="'flirt'" />
</div>
</template>
<script>
import SettingsWidget from '@/components/SettingsWidget.vue';
export default {
name: 'FlirtSettingsView',
components: {
SettingsWidget,
}
}
</script>

View File

@@ -0,0 +1,206 @@
<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')" />
</div>
<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" />
</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>
<th>{{ $t("socialnetwork.usersearch.result.nick") }}</th>
<th>{{ $t("socialnetwork.usersearch.result.gender") }}</th>
<th>{{ $t("socialnetwork.usersearch.result.age") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="result in searchResults" :key="result.id">
<td><span @click.prevent="openUserProfile(result.id)" :class="'clickable g-' + result.gender">{{ result.username }}</span></td>
<td>{{ result.gender }}</td>
<td>{{ result.age }}</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="no-results">
{{ $t('socialnetwork.usersearch.no_results') }}
</div>
</div>
<UserProfileDialog ref="userProfileDialog" :username="selectedUsername" />
</template>
<script>
import Multiselect from 'vue-multiselect';
import 'vue-multiselect/dist/vue-multiselect.min.css';
import apiClient from '@/utils/axios.js';
import UserProfileDialog from '@/dialogues/socialnetwork/UserProfileDialog.vue';
export default {
components: {
Multiselect,
UserProfileDialog
},
data() {
return {
searchCriteria: {
username: '',
ageFrom: 14,
ageTo: 150,
gender: []
},
genderOptions: [],
searchResults: []
};
},
async mounted() {
await this.loadGenderOptions();
},
methods: {
async loadGenderOptions() {
try {
const response = await apiClient.post('/api/settings/getparamvalues', {
type: 'gender'
});
this.genderOptions = response.data.map(g => ({ name: g.name, value: g.value }));
} catch (error) {
console.error('Fehler beim Laden der Geschlechtsoptionen:', error);
}
},
async performSearch() {
const searchCriteria = {
username: this.searchCriteria.username,
ageFrom: this.searchCriteria.ageFrom,
ageTo: this.searchCriteria.ageTo,
gender: this.searchCriteria.gender.map(g => g.value)
};
try {
const response = await apiClient.post('/api/socialnetwork/usersearch', searchCriteria);
this.searchResults = response.data;
} catch (error) {
console.error('Fehler bei der Suche:', error);
}
},
openUserProfile(id) {
this.$refs.userProfileDialog.userId = id;
this.$refs.userProfileDialog.open();
}
}
};
</script>
<style scoped>
.search-view {
max-width: 600px;
margin: 0 auto;
padding: 0;
}
h2 {
margin-bottom: 10px;
text-align: center;
}
.form-group {
display: flex;
align-items: center;
margin-bottom: 10px;
}
label {
width: 120px;
font-weight: bold;
margin-right: 10px;
text-align: right;
}
input,
.multiselect__input {
flex: 1;
padding: 5px;
border-radius: 4px;
border: 1px solid #ccc;
}
.age-input {
width: 70px;
margin-right: 10px;
}
.search-results {
margin-top: 20px;
}
.search-results ul {
list-style-type: none;
padding: 0;
}
.search-results li {
padding: 8px;
background: #f9f9f9;
border-bottom: 1px solid #ddd;
}
table {
margin: 0.5em 0;
padding: 0;
border-collapse: collapse;
}
thead {
color: #7BBE55;
}
th, td {
padding-right: 1em;
}
th, td:not:last-child {
border-bottom: 1px solid #7E471B;
}
.clickable {
cursor: pointer;
}
.no-results {
margin-top: 20px;
text-align: center;
color: #888;
}
.g-male {
color: #3377ff;
}
.g-female {
color: #ff3377;
}
</style>