first commit

This commit is contained in:
2025-11-11 10:36:31 +01:00
commit 80eb037b56
25 changed files with 4509 additions and 0 deletions

887
web/app.js Normal file
View File

@@ -0,0 +1,887 @@
const API_URL = 'https://fb.srv.medeba-media.de/api';
let currentProfile = 1;
let currentTab = 'pending';
let posts = [];
let profilePollTimer = null;
const MAX_PROFILES = 5;
const PROFILE_NAMES = {
1: 'Profil 1',
2: 'Profil 2',
3: 'Profil 3',
4: 'Profil 4',
5: 'Profil 5'
};
const screenshotModal = document.getElementById('screenshotModal');
const screenshotModalContent = document.getElementById('screenshotModalContent');
const screenshotModalImage = document.getElementById('screenshotModalImage');
const screenshotModalClose = document.getElementById('screenshotModalClose');
const screenshotModalBackdrop = document.getElementById('screenshotModalBackdrop');
let screenshotModalLastFocus = null;
let screenshotModalPreviousOverflow = '';
let screenshotModalZoomed = false;
function getProfileName(profileNumber) {
if (!profileNumber) {
return '';
}
return PROFILE_NAMES[profileNumber] || `Profil ${profileNumber}`;
}
function formatDateTime(value) {
if (!value) {
return '';
}
try {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return date.toLocaleString('de-DE');
} catch (error) {
console.warn('Ungültiges Datum:', error);
return '';
}
}
function formatUrlForDisplay(url) {
if (!url) {
return '';
}
try {
const parsed = new URL(url);
const pathname = parsed.pathname === '/' ? '' : parsed.pathname;
const search = parsed.search || '';
return `${parsed.hostname}${pathname}${search}`;
} catch (error) {
return url;
}
}
function normalizeRequiredProfiles(post) {
if (Array.isArray(post.required_profiles) && post.required_profiles.length) {
return post.required_profiles
.map((value) => {
const parsed = parseInt(value, 10);
if (Number.isNaN(parsed)) {
return null;
}
return Math.min(MAX_PROFILES, Math.max(1, parsed));
})
.filter(Boolean);
}
const parsedTarget = parseInt(post.target_count, 10);
const count = Number.isNaN(parsedTarget)
? 1
: Math.min(MAX_PROFILES, Math.max(1, parsedTarget));
return Array.from({ length: count }, (_, index) => index + 1);
}
function normalizeChecks(checks) {
if (!Array.isArray(checks)) {
return [];
}
return checks
.map((check) => {
if (!check) {
return null;
}
const parsed = parseInt(check.profile_number, 10);
if (Number.isNaN(parsed)) {
return null;
}
const profileNumber = Math.min(MAX_PROFILES, Math.max(1, parsed));
return {
...check,
profile_number: profileNumber,
profile_name: check.profile_name || getProfileName(profileNumber),
checked_at: check.checked_at || null
};
})
.filter(Boolean)
.sort((a, b) => {
const aTime = a.checked_at ? new Date(a.checked_at).getTime() : 0;
const bTime = b.checked_at ? new Date(b.checked_at).getTime() : 0;
if (aTime === bTime) {
return a.profile_number - b.profile_number;
}
return aTime - bTime;
});
}
function computePostStatus(post, profileNumber = currentProfile) {
const requiredProfiles = normalizeRequiredProfiles(post);
const checks = normalizeChecks(post.checks);
const backendStatuses = Array.isArray(post.profile_statuses) ? post.profile_statuses : [];
let profileStatuses = backendStatuses
.map((status) => {
if (!status) {
return null;
}
const parsed = parseInt(status.profile_number, 10);
if (Number.isNaN(parsed)) {
return null;
}
const profileNumberValue = Math.min(MAX_PROFILES, Math.max(1, parsed));
let normalizedStatus = status.status;
if (normalizedStatus !== 'done' && normalizedStatus !== 'available') {
normalizedStatus = 'locked';
}
const check = checks.find((item) => item.profile_number === profileNumberValue) || null;
return {
profile_number: profileNumberValue,
profile_name: status.profile_name || getProfileName(profileNumberValue),
status: normalizedStatus,
checked_at: status.checked_at || (check ? check.checked_at : null) || null
};
})
.filter(Boolean);
if (profileStatuses.length !== requiredProfiles.length) {
const checksByProfile = new Map(checks.map((check) => [check.profile_number, check]));
const completedSet = new Set(checks.map((check) => check.profile_number));
profileStatuses = requiredProfiles.map((value, index) => {
const prerequisites = requiredProfiles.slice(0, index);
const prerequisitesMet = prerequisites.every((profile) => completedSet.has(profile));
const check = checksByProfile.get(value) || null;
return {
profile_number: value,
profile_name: getProfileName(value),
status: check ? 'done' : (prerequisitesMet ? 'available' : 'locked'),
checked_at: check ? check.checked_at : null
};
});
} else {
const checksByProfile = new Map(checks.map((check) => [check.profile_number, check]));
profileStatuses = requiredProfiles.map((value) => {
const status = profileStatuses.find((item) => item.profile_number === value);
if (!status) {
const check = checksByProfile.get(value) || null;
return {
profile_number: value,
profile_name: getProfileName(value),
status: check ? 'done' : 'locked',
checked_at: check ? check.checked_at : null
};
}
if (status.status === 'done') {
const check = checksByProfile.get(value) || null;
return {
...status,
checked_at: status.checked_at || (check ? check.checked_at : null) || null
};
}
if (status.status === 'available') {
return {
...status,
checked_at: status.checked_at || null
};
}
return {
...status,
status: 'locked',
checked_at: status.checked_at || null
};
});
}
const completedProfilesSet = new Set(
profileStatuses
.filter((status) => status.status === 'done')
.map((status) => status.profile_number)
);
const checkedCount = profileStatuses.filter((status) => status.status === 'done').length;
const targetCount = profileStatuses.length;
const isComplete = profileStatuses.every((status) => status.status === 'done');
const nextRequiredProfile = profileStatuses.find((status) => status.status === 'available') || null;
const isCurrentProfileRequired = requiredProfiles.includes(profileNumber);
const isCurrentProfileDone = profileStatuses.some((status) => status.profile_number === profileNumber && status.status === 'done');
const canCurrentProfileCheck = profileStatuses.some((status) => status.profile_number === profileNumber && status.status === 'available');
const waitingForStatuses = profileStatuses.filter((status) => status.profile_number < profileNumber && status.status !== 'done');
const waitingForProfiles = waitingForStatuses.map((status) => status.profile_number);
const waitingForNames = waitingForStatuses.map((status) => status.profile_name);
return {
requiredProfiles,
profileStatuses,
checks,
completedProfilesSet,
checkedCount,
targetCount,
isComplete,
isCurrentProfileRequired,
isCurrentProfileDone,
canCurrentProfileCheck,
waitingForProfiles,
waitingForNames,
nextRequiredProfile,
profileNumber
};
}
function applyScreenshotModalSize() {
if (!screenshotModalContent || !screenshotModalImage) {
return;
}
if (screenshotModalZoomed) {
return;
}
if (!screenshotModalImage.src) {
return;
}
requestAnimationFrame(() => {
const padding = 48;
const viewportWidth = Math.max(320, window.innerWidth * 0.95);
const viewportHeight = Math.max(280, window.innerHeight * 0.92);
const naturalWidth = screenshotModalImage.naturalWidth || screenshotModalImage.width || 0;
const naturalHeight = screenshotModalImage.naturalHeight || screenshotModalImage.height || 0;
const targetWidth = Math.min(Math.max(320, naturalWidth + padding), viewportWidth);
const targetHeight = Math.min(Math.max(260, naturalHeight + padding), viewportHeight);
screenshotModalContent.style.width = `${targetWidth}px`;
screenshotModalContent.style.height = `${targetHeight}px`;
});
}
async function fetchProfileState() {
try {
const response = await fetch(`${API_URL}/profile-state`);
if (!response.ok) {
return null;
}
const data = await response.json();
if (data && typeof data.profile_number !== 'undefined') {
const parsed = parseInt(data.profile_number, 10);
if (!Number.isNaN(parsed)) {
return parsed;
}
}
return null;
} catch (error) {
console.warn('Profilstatus konnte nicht geladen werden:', error);
return null;
}
}
async function pushProfileState(profileNumber) {
try {
await fetch(`${API_URL}/profile-state`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profile_number: profileNumber })
});
} catch (error) {
console.error('Profilstatus konnte nicht gespeichert werden:', error);
}
}
function applyProfileNumber(profileNumber, { fromBackend = false } = {}) {
if (!profileNumber) {
return;
}
document.getElementById('profileSelect').value = String(profileNumber);
if (currentProfile === profileNumber) {
if (!fromBackend) {
pushProfileState(profileNumber);
}
return;
}
currentProfile = profileNumber;
localStorage.setItem('profileNumber', currentProfile);
if (!fromBackend) {
pushProfileState(currentProfile);
}
renderPosts();
}
// Load profile from localStorage
function loadProfile() {
fetchProfileState().then((backendProfile) => {
if (backendProfile) {
applyProfileNumber(backendProfile, { fromBackend: true });
} else {
const saved = localStorage.getItem('profileNumber');
if (saved) {
applyProfileNumber(parseInt(saved, 10) || 1, { fromBackend: true });
} else {
applyProfileNumber(1, { fromBackend: true });
}
}
});
}
// Save profile to localStorage
function saveProfile(profileNumber) {
applyProfileNumber(profileNumber);
}
function startProfilePolling() {
if (profilePollTimer) {
clearInterval(profilePollTimer);
}
profilePollTimer = setInterval(async () => {
const backendProfile = await fetchProfileState();
if (backendProfile && backendProfile !== currentProfile) {
applyProfileNumber(backendProfile, { fromBackend: true });
}
}, 5000);
}
// Profile selector change handler
document.getElementById('profileSelect').addEventListener('change', (e) => {
saveProfile(parseInt(e.target.value, 10));
});
// Tab switching
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentTab = btn.dataset.tab;
renderPosts();
});
});
// Fetch all posts
async function fetchPosts() {
try {
showLoading();
const response = await fetch(`${API_URL}/posts`);
if (!response.ok) {
throw new Error('Failed to fetch posts');
}
posts = await response.json();
renderPosts();
} catch (error) {
showError('Fehler beim Laden der Beiträge. Stelle sicher, dass das Backend läuft.');
console.error('Error fetching posts:', error);
}
}
// Render posts
function renderPosts() {
hideLoading();
hideError();
const container = document.getElementById('postsContainer');
const postItems = posts.map((post) => ({
post,
status: computePostStatus(post)
}));
let filteredItems = postItems;
if (currentTab === 'pending') {
filteredItems = postItems.filter((item) => item.status.canCurrentProfileCheck && !item.status.isComplete);
}
if (filteredItems.length === 0) {
container.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">🎉</div>
<div class="empty-state-text">
${currentTab === 'pending' ? 'Keine offenen Beiträge!' : 'Noch keine Beiträge erfasst.'}
</div>
</div>
`;
return;
}
container.innerHTML = filteredItems
.map(({ post, status }) => createPostCard(post, status))
.join('');
filteredItems.forEach(({ post, status }) => {
const card = document.getElementById(`post-${post.id}`);
if (!card) {
return;
}
const openBtn = card.querySelector('.btn-open');
if (openBtn) {
openBtn.addEventListener('click', () => openPost(post.id));
}
const editBtn = card.querySelector('.btn-edit-target');
if (editBtn) {
editBtn.addEventListener('click', () => promptEditTarget(post.id, status.targetCount));
}
const deleteBtn = card.querySelector('.btn-delete');
if (deleteBtn) {
deleteBtn.addEventListener('click', () => deletePost(post.id));
}
const screenshotEl = card.querySelector('.post-screenshot');
if (screenshotEl && screenshotEl.dataset.screenshot) {
const url = screenshotEl.dataset.screenshot;
const openHandler = () => openScreenshotModal(url);
screenshotEl.addEventListener('click', openHandler);
screenshotEl.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
openScreenshotModal(url);
}
});
}
});
}
function openScreenshotModal(url) {
if (!screenshotModal || !url) {
return;
}
screenshotModalLastFocus = document.activeElement;
screenshotModalImage.src = url;
resetScreenshotZoom();
screenshotModalPreviousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
screenshotModal.removeAttribute('hidden');
screenshotModal.classList.add('open');
if (screenshotModalClose) {
screenshotModalClose.focus();
}
if (screenshotModalImage.complete) {
applyScreenshotModalSize();
} else {
const handleLoad = () => {
applyScreenshotModalSize();
};
screenshotModalImage.addEventListener('load', handleLoad, { once: true });
}
}
function closeScreenshotModal() {
if (!screenshotModal) {
return;
}
if (!screenshotModal.classList.contains('open')) {
return;
}
resetScreenshotZoom();
screenshotModal.classList.remove('open');
screenshotModal.setAttribute('hidden', '');
screenshotModalImage.src = '';
document.body.style.overflow = screenshotModalPreviousOverflow;
if (screenshotModalLastFocus && typeof screenshotModalLastFocus.focus === 'function') {
screenshotModalLastFocus.focus();
}
}
function resetScreenshotZoom() {
screenshotModalZoomed = false;
if (screenshotModalContent) {
screenshotModalContent.classList.remove('zoomed');
screenshotModalContent.style.width = '';
screenshotModalContent.style.height = '';
screenshotModalContent.scrollTo({ top: 0, left: 0, behavior: 'auto' });
}
if (screenshotModalImage) {
screenshotModalImage.classList.remove('zoomed');
}
applyScreenshotModalSize();
}
function toggleScreenshotZoom() {
if (!screenshotModalContent || !screenshotModalImage) {
return;
}
screenshotModalZoomed = !screenshotModalZoomed;
screenshotModalContent.classList.toggle('zoomed', screenshotModalZoomed);
screenshotModalImage.classList.toggle('zoomed', screenshotModalZoomed);
if (screenshotModalZoomed) {
screenshotModalContent.style.width = 'min(95vw, 1300px)';
screenshotModalContent.style.height = '92vh';
screenshotModalContent.scrollTo({ top: 0, left: 0, behavior: 'auto' });
} else {
screenshotModalContent.style.width = '';
screenshotModalContent.style.height = '';
applyScreenshotModalSize();
}
}
// Create post card HTML
function createPostCard(post, status) {
const createdDate = formatDateTime(post.created_at) || '—';
const resolvedScreenshotPath = post.screenshot_path
? (post.screenshot_path.startsWith('http')
? post.screenshot_path
: `${API_URL.replace(/\/api$/, '')}${post.screenshot_path}`)
: null;
const screenshotHtml = resolvedScreenshotPath
? `
<div class="post-screenshot" data-screenshot="${escapeHtml(resolvedScreenshotPath)}" role="button" tabindex="0" aria-label="Screenshot anzeigen">
<img src="${escapeHtml(resolvedScreenshotPath)}" alt="Screenshot zum Beitrag" loading="lazy" />
</div>
`
: '';
const profileRowsHtml = status.profileStatuses.map((profileStatus) => {
const classes = ['profile-line', `profile-line--${profileStatus.status}`];
let label = 'Wartet';
if (profileStatus.status === 'done') {
const doneDate = formatDateTime(profileStatus.checked_at);
label = doneDate ? `Erledigt (${doneDate})` : 'Erledigt';
} else if (profileStatus.status === 'available') {
label = 'Bereit';
}
return `
<div class="${classes.join(' ')}">
<span class="profile-line__name">${escapeHtml(profileStatus.profile_name)}</span>
<span class="profile-line__status">${escapeHtml(label)}</span>
</div>
`;
}).join('');
const infoMessages = [];
if (!status.isCurrentProfileRequired) {
infoMessages.push('Dieses Profil muss den Beitrag nicht bestätigen.');
} else if (status.isCurrentProfileDone) {
infoMessages.push('Für dein Profil erledigt.');
} else if (status.waitingForNames.length) {
infoMessages.push(`Wartet auf: ${status.waitingForNames.join(', ')}`);
}
const infoHtml = infoMessages.length
? `
<div class="post-hints">
${infoMessages.map((message) => `
<div class="post-hint${message.includes('erledigt') ? ' post-hint--success' : ''}">
${escapeHtml(message)}
</div>
`).join('')}
</div>
`
: '';
const directLinkHtml = post.url
? `
<div class="post-link">
<span class="post-link__label">Direktlink:</span>
<a href="${escapeHtml(post.url)}" target="_blank" rel="noopener noreferrer" class="post-link__anchor">
${escapeHtml(formatUrlForDisplay(post.url))}
</a>
</div>
`
: '';
const openButtonHtml = status.canCurrentProfileCheck
? `
<button class="btn btn-success btn-open">Beitrag öffnen & abhaken</button>
`
: '';
return `
<div class="post-card ${status.isComplete ? 'complete' : ''}" id="post-${post.id}">
<div class="post-header">
<div class="post-title">${escapeHtml(post.title || '')}</div>
<div class="post-status ${status.isComplete ? 'complete' : ''}">
${status.checkedCount}/${status.targetCount} ${status.isComplete ? '✓' : ''}
</div>
</div>
<div class="post-meta">
<div class="post-info">Erstellt: ${escapeHtml(createdDate)}</div>
<div class="post-target">
<span>Benötigte Profile: ${status.targetCount}</span>
<button type="button" class="btn btn-inline btn-edit-target">Anzahl ändern</button>
</div>
</div>
${directLinkHtml}
<div class="post-profiles">
${profileRowsHtml}
</div>
${infoHtml}
${screenshotHtml}
<div class="post-actions">
${openButtonHtml}
${post.url ? `
<a href="${escapeHtml(post.url)}" target="_blank" rel="noopener noreferrer" class="btn btn-secondary btn-direct-link">
Direkt öffnen
</a>
` : ''}
<button class="btn btn-danger btn-delete">Löschen</button>
</div>
</div>
`;
}
// Open post and auto-check
async function openPost(postId) {
const post = posts.find((item) => item.id === postId);
if (!post) {
alert('Beitrag konnte nicht gefunden werden.');
return;
}
if (!post.url) {
alert('Für diesen Beitrag ist kein Direktlink vorhanden.');
return;
}
const status = computePostStatus(post);
if (!status.isCurrentProfileRequired) {
alert('Dieses Profil muss den Beitrag nicht bestätigen.');
return;
}
if (status.isCurrentProfileDone) {
window.open(post.url, '_blank');
return;
}
if (!status.canCurrentProfileCheck) {
if (status.waitingForNames.length) {
alert(`Wartet auf: ${status.waitingForNames.join(', ')}`);
} else {
alert('Der Beitrag kann aktuell nicht abgehakt werden.');
}
return;
}
try {
const response = await fetch(`${API_URL}/check-by-url`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: post.url,
profile_number: currentProfile
})
});
if (!response.ok) {
if (response.status === 409) {
const data = await response.json().catch(() => null);
if (data && data.error) {
alert(data.error);
return;
}
}
throw new Error('Failed to check post');
}
window.open(post.url, '_blank');
await fetchPosts();
} catch (error) {
alert('Fehler beim Abhaken des Beitrags');
console.error('Error checking post:', error);
}
}
async function promptEditTarget(postId, currentTarget) {
const defaultValue = Number.isFinite(currentTarget) ? String(currentTarget) : '';
const newValue = prompt(`Neue Anzahl (1-${MAX_PROFILES}):`, defaultValue);
if (newValue === null) {
return;
}
const parsed = parseInt(newValue, 10);
if (Number.isNaN(parsed) || parsed < 1 || parsed > MAX_PROFILES) {
alert(`Bitte gib eine Zahl zwischen 1 und ${MAX_PROFILES} ein.`);
return;
}
try {
const response = await fetch(`${API_URL}/posts/${postId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target_count: parsed })
});
if (!response.ok) {
const data = await response.json().catch(() => null);
const message = data && data.error ? data.error : 'Fehler beim Aktualisieren der Anzahl.';
alert(message);
return;
}
await fetchPosts();
} catch (error) {
alert('Fehler beim Aktualisieren der Anzahl.');
console.error('Error updating target count:', error);
}
}
// Delete post
async function deletePost(postId) {
if (!confirm('Beitrag wirklich löschen?')) {
return;
}
try {
const response = await fetch(`${API_URL}/posts/${postId}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('Failed to delete post');
}
await fetchPosts();
} catch (error) {
alert('Fehler beim Löschen des Beitrags');
console.error('Error deleting post:', error);
}
}
// Utility functions
function showLoading() {
document.getElementById('loading').style.display = 'block';
document.getElementById('postsContainer').style.display = 'none';
}
function hideLoading() {
document.getElementById('loading').style.display = 'none';
document.getElementById('postsContainer').style.display = 'block';
}
function showError(message) {
const errorEl = document.getElementById('error');
errorEl.textContent = message;
errorEl.style.display = 'block';
}
function hideError() {
document.getElementById('error').style.display = 'none';
}
function escapeHtml(unsafe) {
if (unsafe === null || unsafe === undefined) {
unsafe = '';
}
if (typeof unsafe !== 'string') {
unsafe = String(unsafe);
}
return unsafe
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
// Auto-check on page load if URL parameter is present
function checkAutoCheck() {
const urlParams = new URLSearchParams(window.location.search);
const autoCheckUrl = urlParams.get('check');
if (autoCheckUrl) {
// Try to check this URL automatically
fetch(`${API_URL}/check-by-url`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: decodeURIComponent(autoCheckUrl),
profile_number: currentProfile
})
}).then(() => {
// Remove the parameter from URL
window.history.replaceState({}, document.title, window.location.pathname);
fetchPosts();
}).catch(console.error);
}
}
if (screenshotModalClose) {
screenshotModalClose.addEventListener('click', closeScreenshotModal);
}
if (screenshotModalBackdrop) {
screenshotModalBackdrop.addEventListener('click', closeScreenshotModal);
}
if (screenshotModalImage) {
screenshotModalImage.addEventListener('click', (event) => {
event.stopPropagation();
toggleScreenshotZoom();
});
screenshotModalImage.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
toggleScreenshotZoom();
}
});
screenshotModalImage.setAttribute('tabindex', '0');
screenshotModalImage.setAttribute('role', 'button');
screenshotModalImage.setAttribute('aria-label', 'Screenshot vergrößern oder verkleinern');
}
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
if (screenshotModalZoomed) {
resetScreenshotZoom();
return;
}
closeScreenshotModal();
}
});
window.addEventListener('resize', () => {
if (screenshotModal && screenshotModal.classList.contains('open') && !screenshotModalZoomed) {
applyScreenshotModalSize();
}
});
// Initialize
loadProfile();
startProfilePolling();
fetchPosts();
checkAutoCheck();
// Auto-refresh every 30 seconds
setInterval(fetchPosts, 30000);