Improve posts UI in three iterative passes
This commit is contained in:
191
web/app.js
191
web/app.js
@@ -357,6 +357,8 @@ const bookmarkSortSelect = document.getElementById('bookmarkSortSelect');
|
||||
const bookmarkSortDirectionToggle = document.getElementById('bookmarkSortDirectionToggle');
|
||||
const profileSelectElement = document.getElementById('profileSelect');
|
||||
const includeExpiredToggle = document.getElementById('includeExpiredToggle');
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
const searchClearBtn = document.getElementById('searchClearBtn');
|
||||
const mergeControls = document.getElementById('mergeControls');
|
||||
const mergeModeToggle = document.getElementById('mergeModeToggle');
|
||||
const mergeSubmitBtn = document.getElementById('mergeSubmitBtn');
|
||||
@@ -540,6 +542,8 @@ let pendingAutoOpenTimerId = null;
|
||||
let pendingAutoOpenCountdownIntervalId = null;
|
||||
let pendingProcessingBatch = false;
|
||||
let pendingOpenCooldownMap = loadPendingOpenCooldownMap(currentProfile);
|
||||
let pendingBulkStatusResetTimerId = null;
|
||||
let lastPendingBulkHint = { openableCount: 0, cooldownBlocked: 0 };
|
||||
|
||||
function updateIncludeExpiredToggleVisibility() {
|
||||
if (!includeExpiredToggle) {
|
||||
@@ -583,27 +587,89 @@ function updateMergeControlsUI() {
|
||||
}
|
||||
}
|
||||
|
||||
function updatePendingBulkControls(filteredCount = 0) {
|
||||
function clearPendingBulkStatusResetTimer() {
|
||||
if (pendingBulkStatusResetTimerId) {
|
||||
clearTimeout(pendingBulkStatusResetTimerId);
|
||||
pendingBulkStatusResetTimerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildPendingBulkHintText({ openableCount = 0, cooldownBlocked = 0 } = {}) {
|
||||
const openable = Math.max(0, openableCount || 0);
|
||||
const blocked = Math.max(0, cooldownBlocked || 0);
|
||||
if (blocked > 0) {
|
||||
return `Öffnungsbereit: ${openable} · Cooldown: ${blocked}`;
|
||||
}
|
||||
return `Öffnungsbereit: ${openable}`;
|
||||
}
|
||||
|
||||
function applyPendingBulkHintStatus() {
|
||||
if (!pendingBulkStatus) {
|
||||
return;
|
||||
}
|
||||
if (currentTab !== 'pending') {
|
||||
pendingBulkStatus.textContent = '';
|
||||
pendingBulkStatus.classList.remove('bulk-status--hint', 'bulk-status--error');
|
||||
return;
|
||||
}
|
||||
pendingBulkStatus.textContent = buildPendingBulkHintText(lastPendingBulkHint);
|
||||
pendingBulkStatus.classList.add('bulk-status--hint');
|
||||
pendingBulkStatus.classList.remove('bulk-status--error');
|
||||
}
|
||||
|
||||
function updatePendingBulkControls(filteredCount = 0, stats = null) {
|
||||
if (!pendingBulkControls) {
|
||||
return;
|
||||
}
|
||||
const isPendingTab = currentTab === 'pending';
|
||||
pendingBulkControls.hidden = !isPendingTab;
|
||||
pendingBulkControls.style.display = isPendingTab ? 'flex' : 'none';
|
||||
|
||||
if (!isPendingTab) {
|
||||
clearPendingBulkStatusResetTimer();
|
||||
if (pendingBulkStatus) {
|
||||
pendingBulkStatus.textContent = '';
|
||||
pendingBulkStatus.classList.remove('bulk-status--hint', 'bulk-status--error');
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedStats = stats && typeof stats === 'object'
|
||||
? {
|
||||
openableCount: Math.max(0, parseInt(stats.openableCount, 10) || 0),
|
||||
cooldownBlocked: Math.max(0, parseInt(stats.cooldownBlocked, 10) || 0)
|
||||
}
|
||||
: { openableCount: Math.max(0, filteredCount || 0), cooldownBlocked: 0 };
|
||||
lastPendingBulkHint = normalizedStats;
|
||||
|
||||
if (pendingBulkOpenBtn) {
|
||||
pendingBulkOpenBtn.disabled = !isPendingTab || pendingProcessingBatch || filteredCount === 0;
|
||||
pendingBulkOpenBtn.disabled = !isPendingTab || pendingProcessingBatch || normalizedStats.openableCount === 0;
|
||||
}
|
||||
|
||||
if (isPendingTab) {
|
||||
applyPendingBulkHintStatus();
|
||||
}
|
||||
}
|
||||
|
||||
function setPendingBulkStatus(message = '', isError = false) {
|
||||
clearPendingBulkStatusResetTimer();
|
||||
if (!pendingBulkStatus) {
|
||||
if (message) {
|
||||
showToast(message, isError ? 'error' : 'info');
|
||||
}
|
||||
return;
|
||||
}
|
||||
pendingBulkStatus.textContent = '';
|
||||
pendingBulkStatus.classList.remove('bulk-status--error');
|
||||
if (message) {
|
||||
showToast(message, isError ? 'error' : 'info');
|
||||
if (!message) {
|
||||
applyPendingBulkHintStatus();
|
||||
return;
|
||||
}
|
||||
pendingBulkStatus.textContent = message;
|
||||
pendingBulkStatus.classList.toggle('bulk-status--error', !!isError);
|
||||
pendingBulkStatus.classList.toggle('bulk-status--hint', !isError);
|
||||
showToast(message, isError ? 'error' : 'info');
|
||||
pendingBulkStatusResetTimerId = setTimeout(() => {
|
||||
pendingBulkStatusResetTimerId = null;
|
||||
applyPendingBulkHintStatus();
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
function initializeFocusParams() {
|
||||
@@ -1949,15 +2015,65 @@ function normalizeRequiredProfiles(post) {
|
||||
return Array.from({ length: count }, (_, index) => index + 1);
|
||||
}
|
||||
|
||||
function getTabButtons() {
|
||||
return Array.from(document.querySelectorAll('.tab-btn[data-tab]'));
|
||||
}
|
||||
|
||||
function updateTabButtons() {
|
||||
document.querySelectorAll('.tab-btn').forEach((button) => {
|
||||
if (!button.dataset.tab) {
|
||||
return;
|
||||
}
|
||||
button.classList.toggle('active', button.dataset.tab === currentTab);
|
||||
getTabButtons().forEach((button) => {
|
||||
const isActive = button.dataset.tab === currentTab;
|
||||
button.classList.toggle('active', isActive);
|
||||
button.setAttribute('aria-selected', isActive ? 'true' : 'false');
|
||||
button.setAttribute('tabindex', isActive ? '0' : '-1');
|
||||
});
|
||||
}
|
||||
|
||||
function handleTabKeydown(event) {
|
||||
if (!event || (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight' && event.key !== 'Home' && event.key !== 'End')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tabs = getTabButtons();
|
||||
if (!tabs.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIndex = tabs.findIndex((tab) => tab === event.currentTarget);
|
||||
if (currentIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
let nextIndex = currentIndex;
|
||||
|
||||
if (event.key === 'Home') {
|
||||
nextIndex = 0;
|
||||
} else if (event.key === 'End') {
|
||||
nextIndex = tabs.length - 1;
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
nextIndex = (currentIndex + 1) % tabs.length;
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
nextIndex = (currentIndex - 1 + tabs.length) % tabs.length;
|
||||
}
|
||||
|
||||
const nextTab = tabs[nextIndex];
|
||||
if (!nextTab || !nextTab.dataset.tab) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTab(nextTab.dataset.tab, { updateUrl: true });
|
||||
nextTab.focus();
|
||||
}
|
||||
|
||||
function updateSearchClearButtonVisibility() {
|
||||
if (!searchInput || !searchClearBtn) {
|
||||
return;
|
||||
}
|
||||
const hasValue = typeof searchInput.value === 'string' && searchInput.value.trim().length > 0;
|
||||
searchClearBtn.hidden = !hasValue;
|
||||
searchClearBtn.disabled = !hasValue;
|
||||
}
|
||||
|
||||
function isPostsViewActive() {
|
||||
const postsView = document.querySelector('[data-view="posts"]');
|
||||
return Boolean(postsView && postsView.classList.contains('app-view--active'));
|
||||
@@ -2026,7 +2142,6 @@ function getPostListState() {
|
||||
|
||||
const tabTotalCount = filteredItems.length;
|
||||
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
const searchValue = searchInput && typeof searchInput.value === 'string' ? searchInput.value.trim() : '';
|
||||
const searchActive = Boolean(searchValue);
|
||||
|
||||
@@ -3651,10 +3766,11 @@ if (pendingAutoOpenToggle) {
|
||||
}
|
||||
|
||||
// Tab switching
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
getTabButtons().forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
setTab(btn.dataset.tab, { updateUrl: true });
|
||||
});
|
||||
btn.addEventListener('keydown', handleTabKeydown);
|
||||
});
|
||||
|
||||
window.addEventListener('app:view-change', (event) => {
|
||||
@@ -3715,14 +3831,44 @@ if (manualRefreshBtn) {
|
||||
});
|
||||
}
|
||||
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', () => {
|
||||
updateSearchClearButtonVisibility();
|
||||
resetVisibleCount();
|
||||
renderPosts();
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (!event || event.key !== '/' || event.metaKey || event.ctrlKey || event.altKey) {
|
||||
return;
|
||||
}
|
||||
const target = event.target;
|
||||
const tagName = target && target.tagName ? target.tagName.toLowerCase() : '';
|
||||
const isEditable = !!(target && (target.isContentEditable || tagName === 'input' || tagName === 'textarea' || tagName === 'select'));
|
||||
if (isEditable || !isPostsViewActive()) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
searchInput.focus();
|
||||
searchInput.select();
|
||||
});
|
||||
}
|
||||
|
||||
if (searchClearBtn) {
|
||||
searchClearBtn.addEventListener('click', () => {
|
||||
if (!searchInput) {
|
||||
return;
|
||||
}
|
||||
searchInput.value = '';
|
||||
updateSearchClearButtonVisibility();
|
||||
resetVisibleCount();
|
||||
renderPosts();
|
||||
searchInput.focus();
|
||||
});
|
||||
}
|
||||
|
||||
updateSearchClearButtonVisibility();
|
||||
|
||||
if (mergeModeToggle) {
|
||||
mergeModeToggle.addEventListener('click', () => {
|
||||
if (currentTab !== 'all') {
|
||||
@@ -4129,7 +4275,22 @@ function renderPosts() {
|
||||
}
|
||||
|
||||
updateFilteredCount(currentTab, filteredItems.length);
|
||||
updatePendingBulkControls(filteredItems.length);
|
||||
let pendingBulkStats = null;
|
||||
if (currentTab === 'pending') {
|
||||
pendingBulkStats = filteredItems.reduce((stats, item) => {
|
||||
const post = item && item.post ? item.post : null;
|
||||
if (!post || !post.url) {
|
||||
return stats;
|
||||
}
|
||||
if (isPendingOpenCooldownActive(post.id)) {
|
||||
stats.cooldownBlocked += 1;
|
||||
} else {
|
||||
stats.openableCount += 1;
|
||||
}
|
||||
return stats;
|
||||
}, { openableCount: 0, cooldownBlocked: 0 });
|
||||
}
|
||||
updatePendingBulkControls(filteredItems.length, pendingBulkStats);
|
||||
|
||||
const visibleCount = Math.min(filteredItems.length, getVisibleCount(currentTab));
|
||||
const visibleItems = filteredItems.slice(0, visibleCount);
|
||||
|
||||
@@ -214,9 +214,9 @@
|
||||
</div>
|
||||
|
||||
<div class="tabs-section">
|
||||
<div class="tabs">
|
||||
<button class="tab-btn" data-tab="all">Alle Beiträge</button>
|
||||
<button class="tab-btn active" data-tab="pending">Offene Beiträge</button>
|
||||
<div class="tabs" role="tablist" aria-label="Beitragsansicht filtern">
|
||||
<button class="tab-btn" id="postsTabAll" type="button" role="tab" aria-selected="false" aria-controls="postsContainer" tabindex="-1" data-tab="all">Alle Beiträge</button>
|
||||
<button class="tab-btn active" id="postsTabPending" type="button" role="tab" aria-selected="true" aria-controls="postsContainer" tabindex="0" data-tab="pending">Offene Beiträge</button>
|
||||
</div>
|
||||
<div class="merge-controls" id="mergeControls" hidden>
|
||||
<div class="merge-actions">
|
||||
@@ -229,7 +229,13 @@
|
||||
<input type="checkbox" id="includeExpiredToggle">
|
||||
<span>Abgelaufene/abgeschlossene anzeigen</span>
|
||||
</label>
|
||||
<input type="text" id="searchInput" class="search-input" placeholder="Beiträge durchsuchen...">
|
||||
<div class="search-field">
|
||||
<label for="searchInput" class="search-field__label">Suche</label>
|
||||
<div class="search-field__input-wrap">
|
||||
<input type="search" id="searchInput" class="search-input" placeholder="Beiträge durchsuchen..." autocomplete="off" enterkeyhint="search">
|
||||
<button type="button" id="searchClearBtn" class="search-clear-btn" aria-label="Suche leeren" title="Suche leeren" hidden>×</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="posts-bulk-controls" id="pendingBulkControls" hidden>
|
||||
<div class="bulk-actions">
|
||||
|
||||
126
web/style.css
126
web/style.css
@@ -269,11 +269,12 @@ header {
|
||||
}
|
||||
|
||||
.search-input {
|
||||
padding: 6px 12px;
|
||||
padding: 8px 38px 8px 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
min-width: 200px;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
@@ -282,6 +283,50 @@ header {
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.search-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.search-field__label {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.search-field__input-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-clear-btn {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.search-clear-btn:hover {
|
||||
background: #e5e7eb;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.search-clear-btn:focus-visible {
|
||||
outline: 2px solid #1d4ed8;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -530,6 +575,11 @@ h1 {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.tab-btn:focus-visible {
|
||||
outline: 2px solid #1d4ed8;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background: #1877f2;
|
||||
color: white;
|
||||
@@ -637,10 +687,27 @@ h1 {
|
||||
.bulk-status {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
min-height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bulk-status--hint {
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #c7d2fe;
|
||||
background: #eef2ff;
|
||||
color: #1e3a8a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bulk-status--error {
|
||||
color: #dc2626;
|
||||
color: #991b1b;
|
||||
border: 1px solid #fecaca;
|
||||
background: #fee2e2;
|
||||
border-radius: 999px;
|
||||
padding: 6px 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.auto-open-overlay {
|
||||
@@ -2189,6 +2256,19 @@ h1 {
|
||||
max-height: 75vh;
|
||||
}
|
||||
|
||||
.search-field {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.bulk-actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.bulk-actions .btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bookmark-section__list {
|
||||
gap: 5px;
|
||||
}
|
||||
@@ -2354,6 +2434,46 @@ h1 {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.tabs-section {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
flex: 1 1 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.search-field {
|
||||
flex: 1 1 260px;
|
||||
}
|
||||
|
||||
.posts-bulk-controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bulk-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.bulk-status {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user