Implement Court Drawing Dialog and enhance activity management in DiaryView and PredefinedActivities
Added a Court Drawing Dialog component to facilitate the creation and editing of drawing data for activities. Updated DiaryView.vue to include a button for opening the drawing dialog and handling the resulting data. Enhanced PredefinedActivities.vue to allow users to create or edit drawing data directly from the activity form, improving the overall user experience. Refactored related styles and logic for better integration and usability.
This commit is contained in:
168
frontend/src/components/CourtDrawingDialog.vue
Normal file
168
frontend/src/components/CourtDrawingDialog.vue
Normal file
@@ -0,0 +1,168 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
:model-value="modelValue"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
title="Tischtennis-Übung konfigurieren"
|
||||
size="large"
|
||||
:close-on-overlay="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<CourtDrawingTool
|
||||
ref="drawingTool"
|
||||
:drawing-data="initialDrawingData"
|
||||
:allow-image-upload="false"
|
||||
@update-drawing-data="handleDrawingDataUpdate"
|
||||
@update-fields="handleFieldsUpdate"
|
||||
/>
|
||||
|
||||
<template #footer>
|
||||
<button class="btn-secondary" @click="handleClose">Abbrechen</button>
|
||||
<button
|
||||
class="btn-primary"
|
||||
@click="handleOk"
|
||||
:disabled="!isValid"
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import BaseDialog from './BaseDialog.vue';
|
||||
import CourtDrawingTool from './CourtDrawingTool.vue';
|
||||
|
||||
export default {
|
||||
name: 'CourtDrawingDialog',
|
||||
components: {
|
||||
BaseDialog,
|
||||
CourtDrawingTool
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
initialDrawingData: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue', 'close', 'ok'],
|
||||
data() {
|
||||
return {
|
||||
currentDrawingData: null,
|
||||
currentFields: null
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isValid() {
|
||||
// Mindestens Aufschlag und Zielposition müssen gesetzt sein
|
||||
return this.currentDrawingData &&
|
||||
this.currentDrawingData.selectedStartPosition &&
|
||||
this.currentDrawingData.strokeType &&
|
||||
this.currentDrawingData.spinType &&
|
||||
this.currentDrawingData.targetPosition;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
initialDrawingData: {
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.currentDrawingData = { ...newVal };
|
||||
}
|
||||
},
|
||||
deep: true,
|
||||
immediate: true
|
||||
},
|
||||
modelValue(newVal) {
|
||||
// Wenn Dialog geöffnet wird, stelle sicher dass Tool neu gezeichnet wird
|
||||
if (newVal) {
|
||||
this.$nextTick(() => {
|
||||
// Warte bis der Dialog vollständig gerendert ist
|
||||
setTimeout(() => {
|
||||
if (this.$refs.drawingTool && this.$refs.drawingTool.drawCourt) {
|
||||
this.$refs.drawingTool.drawCourt(true);
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// Wenn Dialog bereits beim Mount geöffnet ist
|
||||
if (this.modelValue) {
|
||||
this.$nextTick(() => {
|
||||
setTimeout(() => {
|
||||
if (this.$refs.drawingTool && this.$refs.drawingTool.drawCourt) {
|
||||
this.$refs.drawingTool.drawCourt(true);
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleDrawingDataUpdate(data) {
|
||||
this.currentDrawingData = { ...data };
|
||||
},
|
||||
handleFieldsUpdate(fields) {
|
||||
this.currentFields = { ...fields };
|
||||
},
|
||||
handleClose() {
|
||||
this.$emit('update:modelValue', false);
|
||||
this.$emit('close');
|
||||
},
|
||||
handleOk() {
|
||||
if (this.isValid && this.currentDrawingData) {
|
||||
// Sammle alle Daten zusammen
|
||||
const result = {
|
||||
drawingData: { ...this.currentDrawingData },
|
||||
fields: this.currentFields ? { ...this.currentFields } : null,
|
||||
code: this.currentDrawingData.code || (this.currentFields ? this.currentFields.code : ''),
|
||||
name: this.currentFields ? this.currentFields.name : '',
|
||||
description: this.currentFields ? this.currentFields.description : ''
|
||||
};
|
||||
this.$emit('ok', result);
|
||||
this.handleClose();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.btn-primary,
|
||||
.btn-secondary {
|
||||
padding: 0.5rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #5a6268;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -96,7 +96,7 @@
|
||||
<span class="group-label">Zielposition:</span>
|
||||
<div class="target-grid">
|
||||
<button
|
||||
v-for="n in 9"
|
||||
v-for="n in mainTargetPositions"
|
||||
:key="`main-target-${n}`"
|
||||
type="button"
|
||||
:class="['grid-btn', { 'is-active': targetPosition === String(n) }]"
|
||||
@@ -205,7 +205,7 @@
|
||||
<span>Zielposition:</span>
|
||||
<div class="target-grid">
|
||||
<button
|
||||
v-for="n in 9"
|
||||
v-for="n in additionalTargetPositions"
|
||||
:key="`next-target-${n}`"
|
||||
type="button"
|
||||
:class="['grid-btn', { 'is-active': nextStrokeTargetPosition === String(n) }]"
|
||||
@@ -379,6 +379,28 @@ export default {
|
||||
nextStrokeTargetPosition: ''
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
// Reihenfolge der Positionen für Hauptschlag basierend auf Richtung
|
||||
mainTargetPositions() {
|
||||
const isLeftToRight = this.isMainStrokeLeftToRight();
|
||||
// Links nach rechts: 7 4 1 / 8 5 2 / 9 6 3
|
||||
// Rechts nach links: 3 6 9 / 2 5 8 / 1 4 7
|
||||
if (isLeftToRight) {
|
||||
return [7, 4, 1, 8, 5, 2, 9, 6, 3];
|
||||
} else {
|
||||
return [3, 6, 9, 2, 5, 8, 1, 4, 7];
|
||||
}
|
||||
},
|
||||
// Reihenfolge der Positionen für zusätzliche Schläge basierend auf Richtung
|
||||
additionalTargetPositions() {
|
||||
const isLeftToRight = this.isAdditionalStrokeLeftToRight();
|
||||
if (isLeftToRight) {
|
||||
return [7, 4, 1, 8, 5, 2, 9, 6, 3];
|
||||
} else {
|
||||
return [3, 6, 9, 2, 5, 8, 1, 4, 7];
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
strokeType() {
|
||||
this.emitDrawingData();
|
||||
@@ -905,6 +927,53 @@ export default {
|
||||
return idx % 2 === 0 ? 'left' : 'right';
|
||||
},
|
||||
|
||||
// Bestimmt ob der Hauptschlag von links nach rechts geht
|
||||
isMainStrokeLeftToRight() {
|
||||
// Bei Aufschlag immer links nach rechts
|
||||
if (this.selectedStartPosition && this.selectedStartPosition.startsWith('AS')) {
|
||||
return true;
|
||||
}
|
||||
// Wenn von links kommt, dann links nach rechts
|
||||
if (this.selectedCirclePosition === 'left') {
|
||||
return true;
|
||||
}
|
||||
// Wenn von rechts kommt, dann rechts nach links
|
||||
if (this.selectedCirclePosition === 'right') {
|
||||
return false;
|
||||
}
|
||||
// Standard: links nach rechts
|
||||
return true;
|
||||
},
|
||||
|
||||
// Bestimmt ob der nächste zusätzliche Schlag von links nach rechts geht
|
||||
isAdditionalStrokeLeftToRight() {
|
||||
// Wenn der Hauptschlag ein Aufschlag ist, endet er rechts
|
||||
// Dann kommt der erste zusätzliche Schlag von rechts → rechts nach links → false
|
||||
// Der zweite zusätzliche Schlag kommt dann von links → links nach rechts → true
|
||||
// etc.
|
||||
|
||||
// Wenn der Hauptschlag kein Aufschlag ist und von links kommt, endet er rechts
|
||||
// Dann kommt der erste zusätzliche Schlag von rechts → rechts nach links → false
|
||||
|
||||
// Wenn der Hauptschlag von rechts kommt, endet er links
|
||||
// Dann kommt der erste zusätzliche Schlag von links → links nach rechts → true
|
||||
|
||||
const nextIdx = this.additionalStrokes.length;
|
||||
const isMainStrokeEndingRight = this.isMainStrokeLeftToRight();
|
||||
|
||||
// Wenn der Hauptschlag rechts endet (links nach rechts), dann:
|
||||
// - erster zusätzlicher Schlag kommt von rechts → rechts nach links → false
|
||||
// - zweiter zusätzlicher Schlag kommt von links → links nach rechts → true
|
||||
if (isMainStrokeEndingRight) {
|
||||
return nextIdx % 2 === 1; // ungerade Index = links nach rechts
|
||||
} else {
|
||||
// Wenn der Hauptschlag links endet (rechts nach links), dann:
|
||||
// - erster zusätzlicher Schlag kommt von links → links nach rechts → true
|
||||
// - zweiter zusätzlicher Schlag kommt von rechts → rechts nach links → false
|
||||
return nextIdx % 2 === 0; // gerade Index = links nach rechts
|
||||
}
|
||||
},
|
||||
|
||||
// Berechne Mittelpunkt eines rechten Zielkreises (1..9)
|
||||
computeRightTargetCenter(tableX, tableY, tableWidth, tableHeight, number) {
|
||||
const cfg = this.config.targetCircles;
|
||||
|
||||
@@ -244,10 +244,20 @@
|
||||
@click="addGroupActivity">Gruppen-Aktivität</button>
|
||||
</td>
|
||||
<td v-if="addNewItem || addNewGroupActivity">
|
||||
<div v-if="addtype === 'activity'" style="position: relative;">
|
||||
<div v-if="addtype === 'activity'" style="position: relative; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-palette"
|
||||
@click="showDrawingDialog = true"
|
||||
title="Übungszeichnung erstellen"
|
||||
style="margin: 0; margin-left: 0;"
|
||||
>
|
||||
🎨
|
||||
</button>
|
||||
<input type="text" v-model="newPlanItem.activity"
|
||||
placeholder="Aktivität / Zeitblock" required
|
||||
@input="onNewItemInputChange" />
|
||||
@input="onNewItemInputChange"
|
||||
style="flex: 1;" />
|
||||
<div v-if="newItemShowDropdown && newItemSearchResults.length"
|
||||
class="dropdown" style="max-height: 9.5em;">
|
||||
<div v-for="s in newItemSearchResults" :key="s.id"
|
||||
@@ -400,6 +410,13 @@
|
||||
@submit="createAndAddMember"
|
||||
@close="closeQuickAddDialog"
|
||||
/>
|
||||
|
||||
<!-- Court Drawing Dialog -->
|
||||
<CourtDrawingDialog
|
||||
v-model="showDrawingDialog"
|
||||
:initial-drawing-data="null"
|
||||
@ok="handleDrawingDialogOkForDiary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Info Dialog -->
|
||||
@@ -430,6 +447,7 @@ import Multiselect from 'vue-multiselect';
|
||||
import Sortable from 'sortablejs';
|
||||
import PDFGenerator from '../components/PDFGenerator.js';
|
||||
import CourtDrawingRender from '../components/CourtDrawingRender.vue';
|
||||
import CourtDrawingDialog from '../components/CourtDrawingDialog.vue';
|
||||
import InfoDialog from '../components/InfoDialog.vue';
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue';
|
||||
import ImageDialog from '../components/ImageDialog.vue';
|
||||
@@ -444,6 +462,7 @@ export default {
|
||||
components: {
|
||||
Multiselect,
|
||||
CourtDrawingRender,
|
||||
CourtDrawingDialog,
|
||||
InfoDialog,
|
||||
ConfirmDialog,
|
||||
ImageDialog,
|
||||
@@ -558,7 +577,9 @@ export default {
|
||||
lastName: '',
|
||||
birthDate: '',
|
||||
gender: ''
|
||||
}
|
||||
},
|
||||
// Court Drawing Dialog
|
||||
showDrawingDialog: false
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
@@ -1731,10 +1752,58 @@ export default {
|
||||
|
||||
chooseNewItemSuggestion(s) {
|
||||
this.newPlanItem.activity = (s.code && s.code.trim() !== '') ? s.code : s.name;
|
||||
this.newPlanItem.durationText = s.durationText || '';
|
||||
this.newPlanItem.duration = s.duration || '';
|
||||
this.newItemShowDropdown = false;
|
||||
this.newItemSearchResults = [];
|
||||
},
|
||||
|
||||
async handleDrawingDialogOkForDiary(result) {
|
||||
if (!result || !result.code) {
|
||||
this.showInfo('Fehler', 'Keine Übungsdaten erhalten', '', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const code = result.code.trim();
|
||||
|
||||
// Suche nach existierender Aktivität mit diesem Code
|
||||
const searchResults = await this.searchPredefinedActivities(code);
|
||||
const existing = searchResults.find(a =>
|
||||
a.code && a.code.trim().toLowerCase() === code.toLowerCase()
|
||||
);
|
||||
|
||||
let activityToUse;
|
||||
|
||||
if (existing) {
|
||||
// Aktivität existiert bereits - verwende diese
|
||||
activityToUse = existing;
|
||||
} else {
|
||||
// Erstelle neue PredefinedActivity
|
||||
const newActivity = {
|
||||
name: result.name || result.fields?.name || '',
|
||||
code: code,
|
||||
description: result.description || result.fields?.description || '',
|
||||
drawingData: result.drawingData || null
|
||||
};
|
||||
|
||||
const response = await apiClient.post('/predefined-activities', newActivity);
|
||||
activityToUse = response.data;
|
||||
}
|
||||
|
||||
// Setze die Aktivität im Formular
|
||||
this.chooseNewItemSuggestion(activityToUse);
|
||||
|
||||
// Erstelle automatisch den Plan-Eintrag
|
||||
await this.addPlanItem();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Verarbeiten der Übungszeichnung:', error);
|
||||
const msg = error.response?.data?.error || 'Fehler beim Erstellen der Aktivität';
|
||||
this.showInfo('Fehler', msg, '', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async loadTrainingPlan() {
|
||||
try {
|
||||
this.trainingPlan = await apiClient.get(`/diary-date-activities/${this.currentClub}/${this.date.id}`).then(response => response.data);
|
||||
@@ -2651,6 +2720,29 @@ img {
|
||||
font-size: 0.9rem !important;
|
||||
}
|
||||
|
||||
.btn-palette {
|
||||
background: linear-gradient(135deg, #4CAF50, #45a049);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 0.25rem;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
width: 1.75rem;
|
||||
height: 0.75rem;
|
||||
margin: 0 !important;
|
||||
margin-left: 0 !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-palette:hover {
|
||||
background: linear-gradient(135deg, #45a049, #3d8b40);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.modal .btn-primary:hover {
|
||||
background-color: #0056b3 !important;
|
||||
}
|
||||
|
||||
@@ -47,6 +47,11 @@
|
||||
|
||||
<div class="detail" v-if="editModel">
|
||||
<h3>{{ editModel.id ? 'Aktivität bearbeiten' : 'Neue Aktivität' }}</h3>
|
||||
<div class="drawing-button-section">
|
||||
<button type="button" class="btn-secondary" @click="showDrawingDialog = true">
|
||||
{{ editModel.drawingData ? 'Übungszeichnung bearbeiten' : 'Übungszeichnung erstellen' }}
|
||||
</button>
|
||||
</div>
|
||||
<form @submit.prevent="save">
|
||||
<label>Name
|
||||
<input type="text" v-model="editModel.name" required />
|
||||
@@ -65,7 +70,7 @@
|
||||
</label>
|
||||
<div class="image-section">
|
||||
<h4>Bild hinzufügen</h4>
|
||||
<p class="image-help">Du kannst entweder einen Link zu einem Bild eingeben, ein Bild hochladen oder eine Übungszeichnung erstellen:</p>
|
||||
<p class="image-help">Du kannst entweder einen Link zu einem Bild eingeben oder ein Bild hochladen:</p>
|
||||
|
||||
<label>Bild-Link (optional)
|
||||
<input type="text" v-model="editModel.imageLink" placeholder="z.B. https://example.com/bild.jpg oder /api/predefined-activities/:id/image/:imageId" />
|
||||
@@ -83,18 +88,6 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Zeichen-Tool -->
|
||||
<div class="drawing-section">
|
||||
<h5>Übungszeichnung erstellen</h5>
|
||||
<CourtDrawingTool
|
||||
:activity-id="editModel.id"
|
||||
:drawing-data="editModel.drawingData"
|
||||
:allow-image-upload="false"
|
||||
@update-fields="onUpdateFields"
|
||||
@update-drawing-data="onUpdateDrawingData"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="image-list" v-if="images && images.length">
|
||||
<h5>Hochgeladene Bilder:</h5>
|
||||
<div class="image-grid">
|
||||
@@ -135,19 +128,25 @@
|
||||
@confirm="handleConfirmResult(true)"
|
||||
@cancel="handleConfirmResult(false)"
|
||||
/>
|
||||
|
||||
<!-- Court Drawing Dialog -->
|
||||
<CourtDrawingDialog
|
||||
v-model="showDrawingDialog"
|
||||
:initial-drawing-data="editModel ? editModel.drawingData : null"
|
||||
@ok="handleDrawingDialogOk"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import apiClient from '../apiClient.js';
|
||||
import CourtDrawingTool from '../components/CourtDrawingTool.vue';
|
||||
import CourtDrawingDialog from '../components/CourtDrawingDialog.vue';
|
||||
|
||||
import InfoDialog from '../components/InfoDialog.vue';
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue';
|
||||
export default {
|
||||
name: 'PredefinedActivities',
|
||||
components: {
|
||||
CourtDrawingTool
|
||||
,
|
||||
CourtDrawingDialog,
|
||||
InfoDialog,
|
||||
ConfirmDialog},
|
||||
data() {
|
||||
@@ -179,6 +178,7 @@ export default {
|
||||
searchQuery: '',
|
||||
searchResults: [],
|
||||
isSearching: false,
|
||||
showDrawingDialog: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -333,6 +333,25 @@ export default {
|
||||
async save() {
|
||||
if (!this.editModel) return;
|
||||
|
||||
// Prüfe auf Duplikate nur bei neuen Aktivitäten (nicht bei Updates)
|
||||
if (!this.editModel.id && this.editModel.code && this.editModel.code.trim()) {
|
||||
const codeToCheck = this.editModel.code.trim().toLowerCase();
|
||||
const existing = this.activities.find(a =>
|
||||
a.code && a.code.trim().toLowerCase() === codeToCheck
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
const confirmed = await this.showConfirm(
|
||||
'Aktivität existiert bereits',
|
||||
`Eine Aktivität mit dem Kürzel "${this.editModel.code}" existiert bereits:\n${this.formatItem(existing)}\n\nTrotzdem speichern?`,
|
||||
'',
|
||||
'warning'
|
||||
);
|
||||
if (!confirmed) {
|
||||
return; // Speichern abbrechen
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.editModel.id) {
|
||||
const { id, ...payload } = this.editModel;
|
||||
@@ -424,16 +443,22 @@ export default {
|
||||
}
|
||||
// Nicht automatisch speichern, nur wenn User explizit "Speichern" klickt
|
||||
},
|
||||
onUpdateFields(fields) {
|
||||
if (this.editModel) {
|
||||
this.editModel.code = fields.code;
|
||||
this.editModel.name = fields.name;
|
||||
this.editModel.description = fields.description;
|
||||
}
|
||||
},
|
||||
onUpdateDrawingData(data) {
|
||||
if (this.editModel) {
|
||||
this.editModel.drawingData = data;
|
||||
handleDrawingDialogOk(result) {
|
||||
if (this.editModel && result) {
|
||||
// Übernehme alle Daten vom Dialog
|
||||
if (result.drawingData) {
|
||||
this.editModel.drawingData = result.drawingData;
|
||||
}
|
||||
if (result.fields) {
|
||||
this.editModel.code = result.fields.code || result.code || '';
|
||||
this.editModel.name = result.fields.name || result.name || '';
|
||||
this.editModel.description = result.fields.description || result.description || '';
|
||||
} else {
|
||||
// Fallback falls fields nicht vorhanden
|
||||
if (result.code) this.editModel.code = result.code;
|
||||
if (result.name) this.editModel.name = result.name;
|
||||
if (result.description) this.editModel.description = result.description;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -586,18 +611,8 @@ input[type="text"], input[type="number"], textarea { width: 100%; }
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.drawing-section {
|
||||
.drawing-button-section {
|
||||
margin: 1rem 0;
|
||||
padding: 1rem;
|
||||
background: white;
|
||||
border-radius: var(--border-radius);
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.drawing-section h5 {
|
||||
margin: 0 0 1rem 0;
|
||||
color: #333;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Suchfeld Styles */
|
||||
|
||||
Reference in New Issue
Block a user