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

9
web/Dockerfile Normal file
View File

@@ -0,0 +1,9 @@
FROM nginx:alpine
COPY index.html /usr/share/nginx/html/
COPY style.css /usr/share/nginx/html/
COPY app.js /usr/share/nginx/html/
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

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);

46
web/index.html Normal file
View File

@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Facebook Post Tracker - Web Interface</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<header>
<h1>📋 Facebook Post Tracker</h1>
<div class="profile-selector">
<label for="profileSelect">Dein Profil:</label>
<select id="profileSelect">
<option value="1">Profil 1</option>
<option value="2">Profil 2</option>
<option value="3">Profil 3</option>
<option value="4">Profil 4</option>
<option value="5">Profil 5</option>
</select>
</div>
</header>
<div class="tabs">
<button class="tab-btn active" data-tab="pending">Offene Beiträge</button>
<button class="tab-btn" data-tab="all">Alle Beiträge</button>
</div>
<div id="loading" class="loading">Lade Beiträge...</div>
<div id="error" class="error" style="display: none;"></div>
<div id="postsContainer" class="posts-container"></div>
</div>
<div id="screenshotModal" class="screenshot-modal" hidden>
<div id="screenshotModalBackdrop" class="screenshot-modal__backdrop" aria-hidden="true"></div>
<div id="screenshotModalContent" class="screenshot-modal__content" role="dialog" aria-modal="true">
<button type="button" id="screenshotModalClose" class="screenshot-modal__close" aria-label="Schließen">×</button>
<img id="screenshotModalImage" alt="Screenshot zum Beitrag" />
</div>
</div>
<script src="app.js"></script>
</body>
</html>

534
web/style.css Normal file
View File

@@ -0,0 +1,534 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: #f0f2f5;
color: #050505;
line-height: 1.5;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
header {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
margin-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 16px;
}
h1 {
font-size: 24px;
font-weight: 700;
}
.profile-selector {
display: flex;
align-items: center;
gap: 12px;
}
.profile-selector label {
font-size: 14px;
font-weight: 600;
}
.profile-selector select {
padding: 8px 12px;
border: 1px solid #ccd0d5;
border-radius: 6px;
font-size: 14px;
background: white;
cursor: pointer;
}
.tabs {
display: flex;
gap: 8px;
margin-bottom: 20px;
}
.tab-btn {
padding: 10px 20px;
background: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.tab-btn:hover {
background: #f8f9fa;
}
.tab-btn.active {
background: #1877f2;
color: white;
}
.loading,
.error {
text-align: center;
padding: 40px;
background: white;
border-radius: 8px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.error {
color: #dc2626;
}
.posts-container {
display: flex;
flex-direction: column;
gap: 16px;
}
.post-card {
background: white;
border-radius: 8px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
padding: 20px;
transition: transform 0.2s, box-shadow 0.2s;
}
.post-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.post-card.complete {
opacity: 0.7;
border-left: 4px solid #059669;
}
.post-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
gap: 12px;
}
.post-title {
flex: 1;
font-size: 16px;
color: #050505;
line-height: 1.4;
}
.post-status {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: #f0f2f5;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
white-space: nowrap;
}
.post-status.complete {
background: #d1fae5;
color: #065f46;
}
.post-meta {
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: space-between;
margin-bottom: 12px;
}
.post-info {
font-size: 13px;
color: #65676b;
margin-bottom: 0;
}
.post-target {
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
color: #1f2937;
}
.btn-inline {
background: none;
border: none;
color: #2563eb;
padding: 0;
font-size: 13px;
font-weight: 600;
cursor: pointer;
border-radius: 0;
display: inline;
}
.btn-inline:hover,
.btn-inline:focus {
text-decoration: underline;
}
.post-link {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
font-size: 13px;
margin-bottom: 12px;
}
.post-link__label {
font-weight: 600;
color: #4b5563;
}
.post-link__anchor {
color: #2563eb;
text-decoration: none;
word-break: break-all;
}
.post-link__anchor:hover {
text-decoration: underline;
}
.post-profiles {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #f9fafb;
margin-bottom: 12px;
}
.profile-line {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 13px;
padding: 6px 8px;
border-radius: 6px;
}
.profile-line__name {
font-weight: 600;
}
.profile-line__status {
font-weight: 500;
}
.profile-line--done {
background: #ecfdf5;
color: #047857;
border-left: 3px solid #10b981;
}
.profile-line--available {
background: #eff6ff;
color: #1d4ed8;
border-left: 3px solid #3b82f6;
}
.profile-line--locked {
background: #f3f4f6;
color: #6b7280;
border-left: 3px solid #9ca3af;
}
.post-hints {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
}
.post-hint {
font-size: 13px;
color: #92400e;
background: #fff7ed;
border-radius: 6px;
padding: 8px 10px;
}
.post-hint--success {
color: #065f46;
background: #ecfdf5;
}
.post-screenshot {
margin-bottom: 12px;
border: 1px solid #d1d5db;
border-radius: 12px;
background: #f8fafc;
display: flex;
align-items: center;
justify-content: center;
height: clamp(180px, 24vw, 240px);
padding: 12px;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
outline: none;
overflow: hidden;
}
.post-screenshot:hover,
.post-screenshot:focus-visible {
transform: translateY(-2px);
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.18);
}
.post-screenshot img {
max-width: 100%;
max-height: 100%;
width: auto;
height: 100%;
object-fit: contain;
display: block;
border-radius: 8px;
box-shadow: 0 4px 18px rgba(15, 23, 42, 0.18);
}
.check-badge {
padding: 4px 10px;
background: #e4e6eb;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
}
.check-badge:not(.checked) {
color: #4b5563;
}
.check-badge.checked {
background: #d1fae5;
color: #065f46;
}
.check-badge.current {
background: #dbeafe;
color: #1e40af;
border: 2px solid #3b82f6;
}
.post-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.btn {
padding: 10px 16px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
display: inline-block;
text-align: center;
}
.btn-primary {
background: #1877f2;
color: white;
}
.btn-primary:hover {
background: #166fe5;
}
.btn-success {
background: #059669;
color: white;
}
.btn-success:hover {
background: #047857;
}
.btn-danger {
background: #dc2626;
color: white;
}
.btn-danger:hover {
background: #b91c1c;
}
.btn-secondary {
background: #e4e6eb;
color: #050505;
}
.btn-secondary:hover {
background: #d8dadf;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.screenshot-modal {
position: fixed;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
padding: 24px;
background: rgba(15, 23, 42, 0.7);
z-index: 1000;
}
.screenshot-modal[hidden] {
display: none;
}
.screenshot-modal__backdrop {
position: absolute;
inset: 0;
}
.screenshot-modal__content {
position: relative;
width: min(95vw, 1300px);
max-height: 92vh;
padding: 24px;
border-radius: 18px;
background: rgba(15, 23, 42, 0.92);
display: flex;
justify-content: center;
align-items: center;
box-shadow: 0 30px 60px rgba(15, 23, 42, 0.45);
overflow: hidden;
}
.screenshot-modal__content.zoomed {
justify-content: flex-start;
align-items: flex-start;
}
.screenshot-modal__content img {
max-width: calc(95vw - 96px);
max-height: calc(92vh - 120px);
width: auto;
height: auto;
object-fit: contain;
border-radius: 12px;
box-shadow: 0 20px 44px rgba(8, 15, 35, 0.45);
cursor: zoom-in;
}
.screenshot-modal__content.zoomed {
cursor: zoom-out;
overflow: auto;
}
.screenshot-modal__content.zoomed img {
max-width: none;
max-height: none;
width: auto;
height: auto;
cursor: zoom-out;
}
.screenshot-modal__close {
position: absolute;
top: 12px;
right: 12px;
width: 36px;
height: 36px;
border-radius: 999px;
border: none;
background: #111827;
color: #fff;
font-size: 22px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.3);
transition: background 0.2s ease, transform 0.2s ease;
}
.screenshot-modal__close:hover {
background: #2563eb;
transform: scale(1.05);
}
.screenshot-modal__close:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
.empty-state {
text-align: center;
padding: 60px 20px;
background: white;
border-radius: 8px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.empty-state-icon {
font-size: 64px;
margin-bottom: 16px;
}
.empty-state-text {
font-size: 18px;
color: #65676b;
}
@media (max-width: 768px) {
.container {
padding: 12px;
}
header {
padding: 16px;
}
h1 {
font-size: 20px;
}
.post-header {
flex-direction: column;
}
.post-actions {
flex-direction: column;
}
.btn {
width: 100%;
}
}