feat(tournaments): add update functionality for official tournaments
- Implemented an API endpoint to update the title of official tournaments, including error handling for non-existent tournaments. - Enhanced the frontend to allow users to edit tournament titles directly, with input validation and feedback for successful updates or errors. - Updated the German localization file to include new strings for editing titles and error messages.
This commit is contained in:
@@ -1,6 +1,21 @@
|
||||
import { checkAccess } from '../utils/userUtils.js';
|
||||
import officialTournamentService from '../services/officialTournamentService.js';
|
||||
|
||||
export const updateOfficialTournament = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, id } = req.params;
|
||||
await checkAccess(userToken, clubId);
|
||||
|
||||
const result = await officialTournamentService.updateOfficialTournament(clubId, id, req.body);
|
||||
if (!result) return res.status(404).json({ error: 'not found' });
|
||||
res.status(200).json(result);
|
||||
} catch (e) {
|
||||
console.error('[updateOfficialTournament] Error:', e);
|
||||
res.status(500).json({ error: 'Failed to update tournament' });
|
||||
}
|
||||
};
|
||||
|
||||
export const uploadTournamentPdf = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
|
||||
@@ -10,9 +10,47 @@ import { authenticate } from '../middleware/authMiddleware.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/** Domains, deren Links durch den Proxy umgeleitet werden (für Folge-Logs) */
|
||||
const PROXY_DOMAINS = ['click-tt.de', 'httv.de'];
|
||||
|
||||
/**
|
||||
* Prüft, ob eine URL durch unseren Proxy umgeleitet werden soll
|
||||
*/
|
||||
function shouldProxyUrl(href) {
|
||||
if (!href || typeof href !== 'string') return false;
|
||||
const h = href.trim();
|
||||
if (h.startsWith('#') || h.startsWith('javascript:')) return false;
|
||||
return PROXY_DOMAINS.some(d => h.includes(d));
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreibt Links im HTML um, sodass Klicks im iframe über unseren Proxy laufen (Folge-Logs).
|
||||
*/
|
||||
function rewriteLinksInHtml(html, proxyBaseUrl, pageBaseUrl) {
|
||||
if (!html || !proxyBaseUrl || !pageBaseUrl) return html;
|
||||
try {
|
||||
const base = new URL(pageBaseUrl);
|
||||
return html.replace(
|
||||
/<a\s+([^>]*?)href\s*=\s*["']([^"']+)["']([^>]*)>/gi,
|
||||
(match, before, href, after) => {
|
||||
let absoluteUrl = href;
|
||||
if (href.startsWith('/') || !href.startsWith('http')) {
|
||||
absoluteUrl = new URL(href, base.origin + base.pathname).href;
|
||||
}
|
||||
if (!shouldProxyUrl(absoluteUrl)) return match;
|
||||
const proxyUrl = `${proxyBaseUrl}${proxyBaseUrl.includes('?') ? '&' : '?'}url=${encodeURIComponent(absoluteUrl)}`;
|
||||
return `<a ${before}href="${proxyUrl}"${after}>`;
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
return html;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/clicktt/proxy
|
||||
* Proxy für iframe-Einbettung – liefert HTML direkt (ohne Auth, für iframe src).
|
||||
* Links zu click-tt.de/httv.de werden umgeschrieben, damit Folge-Klicks geloggt werden.
|
||||
* Query: type (leaguePage|clubInfo|regionMeetings), association, championship, clubId
|
||||
* ODER: url (vollständige URL, nur click-tt.de/httv.de)
|
||||
*/
|
||||
@@ -66,6 +104,12 @@ router.get('/proxy', async (req, res, next) => {
|
||||
.replace(/<meta[^>]*http-equiv=["']x-frame-options["'][^>]*>/gi, '')
|
||||
.replace(/<meta[^>]*http-equiv=["']x-content-type-options["'][^>]*>/gi, '');
|
||||
|
||||
// Links umschreiben: Klicks im iframe laufen über unseren Proxy → Folge-Logs
|
||||
const protocol = req.protocol || 'http';
|
||||
const host = req.get('host') || 'localhost:3005';
|
||||
const proxyBase = `${protocol}://${host}/api/clicktt/proxy`;
|
||||
html = rewriteLinksInHtml(html, proxyBase, targetUrl);
|
||||
|
||||
res.set({
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import express from 'express';
|
||||
import multer from 'multer';
|
||||
import { authenticate } from '../middleware/authMiddleware.js';
|
||||
import { uploadTournamentPdf, getParsedTournament, listOfficialTournaments, deleteOfficialTournament, upsertCompetitionMember, listClubParticipations, updateParticipantStatus } from '../controllers/officialTournamentController.js';
|
||||
import { uploadTournamentPdf, getParsedTournament, listOfficialTournaments, deleteOfficialTournament, updateOfficialTournament, upsertCompetitionMember, listClubParticipations, updateParticipantStatus } from '../controllers/officialTournamentController.js';
|
||||
|
||||
const router = express.Router();
|
||||
const upload = multer({ storage: multer.memoryStorage() });
|
||||
@@ -12,6 +12,7 @@ router.get('/:clubId', listOfficialTournaments);
|
||||
router.get('/:clubId/participations/summary', listClubParticipations);
|
||||
router.post('/:clubId/upload', upload.single('pdf'), uploadTournamentPdf);
|
||||
router.get('/:clubId/:id', getParsedTournament);
|
||||
router.patch('/:clubId/:id', updateOfficialTournament);
|
||||
router.delete('/:clubId/:id', deleteOfficialTournament);
|
||||
router.post('/:clubId/:id/participation', upsertCompetitionMember);
|
||||
router.post('/:clubId/:id/status', updateParticipantStatus);
|
||||
|
||||
@@ -295,6 +295,14 @@ class OfficialTournamentService {
|
||||
return out;
|
||||
}
|
||||
|
||||
async updateOfficialTournament(clubId, id, { title }) {
|
||||
const t = await OfficialTournament.findOne({ where: { id, clubId } });
|
||||
if (!t) return null;
|
||||
if (title !== undefined) t.title = title;
|
||||
await t.save();
|
||||
return t.toJSON();
|
||||
}
|
||||
|
||||
async deleteOfficialTournament(clubId, id) {
|
||||
const t = await OfficialTournament.findOne({ where: { id, clubId } });
|
||||
if (!t) return false;
|
||||
|
||||
@@ -1400,6 +1400,8 @@
|
||||
"showParticipations": "Turnierbeteiligungen anzeigen",
|
||||
"savedEvents": "Gespeicherte Veranstaltungen",
|
||||
"tournament": "Turnier",
|
||||
"editTitle": "Titel bearbeiten",
|
||||
"updateTitleError": "Titel konnte nicht gespeichert werden.",
|
||||
"delete": "Löschen",
|
||||
"timeRange": "Zeitraum:",
|
||||
"last3Months": "Letzte 3 Monate",
|
||||
|
||||
@@ -13,12 +13,35 @@
|
||||
</div>
|
||||
<div v-if="topActiveTab==='events'">
|
||||
<h3>{{ $t('officialTournaments.savedEvents') }}</h3>
|
||||
<ul>
|
||||
<li v-for="t in list" :key="t.id" style="display:flex; align-items:center; gap:.5rem;">
|
||||
<a href="#" @click.prevent="uploadedId = String(t.id); reload();" style="flex:1;">
|
||||
<ul class="event-list">
|
||||
<li v-for="t in list" :key="t.id" class="event-item">
|
||||
<template v-if="editingTournamentId === t.id">
|
||||
<input
|
||||
ref="titleEditInput"
|
||||
v-model="editingTitle"
|
||||
class="title-input"
|
||||
@blur="saveTournamentTitle(t)"
|
||||
@keydown.enter="saveTournamentTitle(t)"
|
||||
@keydown.esc="cancelTitleEdit"
|
||||
/>
|
||||
</template>
|
||||
<a
|
||||
v-else
|
||||
href="#"
|
||||
class="event-title"
|
||||
@click.prevent="uploadedId = String(t.id); reload();"
|
||||
>
|
||||
{{ t.title || ($t('officialTournaments.tournament') + ' #' + t.id) }}
|
||||
</a>
|
||||
<span v-if="t.termin || t.eventDate"> — {{ t.termin || t.eventDate }}</span>
|
||||
<span v-if="editingTournamentId !== t.id && (t.termin || t.eventDate)" class="event-date"> — {{ t.termin || t.eventDate }}</span>
|
||||
<button
|
||||
v-if="editingTournamentId !== t.id"
|
||||
class="btn-icon"
|
||||
:title="$t('officialTournaments.editTitle')"
|
||||
@click.prevent="startTitleEdit(t)"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button class="btn-secondary" @click.prevent="removeTournament(t)" :title="$t('officialTournaments.delete')">🗑️</button>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -419,6 +442,8 @@ export default {
|
||||
loadingClubParticipations: false,
|
||||
clubParticipationRowsData: [],
|
||||
participationRange: 'all',
|
||||
editingTournamentId: null,
|
||||
editingTitle: '',
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -1237,6 +1262,35 @@ export default {
|
||||
return `${dd}.${mm}.${yyyy}`;
|
||||
},
|
||||
|
||||
startTitleEdit(t) {
|
||||
this.editingTournamentId = t.id;
|
||||
this.editingTitle = t.title || '';
|
||||
this.$nextTick(() => {
|
||||
const el = this.$refs.titleEditInput;
|
||||
if (Array.isArray(el)) {
|
||||
if (el[0]) el[0].focus();
|
||||
} else if (el) el.focus();
|
||||
});
|
||||
},
|
||||
cancelTitleEdit() {
|
||||
this.editingTournamentId = null;
|
||||
this.editingTitle = '';
|
||||
},
|
||||
async saveTournamentTitle(t) {
|
||||
const newTitle = (this.editingTitle || '').trim();
|
||||
this.editingTournamentId = null;
|
||||
this.editingTitle = '';
|
||||
if (newTitle === (t.title || '').trim()) return;
|
||||
try {
|
||||
await apiClient.patch(`/official-tournaments/${this.currentClub}/${t.id}`, { title: newTitle || null });
|
||||
t.title = newTitle || null;
|
||||
if (String(this.uploadedId) === String(t.id) && this.parsed) {
|
||||
this.parsed.parsedData = { ...this.parsed.parsedData, title: newTitle || this.parsed.parsedData.title };
|
||||
}
|
||||
} catch (error) {
|
||||
this.showInfo(this.$t('common.error'), this.$t('officialTournaments.updateTitleError'), getSafeErrorMessage(error), 'error');
|
||||
}
|
||||
},
|
||||
async removeTournament(t) {
|
||||
const confirmed = await this.showConfirm(
|
||||
'Turnier löschen',
|
||||
@@ -1275,6 +1329,13 @@ export default {
|
||||
|
||||
<style scoped>
|
||||
.official-tournaments { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
.event-list { list-style: none; padding: 0; margin: 0; }
|
||||
.event-item { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.event-title { flex: 1; }
|
||||
.event-date { flex-shrink: 0; }
|
||||
.title-input { flex: 1; min-width: 0; padding: 0.25rem 0.4rem; font-size: inherit; }
|
||||
.btn-icon { background: none; border: none; cursor: pointer; padding: 0.25rem; opacity: 0.7; }
|
||||
.btn-icon:hover { opacity: 1; }
|
||||
.top-actions { display: flex; gap: .5rem; margin-bottom: .5rem; }
|
||||
.tabs { display: flex; gap: .25rem; border-bottom: 1px solid var(--border-color); margin: .25rem 0 .5rem; }
|
||||
.tab { background: #f8f9fb; color: var(--text-color, #222); border: none; padding: .4rem .6rem; cursor: pointer; border-bottom: 2px solid transparent; }
|
||||
|
||||
Reference in New Issue
Block a user