feat: streamline publish tab and calendar UI
- Merged Weekly and Multi-Week tabs into a single "By Week" tab - Updated calendar to visually indicate days with articles via a dot indicator - Improved calendar week selection to allow toggling multiple individual weeks - Enhanced calendar hover states to invert foreground text for readability - Fixed active tab styling to remove clashing bottom borders and focus outlines Made-with: Cursor
This commit is contained in:
@@ -31,10 +31,12 @@ def _calendar_data(year: int, month: int) -> list[dict]:
|
|||||||
week_end = current + timedelta(days=6)
|
week_end = current + timedelta(days=6)
|
||||||
iso_week = week_start.isocalendar()[1]
|
iso_week = week_start.isocalendar()[1]
|
||||||
|
|
||||||
article_count = Article.query.filter(
|
week_articles = Article.query.filter(
|
||||||
Article.pub_date >= str(week_start),
|
Article.pub_date >= str(week_start),
|
||||||
Article.pub_date < str(week_end + timedelta(days=1)),
|
Article.pub_date < str(week_end + timedelta(days=1)),
|
||||||
).count()
|
).all()
|
||||||
|
article_count = len(week_articles)
|
||||||
|
article_dates = {a.pub_date.date() for a in week_articles}
|
||||||
|
|
||||||
days = []
|
days = []
|
||||||
for i in range(7):
|
for i in range(7):
|
||||||
@@ -43,6 +45,7 @@ def _calendar_data(year: int, month: int) -> list[dict]:
|
|||||||
"day": d.day,
|
"day": d.day,
|
||||||
"date": d.isoformat(),
|
"date": d.isoformat(),
|
||||||
"in_month": d.month == month,
|
"in_month": d.month == month,
|
||||||
|
"has_articles": d in article_dates,
|
||||||
})
|
})
|
||||||
|
|
||||||
weeks.append({
|
weeks.append({
|
||||||
|
|||||||
@@ -62,17 +62,25 @@ nav .brand { font-weight: bold; font-size: 1.1rem; }
|
|||||||
padding: 0.5rem 1.2rem;
|
padding: 0.5rem 1.2rem;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
border-bottom: 3px solid transparent;
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -2px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
color: var(--pico-muted-color);
|
color: var(--pico-muted-color);
|
||||||
}
|
}
|
||||||
|
.tab:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
.tab.active {
|
.tab.active {
|
||||||
color: var(--pico-primary);
|
color: var(--pico-primary-inverse);
|
||||||
|
background: var(--pico-primary);
|
||||||
border-bottom-color: var(--pico-primary);
|
border-bottom-color: var(--pico-primary);
|
||||||
|
border-radius: var(--pico-border-radius) var(--pico-border-radius) 0 0;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.tab:hover { color: var(--pico-primary); }
|
.tab:hover { color: var(--pico-primary); }
|
||||||
|
.tab.active:hover { color: var(--pico-primary-inverse); }
|
||||||
.tab-content { display: none; }
|
.tab-content { display: none; }
|
||||||
.tab-content.active { display: block; }
|
.tab-content.active { display: block; }
|
||||||
|
|
||||||
@@ -103,17 +111,49 @@ nav .brand { font-weight: bold; font-size: 1.1rem; }
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.cal-week, .cal-week-multi { cursor: pointer; }
|
.cal-week, .cal-week-multi { cursor: pointer; }
|
||||||
.cal-week:hover, .cal-week-multi:hover {
|
.cal-week:hover > td, .cal-week-multi:hover > td {
|
||||||
background: var(--pico-secondary-hover-background);
|
background: var(--pico-primary-hover-background);
|
||||||
|
color: var(--pico-primary-inverse);
|
||||||
}
|
}
|
||||||
.cal-week.selected, .cal-week-multi.selected {
|
.cal-week:hover .cal-wk, .cal-week-multi:hover .cal-wk {
|
||||||
|
color: var(--pico-primary-inverse);
|
||||||
|
}
|
||||||
|
.cal-week:hover .has-articles::after, .cal-week-multi:hover .has-articles::after {
|
||||||
|
background-color: var(--pico-primary-inverse);
|
||||||
|
}
|
||||||
|
.cal-week.selected > td, .cal-week-multi.selected > td {
|
||||||
background: var(--pico-primary-background);
|
background: var(--pico-primary-background);
|
||||||
color: var(--pico-primary-inverse);
|
color: var(--pico-primary-inverse);
|
||||||
}
|
}
|
||||||
.cal-week-multi.in-range {
|
.cal-week-multi.in-range > td {
|
||||||
background: var(--pico-primary-focus);
|
background: var(--pico-primary-focus);
|
||||||
color: var(--pico-primary-inverse);
|
color: var(--pico-primary-inverse);
|
||||||
}
|
}
|
||||||
|
.cal-week.selected .cal-wk, .cal-week-multi.selected .cal-wk,
|
||||||
|
.cal-week-multi.in-range .cal-wk {
|
||||||
|
color: var(--pico-primary-inverse);
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-articles {
|
||||||
|
font-weight: bold;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.has-articles::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 2px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--pico-primary);
|
||||||
|
}
|
||||||
|
.cal-week.selected .has-articles::after,
|
||||||
|
.cal-week-multi.selected .has-articles::after,
|
||||||
|
.cal-week-multi.in-range .has-articles::after {
|
||||||
|
background-color: var(--pico-primary-inverse);
|
||||||
|
}
|
||||||
|
|
||||||
/* Publish page */
|
/* Publish page */
|
||||||
.publish-summary {
|
.publish-summary {
|
||||||
|
|||||||
@@ -4,13 +4,13 @@
|
|||||||
<h1>Publish Issue</h1>
|
<h1>Publish Issue</h1>
|
||||||
|
|
||||||
<div class="tab-bar">
|
<div class="tab-bar">
|
||||||
<button class="tab active" data-tab="weekly" onclick="switchTab('weekly')">Weekly Issue</button>
|
<button class="tab active" data-tab="weekly" onclick="switchTab('weekly')">By Week</button>
|
||||||
<button class="tab" data-tab="multi-week" onclick="switchTab('multi-week')">Multi-Week</button>
|
|
||||||
<button class="tab" data-tab="single-article" onclick="switchTab('single-article')">Single Article</button>
|
<button class="tab" data-tab="single-article" onclick="switchTab('single-article')">Single Article</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- WEEKLY TAB -->
|
<!-- WEEKLY TAB -->
|
||||||
<div id="tab-weekly" class="tab-content active">
|
<div id="tab-weekly" class="tab-content active">
|
||||||
|
<p><small><em>Select a single week, or click two different weeks to select a range.</em></small></p>
|
||||||
<div class="calendar-widget">
|
<div class="calendar-widget">
|
||||||
<div class="calendar-nav">
|
<div class="calendar-nav">
|
||||||
<button class="outline" onclick="changeMonth(-1)">◀</button>
|
<button class="outline" onclick="changeMonth(-1)">◀</button>
|
||||||
@@ -26,10 +26,10 @@
|
|||||||
<tbody id="cal-body">
|
<tbody id="cal-body">
|
||||||
{% for week in calendar_weeks %}
|
{% for week in calendar_weeks %}
|
||||||
<tr class="cal-week" data-start="{{ week.week_start }}" data-end="{{ week.week_end }}"
|
<tr class="cal-week" data-start="{{ week.week_start }}" data-end="{{ week.week_end }}"
|
||||||
onclick="selectWeek(this)">
|
onclick="selectWeekMulti(this)">
|
||||||
<td class="cal-wk">{{ week.iso_week }}{% if week.article_count %} <small>({{ week.article_count }})</small>{% endif %}</td>
|
<td class="cal-wk">{{ week.iso_week }}{% if week.article_count %} <small>({{ week.article_count }})</small>{% endif %}</td>
|
||||||
{% for day in week.days %}
|
{% for day in week.days %}
|
||||||
<td class="{% if not day.in_month %}cal-dim{% endif %}">{{ day.day }}</td>
|
<td class="{% if not day.in_month %}cal-dim{% endif %} {% if day.has_articles %}has-articles{% endif %}">{{ day.day }}</td>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
<form method="post" action="/publish" id="weekly-form">
|
<form method="post" action="/publish" id="weekly-form">
|
||||||
<input type="hidden" name="week_start" id="weekly-start">
|
<input type="hidden" name="week_start" id="weekly-start">
|
||||||
<input type="hidden" name="week_end" id="weekly-end">
|
<input type="hidden" name="week_end" id="weekly-end">
|
||||||
<input type="hidden" name="issue_type" value="weekly">
|
<input type="hidden" name="issue_type" id="weekly-issue-type" value="weekly">
|
||||||
<div id="weekly-checkboxes"></div>
|
<div id="weekly-checkboxes"></div>
|
||||||
<div class="publish-actions">
|
<div class="publish-actions">
|
||||||
<select name="cover_method">
|
<select name="cover_method">
|
||||||
@@ -53,48 +53,6 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- MULTI-WEEK TAB -->
|
|
||||||
<div id="tab-multi-week" class="tab-content">
|
|
||||||
<div class="calendar-widget">
|
|
||||||
<div class="calendar-nav">
|
|
||||||
<button class="outline" onclick="changeMonthMulti(-1)">◀</button>
|
|
||||||
<strong id="cal-month-label-multi">{{ cal_month_name }}</strong>
|
|
||||||
<button class="outline" onclick="changeMonthMulti(1)">▶</button>
|
|
||||||
</div>
|
|
||||||
<table class="calendar-grid">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Wk</th><th>Mon</th><th>Tue</th><th>Wed</th><th>Thu</th><th>Fri</th><th>Sat</th><th>Sun</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="cal-body-multi">
|
|
||||||
{% for week in calendar_weeks %}
|
|
||||||
<tr class="cal-week-multi" data-start="{{ week.week_start }}" data-end="{{ week.week_end }}"
|
|
||||||
onclick="selectWeekMulti(this)">
|
|
||||||
<td class="cal-wk">{{ week.iso_week }}{% if week.article_count %} <small>({{ week.article_count }})</small>{% endif %}</td>
|
|
||||||
{% for day in week.days %}
|
|
||||||
<td class="{% if not day.in_month %}cal-dim{% endif %}">{{ day.day }}</td>
|
|
||||||
{% endfor %}
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div id="multi-summary" class="publish-summary"></div>
|
|
||||||
<form method="post" action="/publish" id="multi-form">
|
|
||||||
<input type="hidden" name="week_start" id="multi-start">
|
|
||||||
<input type="hidden" name="week_end" id="multi-end">
|
|
||||||
<input type="hidden" name="issue_type" value="multi_week">
|
|
||||||
<div id="multi-checkboxes"></div>
|
|
||||||
<div class="publish-actions">
|
|
||||||
<select name="cover_method">
|
|
||||||
<option value="mosaic">Mosaic Cover</option>
|
|
||||||
<option value="text">Text Cover</option>
|
|
||||||
</select>
|
|
||||||
<button type="submit">Generate Issue</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- SINGLE ARTICLE TAB -->
|
<!-- SINGLE ARTICLE TAB -->
|
||||||
<div id="tab-single-article" class="tab-content">
|
<div id="tab-single-article" class="tab-content">
|
||||||
@@ -132,10 +90,7 @@
|
|||||||
<script>
|
<script>
|
||||||
let calYear = {{ cal_year }};
|
let calYear = {{ cal_year }};
|
||||||
let calMonth = {{ cal_month }};
|
let calMonth = {{ cal_month }};
|
||||||
let calYearMulti = {{ cal_year }};
|
let selectedWeeks = [];
|
||||||
let calMonthMulti = {{ cal_month }};
|
|
||||||
let multiStart = null;
|
|
||||||
let multiEnd = null;
|
|
||||||
|
|
||||||
function switchTab(tab) {
|
function switchTab(tab) {
|
||||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||||
@@ -157,57 +112,58 @@ async function loadArticles(start, end, targetId) {
|
|||||||
return articles.length;
|
return articles.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectWeek(row) {
|
|
||||||
document.querySelectorAll('.cal-week').forEach(r => r.classList.remove('selected'));
|
|
||||||
row.classList.add('selected');
|
|
||||||
const start = row.dataset.start;
|
|
||||||
const end = row.dataset.end;
|
|
||||||
document.getElementById('weekly-start').value = start;
|
|
||||||
document.getElementById('weekly-end').value = end;
|
|
||||||
const count = await loadArticles(start, end, 'weekly-checkboxes');
|
|
||||||
const ws = new Date(start + 'T00:00:00');
|
|
||||||
const we = new Date(end + 'T00:00:00');
|
|
||||||
const opts = { month: 'short', day: 'numeric' };
|
|
||||||
const optsYear = { month: 'short', day: 'numeric', year: 'numeric' };
|
|
||||||
document.getElementById('weekly-summary').innerHTML =
|
|
||||||
`<strong>Week ${row.cells[0].textContent.trim().split(' ')[0]}:</strong> ${ws.toLocaleDateString('en-US', opts)} – ${we.toLocaleDateString('en-US', optsYear)} · <strong>${count} articles</strong>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectWeekMulti(row) {
|
async function selectWeekMulti(row) {
|
||||||
const start = row.dataset.start;
|
const start = row.dataset.start;
|
||||||
const end = row.dataset.end;
|
const end = row.dataset.end;
|
||||||
|
|
||||||
if (!multiStart || (multiStart && multiEnd)) {
|
// Toggle selection
|
||||||
multiStart = { start, end, row };
|
const existingIndex = selectedWeeks.findIndex(w => w.start === start);
|
||||||
multiEnd = null;
|
if (existingIndex >= 0) {
|
||||||
document.querySelectorAll('.cal-week-multi').forEach(r => r.classList.remove('selected', 'in-range'));
|
selectedWeeks.splice(existingIndex, 1);
|
||||||
row.classList.add('selected');
|
row.classList.remove('selected');
|
||||||
} else {
|
} else {
|
||||||
multiEnd = { start, end, row };
|
selectedWeeks.push({ start, end, row });
|
||||||
if (multiEnd.start < multiStart.start) {
|
row.classList.add('selected');
|
||||||
[multiStart, multiEnd] = [multiEnd, multiStart];
|
}
|
||||||
}
|
|
||||||
document.querySelectorAll('.cal-week-multi').forEach(r => {
|
if (selectedWeeks.length === 0) {
|
||||||
r.classList.remove('selected', 'in-range');
|
document.getElementById('weekly-start').value = '';
|
||||||
if (r.dataset.start >= multiStart.start && r.dataset.end <= multiEnd.end) {
|
document.getElementById('weekly-end').value = '';
|
||||||
r.classList.add('in-range');
|
document.getElementById('weekly-checkboxes').innerHTML = '';
|
||||||
}
|
document.getElementById('weekly-summary').innerHTML = '';
|
||||||
});
|
return;
|
||||||
multiStart.row.classList.add('selected');
|
}
|
||||||
multiEnd.row.classList.add('selected');
|
|
||||||
|
// Sort selected weeks by start date
|
||||||
|
selectedWeeks.sort((a, b) => a.start.localeCompare(b.start));
|
||||||
|
|
||||||
|
const rangeStart = selectedWeeks[0].start;
|
||||||
|
const rangeEnd = selectedWeeks[selectedWeeks.length - 1].end;
|
||||||
|
|
||||||
|
document.getElementById('weekly-start').value = rangeStart;
|
||||||
|
document.getElementById('weekly-end').value = rangeEnd;
|
||||||
|
|
||||||
|
// Update issue_type based on selection
|
||||||
|
const issueTypeInput = document.getElementById('weekly-issue-type');
|
||||||
|
if (rangeStart === rangeEnd) {
|
||||||
|
issueTypeInput.value = 'weekly';
|
||||||
|
} else {
|
||||||
|
issueTypeInput.value = 'multi_week';
|
||||||
}
|
}
|
||||||
|
|
||||||
const rangeStart = multiStart.start;
|
const count = await loadArticles(rangeStart, rangeEnd, 'weekly-checkboxes');
|
||||||
const rangeEnd = (multiEnd || multiStart).end;
|
|
||||||
document.getElementById('multi-start').value = rangeStart;
|
|
||||||
document.getElementById('multi-end').value = rangeEnd;
|
|
||||||
const count = await loadArticles(rangeStart, rangeEnd, 'multi-checkboxes');
|
|
||||||
const ws = new Date(rangeStart + 'T00:00:00');
|
const ws = new Date(rangeStart + 'T00:00:00');
|
||||||
const we = new Date(rangeEnd + 'T00:00:00');
|
const we = new Date(rangeEnd + 'T00:00:00');
|
||||||
const opts = { month: 'short', day: 'numeric' };
|
const opts = { month: 'short', day: 'numeric' };
|
||||||
const optsYear = { month: 'short', day: 'numeric', year: 'numeric' };
|
const optsYear = { month: 'short', day: 'numeric', year: 'numeric' };
|
||||||
document.getElementById('multi-summary').innerHTML =
|
|
||||||
`<strong>Range:</strong> ${ws.toLocaleDateString('en-US', opts)} – ${we.toLocaleDateString('en-US', optsYear)} · <strong>${count} articles</strong>`;
|
let summaryHtml = '';
|
||||||
|
if (rangeStart === rangeEnd) {
|
||||||
|
summaryHtml = `<strong>Week ${selectedWeeks[0].row.cells[0].textContent.trim().split(' ')[0]}:</strong> ${ws.toLocaleDateString('en-US', opts)} – ${we.toLocaleDateString('en-US', optsYear)} · <strong>${count} articles</strong>`;
|
||||||
|
} else {
|
||||||
|
summaryHtml = `<strong>Range:</strong> ${ws.toLocaleDateString('en-US', opts)} – ${we.toLocaleDateString('en-US', optsYear)} · <strong>${count} articles</strong>`;
|
||||||
|
}
|
||||||
|
document.getElementById('weekly-summary').innerHTML = summaryHtml;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function changeMonth(delta) {
|
async function changeMonth(delta) {
|
||||||
@@ -216,30 +172,27 @@ async function changeMonth(delta) {
|
|||||||
if (calMonth < 1) { calMonth = 12; calYear--; }
|
if (calMonth < 1) { calMonth = 12; calYear--; }
|
||||||
const resp = await fetch(`/publish/calendar?year=${calYear}&month=${calMonth}`);
|
const resp = await fetch(`/publish/calendar?year=${calYear}&month=${calMonth}`);
|
||||||
const weeks = await resp.json();
|
const weeks = await resp.json();
|
||||||
renderCalendar(weeks, 'cal-body', 'cal-week', 'selectWeek(this)');
|
renderCalendar(weeks, 'cal-body', 'cal-week', 'selectWeekMulti(this)');
|
||||||
document.getElementById('cal-month-label').textContent =
|
document.getElementById('cal-month-label').textContent =
|
||||||
new Date(calYear, calMonth - 1).toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
|
new Date(calYear, calMonth - 1).toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
|
||||||
}
|
|
||||||
|
// Re-apply selected classes based on tracked selectedWeeks
|
||||||
async function changeMonthMulti(delta) {
|
document.querySelectorAll('.cal-week').forEach(row => {
|
||||||
calMonthMulti += delta;
|
if (selectedWeeks.some(w => w.start === row.dataset.start)) {
|
||||||
if (calMonthMulti > 12) { calMonthMulti = 1; calYearMulti++; }
|
row.classList.add('selected');
|
||||||
if (calMonthMulti < 1) { calMonthMulti = 12; calYearMulti--; }
|
}
|
||||||
const resp = await fetch(`/publish/calendar?year=${calYearMulti}&month=${calMonthMulti}`);
|
});
|
||||||
const weeks = await resp.json();
|
|
||||||
renderCalendar(weeks, 'cal-body-multi', 'cal-week-multi', 'selectWeekMulti(this)');
|
|
||||||
document.getElementById('cal-month-label-multi').textContent =
|
|
||||||
new Date(calYearMulti, calMonthMulti - 1).toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
|
|
||||||
multiStart = null;
|
|
||||||
multiEnd = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCalendar(weeks, tbodyId, rowClass, onclickFn) {
|
function renderCalendar(weeks, tbodyId, rowClass, onclickFn) {
|
||||||
const tbody = document.getElementById(tbodyId);
|
const tbody = document.getElementById(tbodyId);
|
||||||
tbody.innerHTML = weeks.map(w => {
|
tbody.innerHTML = weeks.map(w => {
|
||||||
const days = w.days.map(d =>
|
const days = w.days.map(d => {
|
||||||
`<td class="${d.in_month ? '' : 'cal-dim'}">${d.day}</td>`
|
let classes = [];
|
||||||
).join('');
|
if (!d.in_month) classes.push('cal-dim');
|
||||||
|
if (d.has_articles) classes.push('has-articles');
|
||||||
|
return `<td class="${classes.join(' ')}">${d.day}</td>`;
|
||||||
|
}).join('');
|
||||||
const count = w.article_count ? ` <small>(${w.article_count})</small>` : '';
|
const count = w.article_count ? ` <small>(${w.article_count})</small>` : '';
|
||||||
return `<tr class="${rowClass}" data-start="${w.week_start}" data-end="${w.week_end}" onclick="${onclickFn}">
|
return `<tr class="${rowClass}" data-start="${w.week_start}" data-end="${w.week_end}" onclick="${onclickFn}">
|
||||||
<td class="cal-wk">${w.iso_week}${count}</td>${days}
|
<td class="cal-wk">${w.iso_week}${count}</td>${days}
|
||||||
|
|||||||
Reference in New Issue
Block a user