Challenge-Logik überarbeitet und fertig gestellt
This commit is contained in:
parent
909eb97bbb
commit
5199b32471
@ -296,6 +296,7 @@ localStorage.setItem('auth', JSON.stringify({
|
|||||||
- Fehlersuche in asynchronen Promise-Ketten
|
- Fehlersuche in asynchronen Promise-Ketten
|
||||||
- Code-Style-Verbesserungen (ES6+ Best Practices)
|
- Code-Style-Verbesserungen (ES6+ Best Practices)
|
||||||
- Regex-Optimierungen für Text-Vergleich
|
- Regex-Optimierungen für Text-Vergleich
|
||||||
|
- Generierung visueller Assets: Logo, Icon sowie Challenge-Grafiken für Gewonnen/Verloren/Unentschieden wurden mit ChatGPT erstellt und anschließend im Projekt eingebunden
|
||||||
|
|
||||||
**Kritische Bewertung:**
|
**Kritische Bewertung:**
|
||||||
- ✅ **Effektiv für**: Schnelle Fehlerbehebung, Best-Practice-Vorschläge
|
- ✅ **Effektiv für**: Schnelle Fehlerbehebung, Best-Practice-Vorschläge
|
||||||
|
|||||||
@ -109,6 +109,13 @@ p {
|
|||||||
.btn:hover {
|
.btn:hover {
|
||||||
background-color: #2b4a7c;
|
background-color: #2b4a7c;
|
||||||
}
|
}
|
||||||
|
.btn:disabled,
|
||||||
|
.btn.disabled {
|
||||||
|
background-color: #adb5bd;
|
||||||
|
color: #495057;
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
background-color: #a84848;
|
background-color: #a84848;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
@ -370,6 +377,51 @@ p {
|
|||||||
background: #fff4f4;
|
background: #fff4f4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.challenge-message-group {
|
||||||
|
border-left-color: #6f7fb7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge-thread {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge-thread.d-none {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge-thread-toggle {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #4a6fa5;
|
||||||
|
display: inline-flex;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge-thread-toggle:hover {
|
||||||
|
color: #2b4a7c;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge-thread-item {
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f8fafc;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge-thread-item span {
|
||||||
|
display: block;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.message-meta {
|
.message-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@ -389,6 +441,12 @@ p {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.challenge-status-button:disabled {
|
||||||
|
background-color: #e5e7eb;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
.challenge-result-graphic,
|
.challenge-result-graphic,
|
||||||
.play-challenge-result {
|
.play-challenge-result {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
@ -405,6 +463,38 @@ p {
|
|||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.challenge-outcome {
|
||||||
|
align-items: center;
|
||||||
|
background: #f8fafc;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge-outcome img {
|
||||||
|
border-radius: 8px;
|
||||||
|
height: 72px;
|
||||||
|
object-fit: cover;
|
||||||
|
width: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge-outcome strong {
|
||||||
|
color: #1b1b2f;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge-outcome-win {
|
||||||
|
background: #e8f7ee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge-outcome-loss {
|
||||||
|
background: #fdecec;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge-outcome-draw {
|
||||||
|
background: #eef3fb;
|
||||||
|
}
|
||||||
|
|
||||||
.challenge-result-win,
|
.challenge-result-win,
|
||||||
.play-challenge-result-winner .play-challenge-result-title {
|
.play-challenge-result-winner .play-challenge-result-title {
|
||||||
background: #198754;
|
background: #198754;
|
||||||
|
|||||||
BIN
docs/Bewertungsraster_Frontend.xlsx
Normal file
BIN
docs/Bewertungsraster_Frontend.xlsx
Normal file
Binary file not shown.
BIN
image/sieg.png
Normal file
BIN
image/sieg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 111 KiB |
BIN
image/unentschieden.png
Normal file
BIN
image/unentschieden.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 144 KiB |
BIN
image/verloren.png
Normal file
BIN
image/verloren.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 118 KiB |
@ -7,7 +7,7 @@
|
|||||||
<link rel="icon" type="image/png" href="image/icon_l.png">
|
<link rel="icon" type="image/png" href="image/icon_l.png">
|
||||||
<!-- Bootstrap CSS -->
|
<!-- Bootstrap CSS -->
|
||||||
<link rel="stylesheet" href="assets/bootstrap-5.3.8-dist/css/bootstrap.min.css">
|
<link rel="stylesheet" href="assets/bootstrap-5.3.8-dist/css/bootstrap.min.css">
|
||||||
<link rel="stylesheet" href="assets/css/custom.css">
|
<link rel="stylesheet" href="assets/css/custom.css?v=challenge-flow-20260528e">
|
||||||
|
|
||||||
<!-- Kleine Korrektur direkt hier, falls Sie custom.css nicht sofort ändern wollen -->
|
<!-- Kleine Korrektur direkt hier, falls Sie custom.css nicht sofort ändern wollen -->
|
||||||
<style>
|
<style>
|
||||||
@ -136,9 +136,9 @@
|
|||||||
<script src="js/login.js"></script>
|
<script src="js/login.js"></script>
|
||||||
<script src="js/leaderboard.js"></script>
|
<script src="js/leaderboard.js"></script>
|
||||||
<script src="js/scores.js"></script>
|
<script src="js/scores.js"></script>
|
||||||
<script src="js/messages.js?v=challenge-flow-20260526"></script>
|
<script src="js/messages.js?v=challenge-flow-20260528f"></script>
|
||||||
<!--Navigation Script -->
|
<!--Navigation Script -->
|
||||||
<script src="js/play.js?v=challenge-flow-20260525b"></script>
|
<script src="js/play.js?v=challenge-flow-20260528d"></script>
|
||||||
<script src="js/navigation.js?v=challenge-flow-20260526"></script>
|
<script src="js/navigation.js?v=challenge-flow-20260528"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
323
js/messages.js
323
js/messages.js
@ -51,6 +51,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeMessage(message) {
|
function normalizeMessage(message) {
|
||||||
|
// Das Backend und unser Nachrichten-Fallback liefern Challenge-Daten leicht unterschiedlich.
|
||||||
|
// Hier werden beide Formen in ein einheitliches Message-Objekt gebracht.
|
||||||
const embeddedChallenge = extractEmbeddedChallenge(message.text ?? "");
|
const embeddedChallenge = extractEmbeddedChallenge(message.text ?? "");
|
||||||
const type = message.type ?? MESSAGE_TYPE_CHALLENGE;
|
const type = message.type ?? MESSAGE_TYPE_CHALLENGE;
|
||||||
const challenge = message.challenge
|
const challenge = message.challenge
|
||||||
@ -71,6 +73,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function extractEmbeddedChallenge(text) {
|
function extractEmbeddedChallenge(text) {
|
||||||
|
// Fallback fuer Challenge-Daten, die im Nachrichtentext mitgesendet werden.
|
||||||
|
// So koennen wir bestehende Message-Endpunkte nutzen, ohne neue Backend-Felder zu verlangen.
|
||||||
const rawText = String(text ?? "");
|
const rawText = String(text ?? "");
|
||||||
if (!rawText.startsWith(CHALLENGE_DATA_PREFIX)) {
|
if (!rawText.startsWith(CHALLENGE_DATA_PREFIX)) {
|
||||||
return {
|
return {
|
||||||
@ -180,7 +184,62 @@
|
|||||||
&& !hasScore(challenge.challengerScore);
|
&& !hasScore(challenge.challengerScore);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getChallengeButtonState(message) {
|
||||||
|
// Bestimmt den sichtbaren Status einer Challenge aus Sicht des eingeloggten Users.
|
||||||
|
// Dadurch gibt es pro Challenge genau einen aktiven oder deaktivierten Button.
|
||||||
|
const challenge = message.challenge;
|
||||||
|
const role = getChallengeRole(message);
|
||||||
|
if (!challenge || !role) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const opponentScore = getOpponentScore(challenge);
|
||||||
|
const challengerHasScore = hasScore(challenge.challengerScore);
|
||||||
|
const opponentHasScore = hasScore(opponentScore);
|
||||||
|
|
||||||
|
if (challengerHasScore && opponentHasScore) {
|
||||||
|
return {
|
||||||
|
disabled: true,
|
||||||
|
label: "Challenge erledigt",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === "opponent") {
|
||||||
|
if (!opponentHasScore && !challengerHasScore) {
|
||||||
|
return {
|
||||||
|
disabled: false,
|
||||||
|
label: "Challenge annehmen",
|
||||||
|
role: "opponent",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
disabled: true,
|
||||||
|
label: "Schon gespielt",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === "challenger") {
|
||||||
|
if (opponentHasScore && !challengerHasScore) {
|
||||||
|
return {
|
||||||
|
disabled: false,
|
||||||
|
label: "Challenge spielen",
|
||||||
|
role: "challenger",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
disabled: true,
|
||||||
|
label: "Warte auf Gegner",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function startChallenge(message, role) {
|
function startChallenge(message, role) {
|
||||||
|
// Der aktive Challenge-Kontext wird nur fuer die naechste Spielrunde im Session Storage abgelegt.
|
||||||
|
// play.js liest diesen Zustand aus und weiss dadurch, ob es die erste oder finale Runde ist.
|
||||||
const challenge = message.challenge;
|
const challenge = message.challenge;
|
||||||
const challenger = getChallengeChallenger(challenge, message.sender);
|
const challenger = getChallengeChallenger(challenge, message.sender);
|
||||||
const opponent = getChallengeOpponent(challenge, message.recipient);
|
const opponent = getChallengeOpponent(challenge, message.recipient);
|
||||||
@ -263,6 +322,148 @@
|
|||||||
navLink.classList.toggle("has-unread-messages", hasUnreadMessages);
|
navLink.classList.toggle("has-unread-messages", hasUnreadMessages);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function markMessageReadOnClick(message, item) {
|
||||||
|
const auth = getAuth();
|
||||||
|
const messageService = getMessageService();
|
||||||
|
if (!auth || !messageService || !message.id || message.read) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isIncoming = normalizeUsername(message.recipient) === normalizeUsername(auth.username)
|
||||||
|
|| normalizeUsername(message.sender) !== normalizeUsername(auth.username);
|
||||||
|
if (!isIncoming) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
message.read = true;
|
||||||
|
item.classList.remove("message-item-unread");
|
||||||
|
updateMessagesNavState(currentMessages);
|
||||||
|
|
||||||
|
const result = await messageService.markMessageAsRead(
|
||||||
|
auth.username,
|
||||||
|
auth.password,
|
||||||
|
message.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
message.read = false;
|
||||||
|
item.classList.add("message-item-unread");
|
||||||
|
updateMessagesNavState(currentMessages);
|
||||||
|
setFeedback("Nachricht konnte nicht als gelesen markiert werden.", "warning");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markMessageGroupReadOnClick(messages, item) {
|
||||||
|
// Eine gruppierte Challenge-Karte kann mehrere einzelne Nachrichten enthalten.
|
||||||
|
// Beim Anklicken werden deshalb alle ungelesenen eingehenden Nachrichten der Gruppe markiert.
|
||||||
|
const auth = getAuth();
|
||||||
|
const messageService = getMessageService();
|
||||||
|
if (!auth || !messageService) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unreadIncomingMessages = messages.filter((message) => {
|
||||||
|
const isIncoming = normalizeUsername(message.recipient) === normalizeUsername(auth.username)
|
||||||
|
|| normalizeUsername(message.sender) !== normalizeUsername(auth.username);
|
||||||
|
return isIncoming && !message.read && message.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (unreadIncomingMessages.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
unreadIncomingMessages.forEach((message) => {
|
||||||
|
message.read = true;
|
||||||
|
});
|
||||||
|
item.classList.remove("message-item-unread");
|
||||||
|
updateMessagesNavState(currentMessages);
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
unreadIncomingMessages.map((message) =>
|
||||||
|
messageService.markMessageAsRead(auth.username, auth.password, message.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (results.some((result) => !result.ok)) {
|
||||||
|
unreadIncomingMessages.forEach((message) => {
|
||||||
|
message.read = false;
|
||||||
|
});
|
||||||
|
item.classList.add("message-item-unread");
|
||||||
|
updateMessagesNavState(currentMessages);
|
||||||
|
setFeedback("Challenge-Nachrichten konnten nicht als gelesen markiert werden.", "warning");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMessageTimeValue(message) {
|
||||||
|
const time = new Date(message.createdAt).getTime();
|
||||||
|
return Number.isNaN(time) ? 0 : time;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeChallengeData(messages) {
|
||||||
|
// Fuer die Challenge-Karte werden Teildaten aus mehreren Nachrichten zusammengefuehrt
|
||||||
|
// (z. B. zuerst Challenge-ID, spaeter Gegner-Score, am Schluss Gewinner).
|
||||||
|
return messages
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => getMessageTimeValue(a) - getMessageTimeValue(b))
|
||||||
|
.reduce((merged, message) => {
|
||||||
|
if (!message.challenge) {
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.keys(message.challenge).forEach((key) => {
|
||||||
|
const value = message.challenge[key];
|
||||||
|
if (value !== null && value !== undefined && value !== "") {
|
||||||
|
merged[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupMessagesByChallenge(messages) {
|
||||||
|
// Nachrichten mit gleicher Challenge-ID werden zu einer Karte gruppiert,
|
||||||
|
// damit alte Zwischen-Nachrichten nicht einzeln die Inbox ueberladen.
|
||||||
|
const groupsByChallenge = new Map();
|
||||||
|
const standaloneGroups = [];
|
||||||
|
|
||||||
|
messages.forEach((message) => {
|
||||||
|
const challengeId = getChallengeId(message.challenge);
|
||||||
|
if (challengeId === null || challengeId === undefined) {
|
||||||
|
standaloneGroups.push({
|
||||||
|
kind: "message",
|
||||||
|
latest: message,
|
||||||
|
messages: [message],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = String(challengeId);
|
||||||
|
if (!groupsByChallenge.has(key)) {
|
||||||
|
groupsByChallenge.set(key, []);
|
||||||
|
}
|
||||||
|
groupsByChallenge.get(key).push(message);
|
||||||
|
});
|
||||||
|
|
||||||
|
const challengeGroups = Array.from(groupsByChallenge.values()).map((groupMessages) => {
|
||||||
|
const sortedMessages = groupMessages
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => getMessageTimeValue(b) - getMessageTimeValue(a));
|
||||||
|
const latest = { ...sortedMessages[0] };
|
||||||
|
latest.challenge = mergeChallengeData(sortedMessages);
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: "challenge",
|
||||||
|
latest: latest,
|
||||||
|
messages: sortedMessages,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return standaloneGroups
|
||||||
|
.concat(challengeGroups)
|
||||||
|
.sort((a, b) => getMessageTimeValue(b.latest) - getMessageTimeValue(a.latest));
|
||||||
|
}
|
||||||
|
|
||||||
function renderUserList(users) {
|
function renderUserList(users) {
|
||||||
const userList = document.getElementById("messages-user-list");
|
const userList = document.getElementById("messages-user-list");
|
||||||
const recipientSelect = document.getElementById("challenge-recipient");
|
const recipientSelect = document.getElementById("challenge-recipient");
|
||||||
@ -308,6 +509,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderMessages(messages = currentMessages) {
|
function renderMessages(messages = currentMessages) {
|
||||||
|
// Rendert normale Nachrichten einzeln und Challenge-Nachrichten als gruppierte Karten.
|
||||||
const messageList = document.getElementById("message-list");
|
const messageList = document.getElementById("message-list");
|
||||||
if (!messageList) {
|
if (!messageList) {
|
||||||
return;
|
return;
|
||||||
@ -325,21 +527,31 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
messages
|
groupMessagesByChallenge(messages)
|
||||||
.slice()
|
.forEach((messageGroup) => {
|
||||||
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
const message = messageGroup.latest;
|
||||||
.forEach((message) => {
|
|
||||||
const item = document.createElement("article");
|
const item = document.createElement("article");
|
||||||
const isOutgoing = normalizeUsername(message.sender) === ownName;
|
const isOutgoing = normalizeUsername(message.sender) === ownName;
|
||||||
|
const hasUnreadIncoming = messageGroup.messages.some((groupMessage) => {
|
||||||
|
const isIncoming = normalizeUsername(groupMessage.recipient) === ownName
|
||||||
|
|| normalizeUsername(groupMessage.sender) !== ownName;
|
||||||
|
return isIncoming && !groupMessage.read;
|
||||||
|
});
|
||||||
|
|
||||||
item.className = "message-item";
|
item.className = "message-item";
|
||||||
if (!message.read && !isOutgoing) {
|
if (messageGroup.kind === "challenge") {
|
||||||
|
item.classList.add("challenge-message-group");
|
||||||
|
}
|
||||||
|
if (hasUnreadIncoming) {
|
||||||
item.classList.add("message-item-unread");
|
item.classList.add("message-item-unread");
|
||||||
|
item.addEventListener("click", () => markMessageGroupReadOnClick(messageGroup.messages, item));
|
||||||
}
|
}
|
||||||
|
|
||||||
const fromToText = isOutgoing
|
const fromToText = messageGroup.kind === "challenge"
|
||||||
? "An " + message.recipient
|
? "Challenge"
|
||||||
: "Von " + message.sender;
|
: isOutgoing
|
||||||
|
? "An " + message.recipient
|
||||||
|
: "Von " + message.sender;
|
||||||
|
|
||||||
const meta = document.createElement("div");
|
const meta = document.createElement("div");
|
||||||
meta.className = "message-meta";
|
meta.className = "message-meta";
|
||||||
@ -356,26 +568,56 @@
|
|||||||
meta.append(sender, time);
|
meta.append(sender, time);
|
||||||
item.append(meta, text);
|
item.append(meta, text);
|
||||||
|
|
||||||
if (message.type === MESSAGE_TYPE_CHALLENGE_RESULT && message.challenge) {
|
if (messageGroup.kind === "challenge" && messageGroup.messages.length > 1) {
|
||||||
|
const toggleButton = document.createElement("button");
|
||||||
|
toggleButton.type = "button";
|
||||||
|
toggleButton.className = "challenge-thread-toggle";
|
||||||
|
toggleButton.textContent = "Verlauf anzeigen";
|
||||||
|
|
||||||
|
const thread = document.createElement("div");
|
||||||
|
thread.className = "challenge-thread d-none";
|
||||||
|
|
||||||
|
messageGroup.messages.forEach((threadMessage) => {
|
||||||
|
const threadItem = document.createElement("div");
|
||||||
|
threadItem.className = "challenge-thread-item";
|
||||||
|
|
||||||
|
const threadMeta = document.createElement("span");
|
||||||
|
threadMeta.textContent = (normalizeUsername(threadMessage.sender) === ownName
|
||||||
|
? "Du"
|
||||||
|
: threadMessage.sender || "System") + " · " + formatMessageTime(threadMessage.createdAt);
|
||||||
|
|
||||||
|
const threadText = document.createElement("p");
|
||||||
|
threadText.textContent = threadMessage.text;
|
||||||
|
|
||||||
|
threadItem.append(threadMeta, threadText);
|
||||||
|
thread.appendChild(threadItem);
|
||||||
|
});
|
||||||
|
|
||||||
|
toggleButton.addEventListener("click", (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
const isCollapsed = thread.classList.toggle("d-none");
|
||||||
|
toggleButton.textContent = isCollapsed ? "Verlauf anzeigen" : "Verlauf ausblenden";
|
||||||
|
});
|
||||||
|
|
||||||
|
item.appendChild(toggleButton);
|
||||||
|
item.appendChild(thread);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.challenge && hasScore(message.challenge.challengerScore) && hasScore(message.challenge.opponentScore)) {
|
||||||
item.appendChild(createChallengeResultGraphic(message.challenge));
|
item.appendChild(createChallengeResultGraphic(message.challenge));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (canOpponentAcceptChallenge(message)) {
|
const challengeButtonState = getChallengeButtonState(message);
|
||||||
const acceptButton = document.createElement("button");
|
if (challengeButtonState) {
|
||||||
acceptButton.type = "button";
|
const challengeButton = document.createElement("button");
|
||||||
acceptButton.className = "btn btn-sm mt-3";
|
challengeButton.type = "button";
|
||||||
acceptButton.textContent = "Challenge annehmen";
|
challengeButton.className = "btn btn-sm mt-3 challenge-status-button";
|
||||||
acceptButton.addEventListener("click", () => startChallenge(message, "opponent"));
|
challengeButton.textContent = challengeButtonState.label;
|
||||||
item.appendChild(acceptButton);
|
challengeButton.disabled = challengeButtonState.disabled;
|
||||||
}
|
if (!challengeButtonState.disabled) {
|
||||||
|
challengeButton.addEventListener("click", () => startChallenge(message, challengeButtonState.role));
|
||||||
if (canChallengerPlayChallenge(message)) {
|
}
|
||||||
const playButton = document.createElement("button");
|
item.appendChild(challengeButton);
|
||||||
playButton.type = "button";
|
|
||||||
playButton.className = "btn btn-sm mt-3";
|
|
||||||
playButton.textContent = "Challenge spielen";
|
|
||||||
playButton.addEventListener("click", () => startChallenge(message, "challenger"));
|
|
||||||
item.appendChild(playButton);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
messageList.appendChild(item);
|
messageList.appendChild(item);
|
||||||
@ -383,11 +625,38 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createChallengeResultGraphic(result) {
|
function createChallengeResultGraphic(result) {
|
||||||
|
// Baut die kompakte Ergebnisgrafik mit passendem Bild aus Sicht des aktuellen Users.
|
||||||
const graphic = document.createElement("div");
|
const graphic = document.createElement("div");
|
||||||
graphic.className = "challenge-result-graphic";
|
graphic.className = "challenge-result-graphic";
|
||||||
|
|
||||||
const winnerName = result.winner ?? "Unentschieden";
|
const winnerName = result.winner ?? "Unentschieden";
|
||||||
const isDraw = result.winner === null || result.winner === "draw";
|
const isDraw = result.winner === null || result.winner === "draw";
|
||||||
|
const auth = getAuth();
|
||||||
|
const isOwnWin = !isDraw && normalizeUsername(winnerName) === normalizeUsername(auth?.username);
|
||||||
|
const outcome = isDraw ? "draw" : isOwnWin ? "win" : "loss";
|
||||||
|
const outcomeText = outcome === "draw"
|
||||||
|
? "Unentschieden"
|
||||||
|
: outcome === "win"
|
||||||
|
? "Gewonnen"
|
||||||
|
: "Verloren";
|
||||||
|
const outcomeImage = outcome === "draw"
|
||||||
|
? "image/unentschieden.png"
|
||||||
|
: outcome === "win"
|
||||||
|
? "image/sieg.png"
|
||||||
|
: "image/verloren.png";
|
||||||
|
|
||||||
|
const outcomeHeader = document.createElement("div");
|
||||||
|
outcomeHeader.className = "challenge-outcome challenge-outcome-" + outcome;
|
||||||
|
|
||||||
|
const outcomeImg = document.createElement("img");
|
||||||
|
outcomeImg.src = outcomeImage;
|
||||||
|
outcomeImg.alt = outcomeText;
|
||||||
|
|
||||||
|
const outcomeLabel = document.createElement("strong");
|
||||||
|
outcomeLabel.textContent = outcomeText;
|
||||||
|
|
||||||
|
outcomeHeader.append(outcomeImg, outcomeLabel);
|
||||||
|
|
||||||
const headline = document.createElement("div");
|
const headline = document.createElement("div");
|
||||||
headline.className = "challenge-result-headline " + (isDraw ? "challenge-result-draw" : "challenge-result-win");
|
headline.className = "challenge-result-headline " + (isDraw ? "challenge-result-draw" : "challenge-result-win");
|
||||||
headline.textContent = isDraw ? "Unentschieden" : "Sieger: " + winnerName;
|
headline.textContent = isDraw ? "Unentschieden" : "Sieger: " + winnerName;
|
||||||
@ -408,7 +677,7 @@
|
|||||||
opponent.querySelector("span").textContent = String(result.opponentScore ?? "-") + " Punkte";
|
opponent.querySelector("span").textContent = String(result.opponentScore ?? "-") + " Punkte";
|
||||||
|
|
||||||
scores.append(challenger, opponent);
|
scores.append(challenger, opponent);
|
||||||
graphic.append(headline, scores);
|
graphic.append(outcomeHeader, headline, scores);
|
||||||
|
|
||||||
return graphic;
|
return graphic;
|
||||||
}
|
}
|
||||||
@ -432,6 +701,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadMessages(options = {}) {
|
async function loadMessages(options = {}) {
|
||||||
|
// Laedt Nachrichten vom Backend und blendet die Seite bei fehlendem Login in einen Hinweiszustand.
|
||||||
const auth = getAuth();
|
const auth = getAuth();
|
||||||
const messageService = getMessageService();
|
const messageService = getMessageService();
|
||||||
const loggedInDiv = document.getElementById("messages-content");
|
const loggedInDiv = document.getElementById("messages-content");
|
||||||
@ -478,6 +748,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleChallengeSubmit(event) {
|
async function handleChallengeSubmit(event) {
|
||||||
|
// Erstellt eine Challenge. Der Gegner spielt zuerst; der weitere Ablauf wird ueber Nachrichten gesteuert.
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const auth = getAuth();
|
const auth = getAuth();
|
||||||
|
|||||||
@ -9,6 +9,9 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
const activeLink = document.getElementById(id);
|
const activeLink = document.getElementById(id);
|
||||||
if (activeLink) activeLink.classList.add("active");
|
if (activeLink) activeLink.classList.add("active");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Laedt Teilseiten dynamisch in den Hauptbereich.
|
||||||
|
// Danach wird die passende Init-Funktion aufgerufen, weil die Elemente erst nach dem Laden existieren.
|
||||||
window.loadPage = function loadPage(page, menuId) {
|
window.loadPage = function loadPage(page, menuId) {
|
||||||
fetch("pages/" + page + ".html", { cache: "no-store" })
|
fetch("pages/" + page + ".html", { cache: "no-store" })
|
||||||
.then(response => {
|
.then(response => {
|
||||||
@ -42,6 +45,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Navigation wird zentral verdrahtet, damit alle Seiten ueber denselben Lade-Mechanismus laufen.
|
||||||
const navHome = document.getElementById("nav-home");
|
const navHome = document.getElementById("nav-home");
|
||||||
const navPlay = document.getElementById("nav-play");
|
const navPlay = document.getElementById("nav-play");
|
||||||
const navMyScores = document.getElementById("nav-my-scores");
|
const navMyScores = document.getElementById("nav-my-scores");
|
||||||
|
|||||||
168
js/play.js
168
js/play.js
@ -178,7 +178,13 @@
|
|||||||
|
|
||||||
// Entfernt alles, was beim Vergleichen nicht zaehlen soll.
|
// Entfernt alles, was beim Vergleichen nicht zaehlen soll.
|
||||||
function normalizeWord(word) {
|
function normalizeWord(word) {
|
||||||
return word.toLowerCase().replace(/[.,\/#!$%\^&\*;:{}=\-_`~()]/g, "");
|
return word
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/ä/g, "ae")
|
||||||
|
.replace(/ö/g, "oe")
|
||||||
|
.replace(/ü/g, "ue")
|
||||||
|
.replace(/ß/g, "ss")
|
||||||
|
.replace(/[.,\/#!$%\^&\*;:{}=\-_`~()]/g, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Behält die sichtbaren Woerter separat, damit Satzzeichen in der Ergebnisanzeige erhalten bleiben.
|
// Behält die sichtbaren Woerter separat, damit Satzzeichen in der Ergebnisanzeige erhalten bleiben.
|
||||||
@ -276,6 +282,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readActiveChallenge() {
|
function readActiveChallenge() {
|
||||||
|
// Challenge-Runden werden aus der Nachrichten-Seite gestartet.
|
||||||
|
// Der dafuer gespeicherte Kontext entscheidet, welcher API-/Nachrichten-Flow nach dem Spiel laeuft.
|
||||||
const raw = sessionStorage.getItem(ACTIVE_CHALLENGE_STORAGE_KEY);
|
const raw = sessionStorage.getItem(ACTIVE_CHALLENGE_STORAGE_KEY);
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
return null;
|
return null;
|
||||||
@ -298,6 +306,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isChallengeSecondRound() {
|
function isChallengeSecondRound() {
|
||||||
|
// In der zweiten Runde spielt der Herausforderer gegen den bereits bekannten Gegner-Score.
|
||||||
return hasActiveChallenge()
|
return hasActiveChallenge()
|
||||||
&& activeChallenge.role === "challenger"
|
&& activeChallenge.role === "challenger"
|
||||||
&& activeChallenge.opponentScore !== null
|
&& activeChallenge.opponentScore !== null
|
||||||
@ -305,6 +314,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isChallengeFirstRound() {
|
function isChallengeFirstRound() {
|
||||||
|
// In der ersten Runde spielt der herausgeforderte User und sendet sein Resultat an den Herausforderer.
|
||||||
return hasActiveChallenge() && activeChallenge.role === "opponent";
|
return hasActiveChallenge() && activeChallenge.role === "opponent";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -317,6 +327,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveScore(scoreData) {
|
async function saveScore(scoreData) {
|
||||||
|
// Normale Spielrunden werden direkt im Score-/Leaderboard-Backend gespeichert.
|
||||||
|
// Challenge-Runden verwenden je nach Rolle einen separaten Ablauf weiter unten.
|
||||||
const auth = getAuth();
|
const auth = getAuth();
|
||||||
if (!auth || !auth.username || !auth.password) {
|
if (!auth || !auth.username || !auth.password) {
|
||||||
showScoreSaveFeedback(
|
showScoreSaveFeedback(
|
||||||
@ -374,6 +386,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderChallengeResult(score) {
|
function renderChallengeResult(score) {
|
||||||
|
// Zeigt das lokale Ergebnis der finalen Challenge-Runde sofort an,
|
||||||
|
// unabhaengig davon, ob der Backend-Abschluss erfolgreich ist.
|
||||||
if (!scoreSaveFeedback || !isChallengeSecondRound()) {
|
if (!scoreSaveFeedback || !isChallengeSecondRound()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -397,10 +411,32 @@
|
|||||||
: result === "loser"
|
: result === "loser"
|
||||||
? opponentName + " gewinnt die Challenge"
|
? opponentName + " gewinnt die Challenge"
|
||||||
: "Unentschieden";
|
: "Unentschieden";
|
||||||
|
const outcomeText = result === "winner"
|
||||||
|
? "Gewonnen"
|
||||||
|
: result === "loser"
|
||||||
|
? "Verloren"
|
||||||
|
: "Unentschieden";
|
||||||
|
const outcomeImage = result === "winner"
|
||||||
|
? "image/sieg.png"
|
||||||
|
: result === "loser"
|
||||||
|
? "image/verloren.png"
|
||||||
|
: "image/unentschieden.png";
|
||||||
|
|
||||||
const graphic = document.createElement("div");
|
const graphic = document.createElement("div");
|
||||||
graphic.className = "play-challenge-result play-challenge-result-" + result;
|
graphic.className = "play-challenge-result play-challenge-result-" + result;
|
||||||
|
|
||||||
|
const outcomeHeader = document.createElement("div");
|
||||||
|
outcomeHeader.className = "challenge-outcome challenge-outcome-" + (result === "winner" ? "win" : result === "loser" ? "loss" : "draw");
|
||||||
|
|
||||||
|
const outcomeImg = document.createElement("img");
|
||||||
|
outcomeImg.src = outcomeImage;
|
||||||
|
outcomeImg.alt = outcomeText;
|
||||||
|
|
||||||
|
const outcomeLabel = document.createElement("strong");
|
||||||
|
outcomeLabel.textContent = outcomeText;
|
||||||
|
|
||||||
|
outcomeHeader.append(outcomeImg, outcomeLabel);
|
||||||
|
|
||||||
const title = document.createElement("div");
|
const title = document.createElement("div");
|
||||||
title.className = "play-challenge-result-title";
|
title.className = "play-challenge-result-title";
|
||||||
title.textContent = headline;
|
title.textContent = headline;
|
||||||
@ -421,11 +457,13 @@
|
|||||||
otherScore.querySelector("span").textContent = opponentScore + " Punkte";
|
otherScore.querySelector("span").textContent = opponentScore + " Punkte";
|
||||||
|
|
||||||
scores.append(ownScore, otherScore);
|
scores.append(ownScore, otherScore);
|
||||||
graphic.append(title, scores);
|
graphic.append(outcomeHeader, title, scores);
|
||||||
scoreSaveFeedback.insertAdjacentElement("afterend", graphic);
|
scoreSaveFeedback.insertAdjacentElement("afterend", graphic);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function completeChallenge(scoreData) {
|
async function completeChallenge(scoreData) {
|
||||||
|
// Primaerer Abschluss ueber das Challenge-Backend.
|
||||||
|
// Wenn dieser Endpoint ablehnt, faellt submitScore auf den Nachrichten-Fallback zurueck.
|
||||||
const auth = getAuth();
|
const auth = getAuth();
|
||||||
if (!auth || !hasActiveChallenge()) {
|
if (!auth || !hasActiveChallenge()) {
|
||||||
return;
|
return;
|
||||||
@ -451,11 +489,8 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
showScoreSaveFeedback(
|
console.warn("Challenge-Abschluss nicht moeglich, nutze Nachrichten-Fallback. Status:", result.status);
|
||||||
"Challenge konnte nicht abgeschlossen werden (Status " + result.status + ").",
|
return false;
|
||||||
"danger",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionStorage.removeItem(ACTIVE_CHALLENGE_STORAGE_KEY);
|
sessionStorage.removeItem(ACTIVE_CHALLENGE_STORAGE_KEY);
|
||||||
@ -468,9 +503,12 @@
|
|||||||
if (typeof window.updateMessagesNavState === "function") {
|
if (typeof window.updateMessagesNavState === "function") {
|
||||||
window.updateMessagesNavState();
|
window.updateMessagesNavState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function finishFirstChallengeRound(scoreData) {
|
async function finishFirstChallengeRound(scoreData) {
|
||||||
|
// Der Gegner speichert zuerst seinen Score und informiert danach den Herausforderer.
|
||||||
await saveScore(scoreData);
|
await saveScore(scoreData);
|
||||||
await notifyChallenger(scoreData);
|
await notifyChallenger(scoreData);
|
||||||
|
|
||||||
@ -484,6 +522,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function notifyChallenger(scoreData) {
|
async function notifyChallenger(scoreData) {
|
||||||
|
// Nutzt den bestehenden Nachrichten-Endpunkt, um dem Herausforderer den Gegner-Score zu senden.
|
||||||
const auth = getAuth();
|
const auth = getAuth();
|
||||||
const messageService = getMessageService();
|
const messageService = getMessageService();
|
||||||
if (!auth || !messageService || !activeChallenge.challenger) {
|
if (!auth || !messageService || !activeChallenge.challenger) {
|
||||||
@ -523,7 +562,110 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getChallengeWinner(challengerScore, opponentScore) {
|
||||||
|
if (challengerScore > opponentScore) {
|
||||||
|
return activeChallenge.challenger || getAuth()?.username || "Herausforderer";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (challengerScore < opponentScore) {
|
||||||
|
return activeChallenge.opponent || "Gegner";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "draw";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendChallengeResultMessage(recipient, resultData, text) {
|
||||||
|
const auth = getAuth();
|
||||||
|
const messageService = getMessageService();
|
||||||
|
if (!auth || !messageService || !recipient) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageText =
|
||||||
|
CHALLENGE_DATA_PREFIX +
|
||||||
|
JSON.stringify(resultData) +
|
||||||
|
"]]" +
|
||||||
|
"\n" +
|
||||||
|
text;
|
||||||
|
|
||||||
|
const result = await messageService.postMessage(
|
||||||
|
auth.username,
|
||||||
|
auth.password,
|
||||||
|
recipient,
|
||||||
|
"challenge",
|
||||||
|
messageText,
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finishFinalChallengeWithMessages(scoreData) {
|
||||||
|
// Fallback fuer den finalen Challenge-Abschluss:
|
||||||
|
// Score speichern, Gewinner lokal berechnen und Ergebnis per Nachricht an den Gegner senden.
|
||||||
|
const auth = getAuth();
|
||||||
|
if (!auth || !activeChallenge) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveScore(scoreData);
|
||||||
|
|
||||||
|
const opponentScore = Number(activeChallenge.opponentScore);
|
||||||
|
const winner = getChallengeWinner(scoreData.score, opponentScore);
|
||||||
|
const resultData = {
|
||||||
|
id: activeChallenge.id,
|
||||||
|
challenger: activeChallenge.challenger || auth.username,
|
||||||
|
opponent: activeChallenge.opponent,
|
||||||
|
challengerScore: scoreData.score,
|
||||||
|
opponentScore: opponentScore,
|
||||||
|
winner: winner,
|
||||||
|
};
|
||||||
|
|
||||||
|
const winnerText = winner === "draw"
|
||||||
|
? "Unentschieden"
|
||||||
|
: "Gewinner: " + winner;
|
||||||
|
const resultText =
|
||||||
|
"Challenge abgeschlossen. " +
|
||||||
|
resultData.challenger +
|
||||||
|
": " +
|
||||||
|
resultData.challengerScore +
|
||||||
|
" Punkte, " +
|
||||||
|
resultData.opponent +
|
||||||
|
": " +
|
||||||
|
resultData.opponentScore +
|
||||||
|
" Punkte. " +
|
||||||
|
winnerText +
|
||||||
|
".";
|
||||||
|
|
||||||
|
const opponentMessageSent = await sendChallengeResultMessage(
|
||||||
|
activeChallenge.opponent,
|
||||||
|
resultData,
|
||||||
|
resultText,
|
||||||
|
);
|
||||||
|
|
||||||
|
sessionStorage.removeItem(ACTIVE_CHALLENGE_STORAGE_KEY);
|
||||||
|
activeChallenge = null;
|
||||||
|
|
||||||
|
if (!opponentMessageSent) {
|
||||||
|
showScoreSaveFeedback(
|
||||||
|
"Score gespeichert, aber die Ergebnisnachricht an den Gegner konnte nicht gesendet werden.",
|
||||||
|
"warning",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showScoreSaveFeedback(
|
||||||
|
"Challenge abgeschlossen. Ergebnisnachricht wurde an den Gegner gesendet.",
|
||||||
|
"success",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (typeof window.updateMessagesNavState === "function") {
|
||||||
|
window.updateMessagesNavState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function submitScore() {
|
async function submitScore() {
|
||||||
|
// Gemeinsamer Abschluss fuer normale Spiele und Challenge-Runden.
|
||||||
|
// Der aktive Challenge-Kontext entscheidet, welcher Speicher-/Nachrichtenfluss verwendet wird.
|
||||||
if (!userTextInput) return;
|
if (!userTextInput) return;
|
||||||
|
|
||||||
const userInput = userTextInput.value.trim();
|
const userInput = userTextInput.value.trim();
|
||||||
@ -572,7 +714,16 @@
|
|||||||
await finishFirstChallengeRound(scoreData);
|
await finishFirstChallengeRound(scoreData);
|
||||||
} else if (hasActiveChallenge()) {
|
} else if (hasActiveChallenge()) {
|
||||||
showScoreSaveFeedback("Challenge-Resultat wird gesendet...", "info");
|
showScoreSaveFeedback("Challenge-Resultat wird gesendet...", "info");
|
||||||
await completeChallenge(scoreData);
|
let completed = false;
|
||||||
|
try {
|
||||||
|
completed = await completeChallenge(scoreData);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Challenge-Abschluss fehlgeschlagen, nutze Nachrichten-Fallback.", error);
|
||||||
|
}
|
||||||
|
if (!completed) {
|
||||||
|
showScoreSaveFeedback("Backend-Abschluss nicht moeglich. Ergebnis wird ueber Nachrichten gesendet...", "info");
|
||||||
|
await finishFinalChallengeWithMessages(scoreData);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await saveScore(scoreData);
|
await saveScore(scoreData);
|
||||||
}
|
}
|
||||||
@ -591,6 +742,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
window.initPlayPage = function initPlayPage() {
|
window.initPlayPage = function initPlayPage() {
|
||||||
|
// Die Spielseite wird dynamisch geladen; daher werden DOM-Elemente und Events hier initialisiert.
|
||||||
clearInterval(timerInterval);
|
clearInterval(timerInterval);
|
||||||
activeChallenge = readActiveChallenge();
|
activeChallenge = readActiveChallenge();
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user