mirror of
https://codeberg.org/kirche-im-netz/Startodon-Hub.git
synced 2025-06-20 16:16:10 +02:00
599 lines
23 KiB
HTML
599 lines
23 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>Mastodon-Trends FediKirche</title>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
|
<!-- Mastodon-Stile (optional) -->
|
|
<link rel="stylesheet" href="https://unpkg.com/mastodon-css@latest/dist/mastodon.min.css">
|
|
<!-- DOMPurify -->
|
|
<script src="https://unpkg.com/dompurify@2.4.4/dist/purify.min.js"></script>
|
|
<!-- Chart.js (nur falls Mini-Charts weiterverwendet) -->
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
|
|
<link rel="stylesheet" href="./fonts/fonts.css?v=1.0.0">
|
|
<link rel="stylesheet" href="./style/index.css">
|
|
<style>
|
|
body {
|
|
font-family: Arial, sans-serif;
|
|
margin: 0;
|
|
padding: 0;
|
|
background-color: #f0f2f5;
|
|
}
|
|
.header-bar {
|
|
background-color: #ffffff;
|
|
padding: 20px;
|
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
text-align: center;
|
|
}
|
|
.controls {
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 20px;
|
|
background-color: #eaeaea;
|
|
flex-wrap: wrap;
|
|
}
|
|
.controls label, .controls select, .controls button {
|
|
font-size: 16px;
|
|
}
|
|
.controls select, .controls button {
|
|
padding: 10px;
|
|
border: 1px solid #ccc;
|
|
border-radius: 4px;
|
|
background-color: #fff;
|
|
cursor: pointer;
|
|
}
|
|
.controls button:hover {
|
|
background-color: #f0f0f0;
|
|
}
|
|
#error-message {
|
|
color: red;
|
|
font-weight: bold;
|
|
text-align: center;
|
|
margin-top: 10px;
|
|
}
|
|
.loading {
|
|
text-align: center;
|
|
color: #555;
|
|
font-style: italic;
|
|
margin: 10px 0;
|
|
}
|
|
.column { padding: 20px; }
|
|
@media (max-width: 600px) {
|
|
.column { padding: 10px; }
|
|
}
|
|
|
|
/* Toot-Grid */
|
|
.toot-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
|
gap: 20px;
|
|
}
|
|
.toot-tile {
|
|
background-color: #fff;
|
|
border: 1px solid #ddd;
|
|
border-radius: 8px;
|
|
padding: 15px;
|
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
display: flex;
|
|
flex-direction: column;
|
|
transition: transform 0.2s, box-shadow 0.2s;
|
|
}
|
|
.toot-tile:hover {
|
|
transform: translateY(-5px);
|
|
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
|
|
}
|
|
.toot-header { display: flex; align-items: center; margin-bottom: 10px; }
|
|
.toot-avatar {
|
|
width: 50px; height: 50px;
|
|
border-radius: 50%; margin-right: 10px; object-fit: cover;
|
|
}
|
|
.toot-author {
|
|
font-weight: bold; font-size: 16px; color: #333;
|
|
}
|
|
.toot-content {
|
|
margin-bottom: 10px; font-size: 14px; color: #555; word-wrap: break-word;
|
|
flex-grow: 1;
|
|
}
|
|
.toot-media img { max-width: 100%; border-radius: 4px; margin-top: 10px; }
|
|
.toot-footer {
|
|
display: flex; justify-content: space-between; align-items: center;
|
|
font-size: 12px; color: #777;
|
|
}
|
|
.actions { display: flex; gap: 10px; }
|
|
.actions button {
|
|
background: none; border: none; cursor: pointer; color: #777;
|
|
display: flex; align-items: center; gap: 5px; font-size: 14px;
|
|
}
|
|
.actions button:hover { color: #000; }
|
|
.toot-timestamp { font-size: 10px; color: #999; }
|
|
|
|
/* Link-Grid */
|
|
.link-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
|
gap: 20px;
|
|
}
|
|
.link-tile {
|
|
background-color: #fff;
|
|
border: 1px solid #ddd;
|
|
border-radius: 8px;
|
|
padding: 15px;
|
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
transition: transform 0.2s, box-shadow 0.2s;
|
|
display: flex; flex-direction: column; gap: 10px;
|
|
}
|
|
.link-tile:hover {
|
|
transform: translateY(-5px);
|
|
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
|
|
}
|
|
.link-title a {
|
|
font-size: 16px; font-weight: bold; color: #333; text-decoration: none;
|
|
}
|
|
.link-title a:hover { text-decoration: underline; }
|
|
.link-desc { font-size: 14px; color: #555; }
|
|
.link-hits { font-size: 12px; color: #777; }
|
|
|
|
/* Mini-Chart optional */
|
|
.mini-chart {
|
|
width: 200px !important;
|
|
height: 120px !important;
|
|
margin-top: 5px;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header class="header-bar">
|
|
<h1>Mastodon-Trends FediKirche</h1>
|
|
</header>
|
|
|
|
<nav>
|
|
<a href="https://codeberg.org/kirche-im-netz/Startodon-Hub" target="_blank" style="color: white; margin: 0 1rem; text-decoration: none;">🛠️ Quellcode</a>
|
|
<a href="rechtlicheHinweise.html" style="color: white; margin: 0 1rem; text-decoration: none;">🛡️ Impressum/Datenschutz</a>
|
|
<a href="fedikirche-trends.html" style="color: white; margin: 0 1rem; text-decoration: none;">📈 FediKirche Trends</a>
|
|
</nav>
|
|
|
|
<div class="controls">
|
|
<label for="social-select">Plattform:</label>
|
|
<select id="social-select" aria-label="Plattform auswählen">
|
|
<option value="alle-fedikirche" selected>Trends alle FediKirche-Instanzen</option>
|
|
<option value="kirche.social">Kirche.social</option>
|
|
<option value="reliverse.social">Reliverse.social</option>
|
|
<option value="libori.social">Libori.social</option>
|
|
<option value="katholisch.social">Katholisch.social</option>
|
|
</select>
|
|
<button id="fetch-trends-button" aria-label="Hole aktuelle Trends">Trends abrufen</button>
|
|
</div>
|
|
|
|
<div id="error-message"></div>
|
|
|
|
<!-- Loading-Anzeigen -->
|
|
<div id="loading-tags" class="loading" style="display:none;">Lade Tags...</div>
|
|
<div id="loading-statuses" class="loading" style="display:none;">Lade Statuses...</div>
|
|
<div id="loading-links" class="loading" style="display:none;">Lade Links...</div>
|
|
|
|
<section class="column" aria-labelledby="trends-tags-header">
|
|
<header class="header-bar">
|
|
<h2 id="trends-tags-header">Trends: Tags</h2>
|
|
</header>
|
|
<div class="item-list">
|
|
<ul id="trends-tags" class="media-list"></ul>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="column" aria-labelledby="trends-statuses-header">
|
|
<header class="header-bar">
|
|
<h2 id="trends-statuses-header">Trends: Statuses</h2>
|
|
</header>
|
|
<div class="item-list">
|
|
<div id="trends-statuses" class="toot-grid"></div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="column" aria-labelledby="trends-links-header">
|
|
<header class="header-bar">
|
|
<h2 id="trends-links-header">Trends: Links</h2>
|
|
</header>
|
|
<div id="trends-links" class="link-grid"></div>
|
|
</section>
|
|
|
|
<footer>
|
|
<p>© 2025 FediKirche</p>
|
|
<div class="footer-credits">
|
|
<p>Bildnachweis:</p>
|
|
<ul style="list-style: none; padding: 0; margin: 0;">
|
|
<li>Header-Bild <a href="https://unsplash.com/de/@joephotography" target="_blank">Akira Hojo via Unsplash</a></li>
|
|
<li>Bild zum Willkommen <a href="https://unsplash.com/@battenhall" target="_blank">Battenhall via Unsplash</a></li>
|
|
</ul>
|
|
</div>
|
|
</footer>
|
|
|
|
<script>
|
|
const errorMessageEl = document.getElementById('error-message');
|
|
const loadingTagsEl = document.getElementById('loading-tags');
|
|
const loadingStatusesEl = document.getElementById('loading-statuses');
|
|
const loadingLinksEl = document.getElementById('loading-links');
|
|
|
|
// Cache-Settings
|
|
const CACHE_TTL = 60 * 1000; // 1 Minute
|
|
const API_VERSION = 'v1'; // Falls man mal wechseln muss
|
|
|
|
let isFetching = false; // Throttling-Flag
|
|
|
|
const button = document.getElementById('fetch-trends-button');
|
|
button.addEventListener('click', onFetchTrends);
|
|
|
|
function onFetchTrends() {
|
|
if (isFetching) return;
|
|
isFetching = true;
|
|
|
|
errorMessageEl.textContent = '';
|
|
const platform = document.getElementById('social-select').value;
|
|
|
|
if (platform === 'alle-fedikirche') {
|
|
fetchAllTrends();
|
|
return;
|
|
}
|
|
|
|
const baseUrl = `https://${platform}/api/${API_VERSION}/trends`;
|
|
|
|
// Paralleles Laden von [Tags, Statuses, Links]
|
|
loadingTagsEl.style.display = 'block';
|
|
loadingStatusesEl.style.display = 'block';
|
|
loadingLinksEl.style.display = 'block';
|
|
|
|
Promise.all([
|
|
fetchTags(baseUrl, `${platform}-${API_VERSION}-tags`),
|
|
fetchStatuses(baseUrl, `${platform}-${API_VERSION}-statuses`),
|
|
fetchLinks(baseUrl, `${platform}-${API_VERSION}-links`)
|
|
])
|
|
.catch(e => {
|
|
showError(e.message || e);
|
|
})
|
|
.finally(() => {
|
|
isFetching = false;
|
|
loadingTagsEl.style.display = 'none';
|
|
loadingStatusesEl.style.display = 'none';
|
|
loadingLinksEl.style.display = 'none';
|
|
});
|
|
}
|
|
|
|
// Einheitliche Fehlerbehandlung
|
|
function handleErrors(response) {
|
|
if (!response.ok) {
|
|
throw new Error(`${response.status} ${response.statusText}`);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
function showError(msg) {
|
|
errorMessageEl.textContent = msg;
|
|
}
|
|
|
|
// Cache-Funktionen
|
|
function getCachedData(key) {
|
|
const cached = localStorage.getItem(key);
|
|
if (!cached) return null;
|
|
try {
|
|
const obj = JSON.parse(cached);
|
|
if (Date.now() - obj.timestamp < CACHE_TTL) {
|
|
return obj.data;
|
|
} else {
|
|
localStorage.removeItem(key);
|
|
return null;
|
|
}
|
|
} catch (err) {
|
|
localStorage.removeItem(key);
|
|
return null;
|
|
}
|
|
}
|
|
function setCachedData(key, data) {
|
|
const obj = { timestamp: Date.now(), data };
|
|
localStorage.setItem(key, JSON.stringify(obj));
|
|
}
|
|
|
|
// DRY-Fetcher (Template)
|
|
function createFetcher(endpoint, processData, renderFn) {
|
|
return (baseUrl, cacheKey) => {
|
|
const fromCache = getCachedData(cacheKey);
|
|
if (fromCache) {
|
|
const processed = processData(fromCache);
|
|
renderFn(processed);
|
|
return Promise.resolve(processed);
|
|
}
|
|
return fetch(`${baseUrl}/${endpoint}`)
|
|
.then(handleErrors)
|
|
.then(data => {
|
|
const processed = processData(data);
|
|
setCachedData(cacheKey, processed);
|
|
renderFn(processed);
|
|
return processed;
|
|
});
|
|
};
|
|
}
|
|
|
|
// Sortier-Helfer (z.B. nach Datum)
|
|
function sortByDateDesc(a, b) {
|
|
return new Date(b.created_at) - new Date(a.created_at);
|
|
}
|
|
|
|
// Fetch-Funktionen
|
|
const fetchTags = createFetcher('', d => d, renderTags);
|
|
const fetchStatuses = createFetcher('statuses', d => d.sort(sortByDateDesc), renderStatuses);
|
|
const fetchLinks = createFetcher('links', sortLinks, renderLinks);
|
|
|
|
function sortLinks(links) {
|
|
// Falls visits_count relevant:
|
|
links.sort((a, b) => (b.visits_count || 0) - (a.visits_count || 0));
|
|
return links;
|
|
}
|
|
|
|
// Rendering
|
|
function renderTags(tags) {
|
|
const list = document.getElementById('trends-tags');
|
|
list.innerHTML = '';
|
|
tags.forEach(tag => {
|
|
const li = document.createElement('li');
|
|
li.className = "media-list__item";
|
|
|
|
// XSS-Prävention: name aus HTML entfernen
|
|
const safeName = DOMPurify.sanitize(tag.name, {ALLOWED_TAGS: []});
|
|
const link = document.createElement('a');
|
|
link.href = tag.url;
|
|
link.textContent = `#${safeName}`;
|
|
link.className = "status__content";
|
|
link.setAttribute('aria-label', `Trend Tag ${safeName}`);
|
|
|
|
li.appendChild(link);
|
|
list.appendChild(li);
|
|
});
|
|
}
|
|
|
|
function renderStatuses(statuses) {
|
|
const container = document.getElementById('trends-statuses');
|
|
container.innerHTML = '';
|
|
statuses.forEach(status => {
|
|
const tootLink = document.createElement('a');
|
|
tootLink.href = status.url;
|
|
tootLink.target = "_blank";
|
|
tootLink.rel = "noopener noreferrer";
|
|
tootLink.className = "toot-link"; // Falls Styling notwendig ist
|
|
|
|
const toot = document.createElement('div');
|
|
toot.className = "toot-tile";
|
|
|
|
tootLink.appendChild(toot); // Hier wird das toot in den Link gepackt
|
|
|
|
|
|
const header = document.createElement('div');
|
|
header.className = "toot-header";
|
|
|
|
const avatar = document.createElement('img');
|
|
avatar.className = "toot-avatar";
|
|
avatar.loading = 'lazy'; // Bild-Lazy-Loading
|
|
avatar.src = status.account?.avatar || 'https://via.placeholder.com/50';
|
|
avatar.alt = `Profilbild von ${status.account?.username || 'Unbekannt'}`;
|
|
avatar.onerror = function() { this.src = 'fallback-avatar.png'; };
|
|
|
|
const safeName = DOMPurify.sanitize(status.account?.display_name || status.account?.username || 'Unbekannt', {ALLOWED_TAGS: []});
|
|
const author = document.createElement('div');
|
|
author.className = "toot-author";
|
|
author.textContent = safeName;
|
|
|
|
header.appendChild(avatar);
|
|
header.appendChild(author);
|
|
|
|
const content = document.createElement('div');
|
|
content.className = "toot-content";
|
|
content.innerHTML = DOMPurify.sanitize(status.content || '');
|
|
|
|
if (status.media_attachments?.length) {
|
|
const mediaDiv = document.createElement('div');
|
|
mediaDiv.className = "toot-media";
|
|
status.media_attachments.forEach(media => {
|
|
if (media.type === 'image') {
|
|
const img = document.createElement('img');
|
|
img.src = media.url;
|
|
img.alt = media.description || 'Vorschaubild';
|
|
img.loading = 'lazy';
|
|
mediaDiv.appendChild(img);
|
|
}
|
|
});
|
|
content.appendChild(mediaDiv);
|
|
}
|
|
|
|
const footer = document.createElement('div');
|
|
footer.className = "toot-footer";
|
|
|
|
const timestamp = document.createElement('span');
|
|
const date = new Date(status.created_at);
|
|
timestamp.textContent = date.toLocaleString('de-DE', {
|
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
|
hour: '2-digit', minute: '2-digit'
|
|
});
|
|
timestamp.className = "toot-timestamp";
|
|
|
|
const actions = document.createElement('div');
|
|
actions.className = "actions";
|
|
|
|
const retweetBtn = document.createElement('button');
|
|
retweetBtn.innerHTML = `🔁 ${status.reblogs_count || 0}`;
|
|
retweetBtn.setAttribute('aria-label', `${status.reblogs_count || 0} Retweets`);
|
|
|
|
const favBtn = document.createElement('button');
|
|
favBtn.innerHTML = `❤️ ${status.favourites_count || 0}`;
|
|
favBtn.setAttribute('aria-label', `${status.favourites_count || 0} Favoriten`);
|
|
|
|
actions.appendChild(retweetBtn);
|
|
actions.appendChild(favBtn);
|
|
|
|
footer.appendChild(timestamp);
|
|
footer.appendChild(actions);
|
|
|
|
toot.appendChild(header);
|
|
toot.appendChild(content);
|
|
toot.appendChild(footer);
|
|
|
|
container.appendChild(toot);
|
|
});
|
|
}
|
|
|
|
function renderLinks(links) {
|
|
const container = document.getElementById('trends-links');
|
|
container.innerHTML = '';
|
|
|
|
links.forEach((linkItem, index) => {
|
|
const tile = document.createElement('div');
|
|
tile.className = "link-tile";
|
|
|
|
const titleDiv = document.createElement('div');
|
|
titleDiv.className = 'link-title';
|
|
|
|
// XSS-Prävention
|
|
const safeTitle = DOMPurify.sanitize(linkItem.title || linkItem.url, {ALLOWED_TAGS: []});
|
|
const linkEl = document.createElement('a');
|
|
linkEl.href = linkItem.url;
|
|
linkEl.textContent = safeTitle;
|
|
linkEl.setAttribute('aria-label', `Trend Link: ${safeTitle}`);
|
|
titleDiv.appendChild(linkEl);
|
|
|
|
const descP = document.createElement('p');
|
|
descP.className = 'link-desc';
|
|
// Beschreibung ist evtl. HTML -> sanitizen
|
|
descP.innerHTML = DOMPurify.sanitize(linkItem.description || '');
|
|
|
|
const hitsSpan = document.createElement('span');
|
|
hitsSpan.className = 'link-hits';
|
|
const hitsValue = linkItem.visits_count || 0;
|
|
hitsSpan.textContent = `Aufrufe: ${hitsValue}`;
|
|
|
|
tile.appendChild(titleDiv);
|
|
tile.appendChild(descP);
|
|
tile.appendChild(hitsSpan);
|
|
|
|
// Falls Verlaufsdaten existieren -> optional Mini-Chart
|
|
if (linkItem.history && linkItem.history.length > 0) {
|
|
const chartCanvas = document.createElement('canvas');
|
|
chartCanvas.className = 'mini-chart';
|
|
chartCanvas.id = `mini-chart-${index}`;
|
|
tile.appendChild(chartCanvas);
|
|
|
|
createMiniChart(linkItem, chartCanvas);
|
|
}
|
|
|
|
container.appendChild(tile);
|
|
});
|
|
}
|
|
|
|
// Beispielhafte Mini-Chart-Funktion (optional)
|
|
function createMiniChart(linkItem, canvas) {
|
|
const labels = linkItem.history.map(h => {
|
|
const ms = parseInt(h.day, 10) * 1000;
|
|
const dt = new Date(ms);
|
|
return dt.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
|
|
});
|
|
|
|
const accountsData = linkItem.history.map(h => parseInt(h.accounts, 10) || 0);
|
|
const usesData = linkItem.history.map(h => parseInt(h.uses, 10) || 0);
|
|
|
|
new Chart(canvas, {
|
|
type: 'line',
|
|
data: {
|
|
labels,
|
|
datasets: [
|
|
{
|
|
label: 'Accounts',
|
|
data: accountsData,
|
|
borderColor: 'blue',
|
|
backgroundColor: 'rgba(0,0,255,0.1)',
|
|
fill: true,
|
|
tension: 0.2
|
|
},
|
|
{
|
|
label: 'Uses',
|
|
data: usesData,
|
|
borderColor: 'green',
|
|
backgroundColor: 'rgba(0,255,0,0.1)',
|
|
fill: true,
|
|
tension: 0.2
|
|
}
|
|
]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: true,
|
|
plugins: {
|
|
legend: { display: false }
|
|
},
|
|
scales: {
|
|
y: { beginAtZero: true }
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Neue Funktion zum Abrufen aller Trends
|
|
function fetchAllTrends() {
|
|
const platforms = [
|
|
'reliverse.social',
|
|
'libori.social',
|
|
'katholisch.social'
|
|
];
|
|
|
|
loadingTagsEl.style.display = 'block';
|
|
loadingStatusesEl.style.display = 'block';
|
|
loadingLinksEl.style.display = 'block';
|
|
errorMessageEl.textContent = '';
|
|
|
|
Promise.all(platforms.map(platform => {
|
|
const baseUrl = `https://${platform}/api/${API_VERSION}/trends`;
|
|
return Promise.all([
|
|
fetchTags(baseUrl, `${platform}-${API_VERSION}-tags`),
|
|
fetchStatuses(baseUrl, `${platform}-${API_VERSION}-statuses`),
|
|
fetchLinks(baseUrl, `${platform}-${API_VERSION}-links`)
|
|
]);
|
|
}))
|
|
.then(results => {
|
|
// Aggregiere die Tags
|
|
let allTags = [];
|
|
results.forEach(([tags, statuses, links]) => {
|
|
allTags = allTags.concat(tags);
|
|
});
|
|
// Entferne Duplikate basierend auf dem Namen
|
|
const uniqueTags = Array.from(new Map(allTags.map(tag => [tag.name, tag])).values());
|
|
renderTags(uniqueTags);
|
|
|
|
// Aggregiere die Statuses
|
|
let allStatuses = [];
|
|
results.forEach(([tags, statuses, links]) => {
|
|
allStatuses = allStatuses.concat(statuses);
|
|
});
|
|
// Sortiere nach Datum absteigend
|
|
allStatuses.sort(sortByDateDesc);
|
|
renderStatuses(allStatuses);
|
|
|
|
// Aggregiere die Links
|
|
let allLinks = [];
|
|
results.forEach(([tags, statuses, links]) => {
|
|
allLinks = allLinks.concat(links);
|
|
});
|
|
// Sortiere nach visits_count absteigend
|
|
allLinks.sort((a, b) => (b.visits_count || 0) - (a.visits_count || 0));
|
|
renderLinks(allLinks);
|
|
})
|
|
.catch(e => {
|
|
showError(e.message || e);
|
|
})
|
|
.finally(() => {
|
|
isFetching = false;
|
|
loadingTagsEl.style.display = 'none';
|
|
loadingStatusesEl.style.display = 'none';
|
|
loadingLinksEl.style.display = 'none';
|
|
});
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|