ReviewOS

also looking at this

stacks/postline

Calendar view for the queue (#20)

#23
Merged glennmichael123 wants to merge feat/queue-calendar into main
2 files +166 -21
resources/views/index.stxmodified+12-0
Changes to resources/views/index.stx
@@ -973,9 +973,21 @@
973973 // Wait for the social module (a deferred `type=module` script) before the
974974 // first provider fetch so window.postlineSocial is guaranteed to exist.
975975 // 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
976987 const start = async () => {
977988 await refreshConnection()
978989 if (editParam) await loadEditPost(editParam)
990 else applyScheduleParam()
979991 }
980992 if (document.readyState === 'loading') {
981993 document.addEventListener('DOMContentLoaded', () => void start(), { once: true })
resources/views/queue.stxmodified+154-21
Changes to resources/views/queue.stx
@@ -5,38 +5,69 @@
55
66@section('content')
77 <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">
1111 <div>
1212 <p class="mb-1.5 font-bold text-[11px] text-zinc-500 tracking-wider uppercase">Queue</p>
1313 <h1 class="m-0 font-bold text-[22px] text-neutral-950 tracking-normal">Scheduled posts</h1>
1414 <p class="mb-0 mt-1.5 text-[13px] text-zinc-600">What's drafted, scheduled, and recently published.</p>
1515 </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>
2030 </header>
2131
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 &amp; 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 &amp; 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>
3153
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">
3355 <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>
3666 </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>
3969 </div>
70 <div class="grid grid-cols-7" data-cal-grid></div>
4071 </section>
4172 </section>
4273
@@ -140,7 +171,108 @@
140171 meta.textContent = `${scheduled} scheduled · ${drafts} draft${drafts === 1 ? '' : 's'}`
141172 }
142173 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()
143271 }
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() })
144276
145277 const refresh = async () => {
146278 try {
@@ -198,6 +330,7 @@
198330 }
199331 })
200332
333 try { setView(localStorage.getItem('postline.queue.view') || 'list') } catch { setView('list') }
201334 void refresh()
202335 })()
203336 </script>