Refactor links section to use dynamic rendering with computed properties, enhancing maintainability and scalability. Add new 'Links' tab in CMS for better navigation.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 56s

This commit is contained in:
Torsten Schulz (local)
2026-03-04 16:05:34 +01:00
parent 3fb40bd87d
commit 6230c96bc9
3 changed files with 439 additions and 93 deletions

303
components/cms/CmsLinks.vue Normal file
View File

@@ -0,0 +1,303 @@
<template>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold text-gray-900">
Links bearbeiten
</h2>
<button
type="button"
class="inline-flex items-center px-4 py-2 rounded-lg bg-primary-600 text-white hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
:disabled="saving"
@click="save"
>
{{ saving ? 'Speichert...' : 'Speichern' }}
</button>
</div>
<p class="text-sm text-gray-500 mb-6">
Diese Übersicht wird auf der öffentlichen Seite als Karten dargestellt.
</p>
<div class="space-y-6">
<div
v-for="(section, sectionIndex) in sections"
:key="section.id"
class="border border-gray-200 rounded-lg p-4"
>
<div class="flex items-center gap-3 mb-4">
<input
v-model="section.title"
type="text"
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="Block-Titel"
>
<button
type="button"
class="px-3 py-2 text-sm bg-red-100 text-red-700 rounded-lg hover:bg-red-200"
@click="removeSection(sectionIndex)"
>
Block löschen
</button>
</div>
<div class="space-y-3">
<div
v-for="(item, itemIndex) in section.items"
:key="item.id"
class="grid md:grid-cols-12 gap-2"
>
<input
v-model="item.label"
type="text"
class="md:col-span-4 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="Link-Text"
>
<input
v-model="item.href"
type="url"
class="md:col-span-5 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="https://..."
>
<input
v-model="item.description"
type="text"
class="md:col-span-2 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="Beschreibung (optional)"
>
<button
type="button"
class="md:col-span-1 px-2 py-2 text-sm bg-red-100 text-red-700 rounded-lg hover:bg-red-200"
@click="removeItem(sectionIndex, itemIndex)"
>
X
</button>
</div>
</div>
<div class="mt-3">
<button
type="button"
class="px-3 py-2 text-sm bg-gray-100 text-gray-800 rounded-lg hover:bg-gray-200"
@click="addItem(sectionIndex)"
>
Link hinzufügen
</button>
</div>
</div>
</div>
<div class="mt-6">
<button
type="button"
class="px-4 py-2 text-sm bg-primary-100 text-primary-800 rounded-lg hover:bg-primary-200"
@click="addSection"
>
Block hinzufügen
</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const saving = ref(false)
const sections = ref([])
function createId() {
const c = globalThis?.crypto
if (c && typeof c.randomUUID === 'function') return c.randomUUID()
return `id-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
}
const defaultSections = [
{
id: createId(),
title: 'Ergebnisse & Portale',
items: [
{ id: createId(), label: 'MyTischtennis.de', href: 'http://www.mytischtennis.de/public/home', description: '(offizielle QTTR-Werte)' },
{ id: createId(), label: 'Click-tt Ergebnisse', href: 'http://httv.click-tt.de/', description: '(offizieller Ergebnisdienst HTTV)' },
{ id: createId(), label: 'Tischtennis Pur - das Tischtennis Portal', href: 'https://www.tischtennis-pur.de/', description: '(Informationen, Blogs, Fachbeiträge, Tipps)' },
{ id: createId(), label: 'Liveticker 2. und 3. TT-Bundesliga', href: 'https://ticker.tt-news.com/', description: '' }
]
},
{
id: createId(),
title: 'Verbände',
items: [
{ id: createId(), label: 'Hessischer Tischtennisverband (HTTV)', href: 'http://www.httv.de/', description: '' },
{ id: createId(), label: 'Deutscher Tischtennisbund (DTTB)', href: 'http://www.tischtennis.de/aktuelles/', description: '' },
{ id: createId(), label: 'European Table Tennis Union (ETTU)', href: 'http://www.ettu.org/', description: '' },
{ id: createId(), label: 'International Table Tennis Federation (ITTF)', href: 'https://www.ittf.com/', description: '' }
]
},
{
id: createId(),
title: 'Regionale Links',
items: [
{ id: createId(), label: 'Stadt Frankfurt', href: 'http://www.frankfurt.de/', description: '' },
{ id: createId(), label: 'Vereinsring Harheim', href: 'http://www.harheim.com/', description: '' }
]
},
{
id: createId(),
title: 'Partner & Vereine',
items: [
{ id: createId(), label: 'TTC OE Bad Homburg', href: 'http://www.ttcoe.de/', description: '' },
{ id: createId(), label: 'SpVgg Steinkirchen e.V.', href: 'https://www.spvgg-steinkirchen.de/menue-abteilungen/abteilungen/tischtennis', description: '' },
{ id: createId(), label: 'Ergebnisse SpVgg Steinkirchen', href: 'https://www.mytischtennis.de/clicktt/ByTTV/24-25/ligen/Bezirksklasse-A-Gruppe-2-IN-PAF/gruppe/466925/tabelle/gesamt/', description: '' }
]
}
]
function createHtmlFromSections(inputSections) {
const safeSections = Array.isArray(inputSections) ? inputSections : []
return safeSections
.filter((s) => s.title && Array.isArray(s.items) && s.items.length > 0)
.map((section) => {
const itemsHtml = section.items
.filter((item) => item.label && item.href)
.map((item) => {
const description = item.description ? ` ${item.description}` : ''
return `<li><a href="${item.href}" target="_blank" rel="noopener noreferrer">${item.label}</a>${description}</li>`
})
.join('')
return `<h2>${section.title}</h2><ul>${itemsHtml}</ul>`
})
.join('\n')
}
function normalizeSections(rawSections) {
if (!Array.isArray(rawSections) || rawSections.length === 0) {
return JSON.parse(JSON.stringify(defaultSections))
}
return rawSections.map((section) => ({
id: section.id || createId(),
title: section.title || '',
items: Array.isArray(section.items)
? section.items.map((item) => ({
id: item.id || createId(),
label: item.label || '',
href: item.href || '',
description: item.description || ''
}))
: []
}))
}
function stripTags(html) {
if (!html) return ''
return String(html)
.replace(/<[^>]*>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.trim()
}
function parseLinksHtml(html) {
if (!html || typeof html !== 'string') return []
const sectionsParsed = []
const sectionPattern = /<h2[^>]*>([\s\S]*?)<\/h2>\s*<ul[^>]*>([\s\S]*?)<\/ul>/gi
let sectionMatch
while ((sectionMatch = sectionPattern.exec(html)) !== null) {
const title = stripTags(sectionMatch[1])
const ulContent = sectionMatch[2] || ''
const itemPattern = /<li[^>]*>([\s\S]*?)<\/li>/gi
const items = []
let itemMatch
while ((itemMatch = itemPattern.exec(ulContent)) !== null) {
const liHtml = itemMatch[1] || ''
const anchorMatch = liHtml.match(/<a[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/i)
const href = anchorMatch ? String(anchorMatch[1]).trim() : ''
const label = anchorMatch ? stripTags(anchorMatch[2]) : stripTags(liHtml)
let description = ''
if (anchorMatch) {
description = stripTags(liHtml.replace(anchorMatch[0], ''))
}
if (label || href || description) {
items.push({
id: createId(),
label,
href,
description
})
}
}
if (title || items.length > 0) {
sectionsParsed.push({
id: createId(),
title,
items
})
}
}
return sectionsParsed
}
function addSection() {
sections.value.push({
id: createId(),
title: '',
items: [{ id: createId(), label: '', href: '', description: '' }]
})
}
function removeSection(index) {
sections.value.splice(index, 1)
}
function addItem(sectionIndex) {
sections.value[sectionIndex].items.push({
id: createId(),
label: '',
href: '',
description: ''
})
}
function removeItem(sectionIndex, itemIndex) {
sections.value[sectionIndex].items.splice(itemIndex, 1)
}
async function load() {
try {
const current = await $fetch('/api/config')
const configured = current?.seiten?.linksStructured
if (Array.isArray(configured) && configured.length > 0) {
sections.value = normalizeSections(configured)
return
}
const html = current?.seiten?.links
const parsed = parseLinksHtml(html)
sections.value = normalizeSections(parsed)
} catch {
sections.value = JSON.parse(JSON.stringify(defaultSections))
}
}
async function save() {
saving.value = true
try {
const current = await $fetch('/api/config')
const cleanedSections = normalizeSections(sections.value)
const linksHtml = createHtmlFromSections(cleanedSections)
const updated = {
...current,
seiten: {
...(current?.seiten || {}),
linksStructured: cleanedSections,
links: linksHtml
}
}
await $fetch('/api/config', { method: 'PUT', body: updated })
try { window.showSuccessModal && window.showSuccessModal('Erfolg', 'Links erfolgreich gespeichert.') } catch {}
} catch (error) {
const msg = error?.data?.message || 'Fehler beim Speichern der Links'
try { window.showErrorModal && window.showErrorModal('Fehler', msg) } catch {}
} finally {
saving.value = false
}
}
onMounted(load)
</script>

View File

@@ -29,6 +29,7 @@
<CmsGeschichte v-if="activeTab === 'geschichte'" />
<CmsTtRegeln v-if="activeTab === 'tt-regeln'" />
<CmsSatzung v-if="activeTab === 'satzung'" />
<CmsLinks v-if="activeTab === 'links'" />
</div>
</div>
</div>
@@ -40,6 +41,7 @@ import CmsUeberUns from '~/components/cms/CmsUeberUns.vue'
import CmsGeschichte from '~/components/cms/CmsGeschichte.vue'
import CmsTtRegeln from '~/components/cms/CmsTtRegeln.vue'
import CmsSatzung from '~/components/cms/CmsSatzung.vue'
import CmsLinks from '~/components/cms/CmsLinks.vue'
definePageMeta({
middleware: 'auth',
@@ -56,6 +58,7 @@ const tabs = [
{ id: 'ueber-uns', label: 'Über uns' },
{ id: 'geschichte', label: 'Geschichte' },
{ id: 'tt-regeln', label: 'TT-Regeln' },
{ id: 'satzung', label: 'Satzung' }
{ id: 'satzung', label: 'Satzung' },
{ id: 'links', label: 'Links' }
]
</script>

View File

@@ -11,102 +11,31 @@
</p>
<div class="grid md:grid-cols-2 gap-6">
<section class="bg-white rounded-xl shadow-lg p-6">
<section
v-for="section in sections"
:key="section.title"
class="bg-white rounded-xl shadow-lg p-6"
>
<h2 class="text-2xl font-display font-bold text-gray-900 mb-4">
Ergebnisse & Portale
{{ section.title }}
</h2>
<ul class="space-y-3">
<li>
<a href="http://www.mytischtennis.de/public/home" target="_blank" rel="noopener noreferrer" class="text-primary-700 hover:text-primary-900 font-medium">
MyTischtennis.de
</a>
<span class="text-gray-600"> (offizielle QTTR-Werte)</span>
</li>
<li>
<a href="http://httv.click-tt.de/" target="_blank" rel="noopener noreferrer" class="text-primary-700 hover:text-primary-900 font-medium">
Click-tt Ergebnisse
</a>
<span class="text-gray-600"> (offizieller Ergebnisdienst HTTV)</span>
</li>
<li>
<a href="https://www.tischtennis-pur.de/" target="_blank" rel="noopener noreferrer" class="text-primary-700 hover:text-primary-900 font-medium">
Tischtennis Pur - das Tischtennis Portal
</a>
<span class="text-gray-600"> (Informationen, Blogs, Fachbeiträge, Tipps)</span>
</li>
<li>
<a href="https://ticker.tt-news.com/" target="_blank" rel="noopener noreferrer" class="text-primary-700 hover:text-primary-900 font-medium">
Liveticker 2. und 3. TT-Bundesliga
</a>
</li>
</ul>
</section>
<section class="bg-white rounded-xl shadow-lg p-6">
<h2 class="text-2xl font-display font-bold text-gray-900 mb-4">
Verbände
</h2>
<ul class="space-y-3">
<li>
<a href="http://www.httv.de/" target="_blank" rel="noopener noreferrer" class="text-primary-700 hover:text-primary-900 font-medium">
Hessischer Tischtennisverband (HTTV)
</a>
</li>
<li>
<a href="http://www.tischtennis.de/aktuelles/" target="_blank" rel="noopener noreferrer" class="text-primary-700 hover:text-primary-900 font-medium">
Deutscher Tischtennisbund (DTTB)
</a>
</li>
<li>
<a href="http://www.ettu.org/" target="_blank" rel="noopener noreferrer" class="text-primary-700 hover:text-primary-900 font-medium">
European Table Tennis Union (ETTU)
</a>
</li>
<li>
<a href="https://www.ittf.com/" target="_blank" rel="noopener noreferrer" class="text-primary-700 hover:text-primary-900 font-medium">
International Table Tennis Federation (ITTF)
</a>
</li>
</ul>
</section>
<section class="bg-white rounded-xl shadow-lg p-6">
<h2 class="text-2xl font-display font-bold text-gray-900 mb-4">
Regionale Links
</h2>
<ul class="space-y-3">
<li>
<a href="http://www.frankfurt.de/" target="_blank" rel="noopener noreferrer" class="text-primary-700 hover:text-primary-900 font-medium">
Stadt Frankfurt
</a>
</li>
<li>
<a href="http://www.harheim.com/" target="_blank" rel="noopener noreferrer" class="text-primary-700 hover:text-primary-900 font-medium">
Vereinsring Harheim
</a>
</li>
</ul>
</section>
<section class="bg-white rounded-xl shadow-lg p-6">
<h2 class="text-2xl font-display font-bold text-gray-900 mb-4">
Partner & Vereine
</h2>
<ul class="space-y-3">
<li>
<a href="http://www.ttcoe.de/" target="_blank" rel="noopener noreferrer" class="text-primary-700 hover:text-primary-900 font-medium">
TTC OE Bad Homburg
</a>
</li>
<li>
<a href="https://www.spvgg-steinkirchen.de/menue-abteilungen/abteilungen/tischtennis" target="_blank" rel="noopener noreferrer" class="text-primary-700 hover:text-primary-900 font-medium">
SpVgg Steinkirchen e.V.
</a>
</li>
<li>
<a href="https://www.mytischtennis.de/clicktt/ByTTV/24-25/ligen/Bezirksklasse-A-Gruppe-2-IN-PAF/gruppe/466925/tabelle/gesamt/" target="_blank" rel="noopener noreferrer" class="text-primary-700 hover:text-primary-900 font-medium">
Ergebnisse SpVgg Steinkirchen
<li
v-for="(item, idx) in section.items"
:key="`${section.title}-${idx}`"
>
<a
:href="item.href"
target="_blank"
rel="noopener noreferrer"
class="text-primary-700 hover:text-primary-900 font-medium"
>
{{ item.label }}
</a>
<span
v-if="item.description"
class="text-gray-600"
> {{ item.description }}</span>
</li>
</ul>
</section>
@@ -116,6 +45,117 @@
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
const rawContent = ref('')
const defaultLinksHtml = `
<h2>Ergebnisse &amp; Portale</h2>
<ul>
<li><a href="http://www.mytischtennis.de/public/home" target="_blank" rel="noopener noreferrer">MyTischtennis.de</a> (offizielle QTTR-Werte)</li>
<li><a href="http://httv.click-tt.de/" target="_blank" rel="noopener noreferrer">Click-tt Ergebnisse</a> (offizieller Ergebnisdienst HTTV)</li>
<li><a href="https://www.tischtennis-pur.de/" target="_blank" rel="noopener noreferrer">Tischtennis Pur - das Tischtennis Portal</a> (Informationen, Blogs, Fachbeiträge, Tipps)</li>
<li><a href="https://ticker.tt-news.com/" target="_blank" rel="noopener noreferrer">Liveticker 2. und 3. TT-Bundesliga</a></li>
</ul>
<h2>Verbände</h2>
<ul>
<li><a href="http://www.httv.de/" target="_blank" rel="noopener noreferrer">Hessischer Tischtennisverband (HTTV)</a></li>
<li><a href="http://www.tischtennis.de/aktuelles/" target="_blank" rel="noopener noreferrer">Deutscher Tischtennisbund (DTTB)</a></li>
<li><a href="http://www.ettu.org/" target="_blank" rel="noopener noreferrer">European Table Tennis Union (ETTU)</a></li>
<li><a href="https://www.ittf.com/" target="_blank" rel="noopener noreferrer">International Table Tennis Federation (ITTF)</a></li>
</ul>
<h2>Regionale Links</h2>
<ul>
<li><a href="http://www.frankfurt.de/" target="_blank" rel="noopener noreferrer">Stadt Frankfurt</a></li>
<li><a href="http://www.harheim.com/" target="_blank" rel="noopener noreferrer">Vereinsring Harheim</a></li>
</ul>
<h2>Partner &amp; Vereine</h2>
<ul>
<li><a href="http://www.ttcoe.de/" target="_blank" rel="noopener noreferrer">TTC OE Bad Homburg</a></li>
<li><a href="https://www.spvgg-steinkirchen.de/menue-abteilungen/abteilungen/tischtennis" target="_blank" rel="noopener noreferrer">SpVgg Steinkirchen e.V.</a></li>
<li><a href="https://www.mytischtennis.de/clicktt/ByTTV/24-25/ligen/Bezirksklasse-A-Gruppe-2-IN-PAF/gruppe/466925/tabelle/gesamt/" target="_blank" rel="noopener noreferrer">Ergebnisse SpVgg Steinkirchen</a></li>
</ul>
`
const sections = computed(() => parseLinksHtml(rawContent.value))
function stripTags(html) {
return String(html || '')
.replace(/<[^>]*>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\s+/g, ' ')
.trim()
}
function parseLinksHtml(html) {
const source = String(html || '')
const sectionRegex = /<h2[^>]*>([\s\S]*?)<\/h2>([\s\S]*?)(?=<h2[^>]*>|$)/gi
const liRegex = /<li[^>]*>([\s\S]*?)<\/li>/gi
const anchorRegex = /<a[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/i
const parsed = []
let sectionMatch
while ((sectionMatch = sectionRegex.exec(source)) !== null) {
const title = stripTags(sectionMatch[1])
const body = sectionMatch[2]
const items = []
let liMatch
while ((liMatch = liRegex.exec(body)) !== null) {
const liContent = liMatch[1]
const anchorMatch = anchorRegex.exec(liContent)
if (!anchorMatch) continue
const href = anchorMatch[1].trim()
const label = stripTags(anchorMatch[2])
const remainder = liContent.replace(anchorMatch[0], '')
const desc = stripTags(remainder)
items.push({
href,
label,
description: desc || ''
})
}
if (title && items.length > 0) {
parsed.push({ title, items })
}
}
return parsed
}
async function loadConfig() {
try {
const data = await $fetch('/api/config')
const structured = data?.seiten?.linksStructured
if (Array.isArray(structured) && structured.length > 0) {
const htmlFromStructured = structured
.filter((section) => section?.title && Array.isArray(section?.items) && section.items.length > 0)
.map((section) => {
const itemsHtml = section.items
.filter((item) => item?.label && item?.href)
.map((item) => `<li><a href="${item.href}" target="_blank" rel="noopener noreferrer">${item.label}</a>${item.description ? ` ${item.description}` : ''}</li>`)
.join('')
return `<h2>${section.title}</h2><ul>${itemsHtml}</ul>`
})
.join('\n')
rawContent.value = htmlFromStructured || defaultLinksHtml
return
}
const links = data?.seiten?.links
rawContent.value = typeof links === 'string' && links.trim() ? links : defaultLinksHtml
} catch {
rawContent.value = defaultLinksHtml
}
}
onMounted(loadConfig)
useHead({
title: 'Links - Harheimer TC',
})