304 lines
10 KiB
Vue
304 lines
10 KiB
Vue
<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(/ /g, ' ')
|
|
.replace(/&/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>
|