also looking at this
Calendar view for the queue (#20)
#23
2 files
+166
-21
| @@ -973,9 +973,21 @@ | ||
| 973 | 973 | // Wait for the social module (a deferred `type=module` script) before the |
| 974 | 974 | // first provider fetch so window.postlineSocial is guaranteed to exist. |
| 975 | 975 | // Load the edit post only after connection so provider toggles resolve. |
| 976 | // /?schedule=YYYY-MM-DD (from the queue calendar) opens the schedule | |
| 977 | // input pre-set to 09:00 local on that day. | |
| 978 | const scheduleParam = new URLSearchParams(window.location.search).get('schedule') | |
| 979 | const applyScheduleParam = () => { | |
| 980 | if (!scheduleParam || !scheduleInput || !/^\d{4}-\d{2}-\d{2}$/.test(scheduleParam)) return | |
| 981 | scheduleInput.value = `${scheduleParam}T09:00` | |
| 982 | scheduleInput.classList.remove('hidden') | |
| 983 | updatePublishLabel() | |
| 984 | activeTextarea()?.focus() | |
| 985 | } | |
| 986 | ||
| 976 | 987 | const start = async () => { |
| 977 | 988 | await refreshConnection() |
| 978 | 989 | if (editParam) await loadEditPost(editParam) |
| 990 | else applyScheduleParam() | |
| 979 | 991 | } |
| 980 | 992 | if (document.readyState === 'loading') { |
| 981 | 993 | document.addEventListener('DOMContentLoaded', () => void start(), { once: true }) |
| @@ -5,38 +5,69 @@ | ||
| 5 | 5 | |
| 6 | 6 | @section('content') |
| 7 | 7 | <section class="grid gap-5" data-postline-page="queue" data-testid="queue-page"> |
| 8 | <!-- Crosswind safelist: status pill + action classes are set from JS. --> | |
| 9 | <div class="hidden text-amber-800 text-blue-700 text-emerald-700 text-red-700 text-zinc-600 bg-amber-50 bg-blue-50 bg-emerald-50 bg-red-50 bg-zinc-100 hover:bg-neutral-800 hover:bg-zinc-50 border-amber-200 border-blue-200 border-emerald-200 border-red-200 border-zinc-200 transition-colors" aria-hidden="true"></div> | |
| 10 | <header class="flex gap-4 items-center justify-between min-h-11"> | |
| 8 | <!-- Crosswind safelist: status pill + action + calendar classes are set from JS. --> | |
| 9 | <div class="hidden text-amber-800 text-blue-700 text-emerald-700 text-red-700 text-zinc-500 text-zinc-600 bg-amber-50 bg-blue-50 bg-blue-500 bg-emerald-50 bg-emerald-500 bg-red-50 bg-red-500 bg-zinc-100 bg-zinc-400 bg-zinc-50 hover:bg-neutral-800 hover:bg-zinc-100 hover:bg-zinc-50 border-amber-200 border-blue-200 border-emerald-200 border-red-200 border-zinc-100 border-zinc-200 shadow-sm transition-colors" aria-hidden="true"></div> | |
| 10 | <header class="flex flex-wrap gap-4 items-center justify-between min-h-11"> | |
| 11 | 11 | <div> |
| 12 | 12 | <p class="mb-1.5 font-bold text-[11px] text-zinc-500 tracking-wider uppercase">Queue</p> |
| 13 | 13 | <h1 class="m-0 font-bold text-[22px] text-neutral-950 tracking-normal">Scheduled posts</h1> |
| 14 | 14 | <p class="mb-0 mt-1.5 text-[13px] text-zinc-600">What's drafted, scheduled, and recently published.</p> |
| 15 | 15 | </div> |
| 16 | <a href="/" data-stx-link class="inline-flex gap-2 items-center justify-center px-4 h-9 font-bold text-sm text-white bg-neutral-950 hover:bg-neutral-800 rounded-xl transition-colors"> | |
| 17 | <span class="h-4 w-4 i-hugeicons-plus-sign" aria-hidden="true"></span> | |
| 18 | <span>New post</span> | |
| 19 | </a> | |
| 16 | <div class="flex flex-wrap gap-2 items-center"> | |
| 17 | <div class="grid grid-cols-2 p-0.5 bg-zinc-100 border border-zinc-200 rounded-xl" role="group" aria-label="Queue view"> | |
| 18 | <button class="inline-flex gap-1.5 items-center justify-center px-3 py-1.5 font-bold text-xs rounded-lg transition-colors" type="button" data-view-toggle="list"> | |
| 19 | <span class="h-3.5 w-3.5 i-hugeicons-menu-01" aria-hidden="true"></span><span>List</span> | |
| 20 | </button> | |
| 21 | <button class="inline-flex gap-1.5 items-center justify-center px-3 py-1.5 font-bold text-xs rounded-lg transition-colors" type="button" data-view-toggle="calendar"> | |
| 22 | <span class="h-3.5 w-3.5 i-hugeicons-calendar-03" aria-hidden="true"></span><span>Calendar</span> | |
| 23 | </button> | |
| 24 | </div> | |
| 25 | <a href="/" data-stx-link class="inline-flex gap-2 items-center justify-center px-4 h-9 font-bold text-sm text-white bg-neutral-950 hover:bg-neutral-800 rounded-xl transition-colors"> | |
| 26 | <span class="h-4 w-4 i-hugeicons-plus-sign" aria-hidden="true"></span> | |
| 27 | <span>New post</span> | |
| 28 | </a> | |
| 29 | </div> | |
| 20 | 30 | </header> |
| 21 | 31 | |
| 22 | <section class="bg-white border border-zinc-200 rounded-2xl shadow-sm"> | |
| 23 | <header class="flex gap-3 items-center justify-between p-3.5 border-b border-zinc-200"> | |
| 24 | <strong class="text-[13px]">Upcoming & drafts</strong> | |
| 25 | <span class="text-xs text-zinc-500" data-queue-meta>Loading…</span> | |
| 26 | </header> | |
| 27 | <div class="grid gap-2.5 p-3" data-queue-pending> | |
| 28 | <p class="m-0 p-3 text-[13px] text-zinc-500" data-queue-pending-empty>Loading queue…</p> | |
| 29 | </div> | |
| 30 | </section> | |
| 32 | <div class="grid gap-5" data-view-panel="list"> | |
| 33 | <section class="bg-white border border-zinc-200 rounded-2xl shadow-sm"> | |
| 34 | <header class="flex gap-3 items-center justify-between p-3.5 border-b border-zinc-200"> | |
| 35 | <strong class="text-[13px]">Upcoming & drafts</strong> | |
| 36 | <span class="text-xs text-zinc-500" data-queue-meta>Loading…</span> | |
| 37 | </header> | |
| 38 | <div class="grid gap-2.5 p-3" data-queue-pending> | |
| 39 | <p class="m-0 p-3 text-[13px] text-zinc-500" data-queue-pending-empty>Loading queue…</p> | |
| 40 | </div> | |
| 41 | </section> | |
| 42 | ||
| 43 | <section class="bg-white border border-zinc-200 rounded-2xl shadow-sm"> | |
| 44 | <header class="flex gap-3 items-center justify-between p-3.5 border-b border-zinc-200"> | |
| 45 | <strong class="text-[13px]">Recent</strong> | |
| 46 | <span class="text-xs text-zinc-500" data-queue-recent-meta></span> | |
| 47 | </header> | |
| 48 | <div class="grid gap-2.5 p-3" data-queue-recent> | |
| 49 | <p class="m-0 p-3 text-[13px] text-zinc-500" data-queue-recent-empty>Loading…</p> | |
| 50 | </div> | |
| 51 | </section> | |
| 52 | </div> | |
| 31 | 53 | |
| 32 | <section class="bg-white border border-zinc-200 rounded-2xl shadow-sm"> | |
| 54 | <section class="hidden bg-white border border-zinc-200 rounded-2xl shadow-sm" data-view-panel="calendar"> | |
| 33 | 55 | <header class="flex gap-3 items-center justify-between p-3.5 border-b border-zinc-200"> |
| 34 | <strong class="text-[13px]">Recent</strong> | |
| 35 | <span class="text-xs text-zinc-500" data-queue-recent-meta></span> | |
| 56 | <div class="flex gap-2 items-center"> | |
| 57 | <button class="inline-flex items-center justify-center h-8 w-8 text-zinc-600 bg-white hover:bg-zinc-50 border border-zinc-200 rounded-lg shadow-sm transition-colors" type="button" aria-label="Previous month" data-cal-prev> | |
| 58 | <span class="h-4 w-4 i-hugeicons-arrow-left-02" aria-hidden="true"></span> | |
| 59 | </button> | |
| 60 | <button class="inline-flex items-center justify-center px-3 h-8 font-bold text-neutral-950 text-xs bg-white hover:bg-zinc-50 border border-zinc-200 rounded-lg shadow-sm transition-colors" type="button" data-cal-today>Today</button> | |
| 61 | <button class="inline-flex items-center justify-center h-8 w-8 text-zinc-600 bg-white hover:bg-zinc-50 border border-zinc-200 rounded-lg shadow-sm transition-colors" type="button" aria-label="Next month" data-cal-next> | |
| 62 | <span class="h-4 w-4 i-hugeicons-arrow-right-02" aria-hidden="true"></span> | |
| 63 | </button> | |
| 64 | </div> | |
| 65 | <strong class="text-[13px]" data-cal-title>—</strong> | |
| 36 | 66 | </header> |
| 37 | <div class="grid gap-2.5 p-3" data-queue-recent> | |
| 38 | <p class="m-0 p-3 text-[13px] text-zinc-500" data-queue-recent-empty>Loading…</p> | |
| 67 | <div class="grid grid-cols-7 font-bold text-[11px] text-zinc-500 border-b border-zinc-200"> | |
| 68 | <div class="p-2 text-center">Sun</div><div class="p-2 text-center">Mon</div><div class="p-2 text-center">Tue</div><div class="p-2 text-center">Wed</div><div class="p-2 text-center">Thu</div><div class="p-2 text-center">Fri</div><div class="p-2 text-center">Sat</div> | |
| 39 | 69 | </div> |
| 70 | <div class="grid grid-cols-7" data-cal-grid></div> | |
| 40 | 71 | </section> |
| 41 | 72 | </section> |
| 42 | 73 | |
| @@ -140,7 +171,108 @@ | ||
| 140 | 171 | meta.textContent = `${scheduled} scheduled · ${drafts} draft${drafts === 1 ? '' : 's'}` |
| 141 | 172 | } |
| 142 | 173 | if (recentMeta) recentMeta.textContent = `${recent.length} post${recent.length === 1 ? '' : 's'}` |
| 174 | ||
| 175 | cachedItems = items | |
| 176 | renderCalendar() | |
| 177 | } | |
| 178 | ||
| 179 | // ---- Calendar view ---- | |
| 180 | const calGrid = root.querySelector('[data-cal-grid]') | |
| 181 | const calTitle = root.querySelector('[data-cal-title]') | |
| 182 | let cachedItems = [] | |
| 183 | let calMonth = (() => { const d = new Date(); return new Date(d.getFullYear(), d.getMonth(), 1) })() | |
| 184 | ||
| 185 | const dotClass = (status) => { | |
| 186 | if (status === 'published') return 'bg-emerald-500' | |
| 187 | if (status === 'failed') return 'bg-red-500' | |
| 188 | if (status === 'scheduled') return 'bg-blue-500' | |
| 189 | return 'bg-zinc-400' | |
| 190 | } | |
| 191 | ||
| 192 | // Local Y-M-D key for a post's effective calendar date. | |
| 193 | const dayKey = (item) => { | |
| 194 | const raw = item.scheduledAt || item.publishedAt | |
| 195 | if (!raw) return null | |
| 196 | const d = new Date(`${raw.replace(' ', 'T')}Z`) | |
| 197 | if (Number.isNaN(d.getTime())) return null | |
| 198 | return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` | |
| 199 | } | |
| 200 | const timeOf = (item) => { | |
| 201 | const raw = item.scheduledAt || item.publishedAt | |
| 202 | const d = new Date(`${raw.replace(' ', 'T')}Z`) | |
| 203 | return Number.isNaN(d.getTime()) ? '' : d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }) | |
| 204 | } | |
| 205 | ||
| 206 | const renderCalendar = () => { | |
| 207 | if (!calGrid) return | |
| 208 | const year = calMonth.getFullYear() | |
| 209 | const month = calMonth.getMonth() | |
| 210 | if (calTitle) calTitle.textContent = calMonth.toLocaleDateString(undefined, { month: 'long', year: 'numeric' }) | |
| 211 | ||
| 212 | // Only dated posts (scheduled or published) land on the calendar. | |
| 213 | const byDay = new Map() | |
| 214 | for (const item of cachedItems) { | |
| 215 | const key = dayKey(item) | |
| 216 | if (!key) continue | |
| 217 | if (!byDay.has(key)) byDay.set(key, []) | |
| 218 | byDay.get(key).push(item) | |
| 219 | } | |
| 220 | ||
| 221 | const first = new Date(year, month, 1) | |
| 222 | const startOffset = first.getDay() | |
| 223 | const daysInMonth = new Date(year, month + 1, 0).getDate() | |
| 224 | const todayKey = (() => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` })() | |
| 225 | const cells = [] | |
| 226 | ||
| 227 | // Leading blanks from the previous month. | |
| 228 | for (let i = 0; i < startOffset; i++) cells.push('<div class="p-1.5 min-h-24 bg-zinc-50/40 border-b border-r border-zinc-100"></div>') | |
| 229 | ||
| 230 | for (let day = 1; day <= daysInMonth; day++) { | |
| 231 | const key = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}` | |
| 232 | const dayItems = (byDay.get(key) || []).sort((a, b) => (a.scheduledAt || a.publishedAt).localeCompare(b.scheduledAt || b.publishedAt)) | |
| 233 | const isToday = key === todayKey | |
| 234 | const chips = dayItems.slice(0, 3).map((item) => { | |
| 235 | const label = escapeHtml((item.title || item.body || '').slice(0, 22) || 'post') | |
| 236 | const href = ['draft', 'scheduled', 'failed'].includes(item.status) ? `/?edit=${item.id}` : null | |
| 237 | const inner = `<span class="h-1.5 w-1.5 shrink-0 rounded-full ${dotClass(item.status)}"></span><span class="truncate">${escapeHtml(timeOf(item))} ${label}</span>` | |
| 238 | return href | |
| 239 | ? `<a class="flex gap-1 items-center px-1 py-0.5 text-[10px] text-zinc-700 bg-zinc-50 hover:bg-zinc-100 rounded transition-colors" href="${href}" data-stx-link>${inner}</a>` | |
| 240 | : `<span class="flex gap-1 items-center px-1 py-0.5 text-[10px] text-zinc-500 bg-zinc-50 rounded">${inner}</span>` | |
| 241 | }).join('') | |
| 242 | const more = dayItems.length > 3 ? `<span class="px-1 text-[10px] text-zinc-400">+${dayItems.length - 3} more</span>` : '' | |
| 243 | cells.push(` | |
| 244 | <div class="min-h-24 p-1.5 grid content-start gap-1 border-b border-r border-zinc-100 ${isToday ? 'bg-blue-50/50' : 'bg-white'}"> | |
| 245 | <a class="text-[11px] font-bold ${isToday ? 'text-blue-700' : 'text-zinc-500'} hover:text-neutral-950 transition-colors" href="/?schedule=${key}" data-stx-link title="Schedule a post on this day">${day}</a> | |
| 246 | ${chips}${more} | |
| 247 | </div> | |
| 248 | `) | |
| 249 | } | |
| 250 | // Trailing blanks to complete the last week row. | |
| 251 | while (cells.length % 7 !== 0) cells.push('<div class="p-1.5 min-h-24 bg-zinc-50/40 border-b border-r border-zinc-100"></div>') | |
| 252 | calGrid.innerHTML = cells.join('') | |
| 253 | } | |
| 254 | ||
| 255 | // ---- View toggle (persisted) ---- | |
| 256 | const viewToggles = Array.from(root.querySelectorAll('[data-view-toggle]')) | |
| 257 | const panels = { list: root.querySelector('[data-view-panel="list"]'), calendar: root.querySelector('[data-view-panel="calendar"]') } | |
| 258 | const setView = (view) => { | |
| 259 | const next = view === 'calendar' ? 'calendar' : 'list' | |
| 260 | panels.list?.classList.toggle('hidden', next !== 'list') | |
| 261 | panels.calendar?.classList.toggle('hidden', next !== 'calendar') | |
| 262 | viewToggles.forEach((button) => { | |
| 263 | const active = button.dataset.viewToggle === next | |
| 264 | button.classList.toggle('bg-white', active) | |
| 265 | button.classList.toggle('text-neutral-950', active) | |
| 266 | button.classList.toggle('shadow-sm', active) | |
| 267 | button.classList.toggle('text-zinc-500', !active) | |
| 268 | }) | |
| 269 | try { localStorage.setItem('postline.queue.view', next) } catch {} | |
| 270 | if (next === 'calendar') renderCalendar() | |
| 143 | 271 | } |
| 272 | viewToggles.forEach(button => button.addEventListener('click', () => setView(button.dataset.viewToggle))) | |
| 273 | root.querySelector('[data-cal-prev]')?.addEventListener('click', () => { calMonth = new Date(calMonth.getFullYear(), calMonth.getMonth() - 1, 1); renderCalendar() }) | |
| 274 | root.querySelector('[data-cal-next]')?.addEventListener('click', () => { calMonth = new Date(calMonth.getFullYear(), calMonth.getMonth() + 1, 1); renderCalendar() }) | |
| 275 | root.querySelector('[data-cal-today]')?.addEventListener('click', () => { const d = new Date(); calMonth = new Date(d.getFullYear(), d.getMonth(), 1); renderCalendar() }) | |
| 144 | 276 | |
| 145 | 277 | const refresh = async () => { |
| 146 | 278 | try { |
| @@ -198,6 +330,7 @@ | ||
| 198 | 330 | } |
| 199 | 331 | }) |
| 200 | 332 | |
| 333 | try { setView(localStorage.getItem('postline.queue.view') || 'list') } catch { setView('list') } | |
| 201 | 334 | void refresh() |
| 202 | 335 | })() |
| 203 | 336 | </script> |