diff --git a/backend/controllers/officialTournamentController.js b/backend/controllers/officialTournamentController.js
index bee2d1ee..f840c732 100644
--- a/backend/controllers/officialTournamentController.js
+++ b/backend/controllers/officialTournamentController.js
@@ -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;
diff --git a/backend/routes/clickTtHttpPageRoutes.js b/backend/routes/clickTtHttpPageRoutes.js
index a4f5af15..2f686a89 100644
--- a/backend/routes/clickTtHttpPageRoutes.js
+++ b/backend/routes/clickTtHttpPageRoutes.js
@@ -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(
+ /]*?)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 ``;
+ }
+ );
+ } 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(/]*http-equiv=["']x-frame-options["'][^>]*>/gi, '')
.replace(/]*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': '*',
diff --git a/backend/routes/officialTournamentRoutes.js b/backend/routes/officialTournamentRoutes.js
index 16d11085..a61a18aa 100644
--- a/backend/routes/officialTournamentRoutes.js
+++ b/backend/routes/officialTournamentRoutes.js
@@ -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);
diff --git a/backend/services/officialTournamentService.js b/backend/services/officialTournamentService.js
index ed189e11..44f17ba2 100644
--- a/backend/services/officialTournamentService.js
+++ b/backend/services/officialTournamentService.js
@@ -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;
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json
index 0037d108..3babd444 100644
--- a/frontend/src/i18n/locales/de.json
+++ b/frontend/src/i18n/locales/de.json
@@ -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",
diff --git a/frontend/src/views/OfficialTournaments.vue b/frontend/src/views/OfficialTournaments.vue
index 823b64b2..99f315f7 100644
--- a/frontend/src/views/OfficialTournaments.vue
+++ b/frontend/src/views/OfficialTournaments.vue
@@ -13,12 +13,35 @@
{{ $t('officialTournaments.savedEvents') }}
-
- -
-
+
@@ -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 {