ReviewOS

also looking at this

stacks/postline

fix: MVP-readiness fixes — restore lost UI feedback, register safety, analytics error state, mobile + a11y

#31
Merged glennmichael123 wants to merge fix/readiness-batch-1-feedback-timing into main
7 files +123 -29
resources/assets/scripts/postline-analytics.tsmodified+47-2
Changes to resources/assets/scripts/postline-analytics.ts
@@ -300,22 +300,67 @@ function fillStats(payload: AnalyticsPayload): void {
300300 }
301301}
302302
303let lastAnalyticsError: string | null = null
304
305function hideAnalyticsError(): void {
306 const banner = document.querySelector<HTMLElement>('[data-analytics-error]')
307 if (banner) {
308 banner.classList.add('hidden')
309 banner.classList.remove('flex')
310 }
311}
312
313function showAnalyticsError(message: string): void {
314 const page = document.querySelector<HTMLElement>('[data-testid="analytics-page"]')
315 if (!page)
316 return
317
318 const msg = page.querySelector('[data-analytics-error-message]')
319 if (msg)
320 msg.textContent = message
321 const banner = page.querySelector<HTMLElement>('[data-analytics-error]')
322 if (banner) {
323 banner.classList.remove('hidden')
324 banner.classList.add('flex')
325 }
326
327 // Stop the stat trends from claiming they're still loading forever.
328 page.querySelectorAll('[data-analytics-posts-trend], [data-analytics-engagement-trend], [data-analytics-best-channel-trend], [data-analytics-best-window-trend]')
329 .forEach((el) => { if (el.textContent === 'Loading…') el.textContent = 'Unavailable' })
330
331 const retry = page.querySelector<HTMLElement>('[data-analytics-retry]')
332 if (retry && !retry.dataset.wired) {
333 retry.dataset.wired = 'true'
334 retry.addEventListener('click', () => {
335 hideAnalyticsError()
336 void loadAnalytics()
337 })
338 }
339}
340
303341async function loadAnalytics(): Promise<void> {
304342 if (!analyticsPromise) {
305343 analyticsPromise = fetch('/api/postline/analytics')
306344 .then(async (response) => {
307345 const payload = await response.json()
308346 if (!response.ok || !payload.ok) throw new Error(payload.error || 'Could not load analytics.')
347 lastAnalyticsError = null
309348 return payload.data as AnalyticsPayload
310349 })
311 .catch(() => {
350 .catch((error: unknown) => {
312351 analyticsPromise = null
352 lastAnalyticsError = error instanceof Error ? error.message : 'Could not load analytics.'
313353 return null
314354 })
315355 }
316356
317357 const data = await analyticsPromise
318 if (!data) return
358 if (!data) {
359 // Previously returned silently, leaving the dashboard stuck on "Loading…".
360 showAnalyticsError(lastAnalyticsError || 'Could not load analytics.')
361 return
362 }
363 hideAnalyticsError()
319364 points = data.series || []
320365 fillStats(data)
321366 scheduleAnalyticsRender()
resources/views/accounts.stxmodified+24-13
Changes to resources/views/accounts.stx
@@ -37,7 +37,7 @@
3737 <span class="text-xs text-zinc-500" data-bluesky-channel-pill>Loading…</span>
3838 </header>
3939 <div class="grid gap-2.5 p-3" data-bluesky-channels>
40 <article class="flex gap-5 items-center justify-between p-3.5 bg-white border border-zinc-200 rounded-xl" data-channel="bluesky">
40 <article class="flex flex-col gap-3 justify-between sm:flex-row sm:gap-5 sm:items-center p-3.5 bg-white border border-zinc-200 rounded-xl" data-channel="bluesky">
4141 <div>
4242 <h2 class="my-0 font-bold text-[15px]">Bluesky</h2>
4343 <p class="my-1.5 text-[13px] text-slate-700" data-bluesky-channel-handle>Not connected</p>
@@ -45,7 +45,7 @@
4545 </div>
4646 <span class="px-2.5 py-1 font-bold text-[11px] text-zinc-500 whitespace-nowrap border border-zinc-200 rounded-full" data-bluesky-channel-status>Setup</span>
4747 </article>
48 <article class="flex gap-5 items-center justify-between p-3.5 bg-white border border-zinc-200 rounded-xl" data-channel="linkedin">
48 <article class="flex flex-col gap-3 justify-between sm:flex-row sm:gap-5 sm:items-center p-3.5 bg-white border border-zinc-200 rounded-xl" data-channel="linkedin">
4949 <div>
5050 <h2 class="my-0 font-bold text-[15px]">LinkedIn</h2>
5151 <p class="my-1.5 text-[13px] text-slate-700" data-linkedin-channel-handle>Not connected</p>
@@ -57,7 +57,7 @@
5757 <button class="hidden text-xs text-zinc-500 underline hover:text-zinc-700 transition-colors" type="button" data-linkedin-connect-env>Use token from .env</button>
5858 </div>
5959 </article>
60 <article class="flex gap-5 items-center justify-between p-3.5 bg-white border border-zinc-200 rounded-xl" data-channel="instagram">
60 <article class="flex flex-col gap-3 justify-between sm:flex-row sm:gap-5 sm:items-center p-3.5 bg-white border border-zinc-200 rounded-xl" data-channel="instagram">
6161 <div>
6262 <h2 class="my-0 font-bold text-[15px]">Instagram</h2>
6363 <p class="my-1.5 text-[13px] text-slate-700" data-instagram-channel-handle>Not connected</p>
@@ -69,7 +69,7 @@
6969 <button class="hidden text-xs text-zinc-500 underline hover:text-zinc-700 transition-colors" type="button" data-instagram-connect-env>Use token from .env</button>
7070 </div>
7171 </article>
72 <article class="flex gap-5 items-center justify-between p-3.5 bg-white border border-zinc-200 rounded-xl" data-channel="threads">
72 <article class="flex flex-col gap-3 justify-between sm:flex-row sm:gap-5 sm:items-center p-3.5 bg-white border border-zinc-200 rounded-xl" data-channel="threads">
7373 <div>
7474 <h2 class="my-0 font-bold text-[15px]">Threads</h2>
7575 <p class="my-1.5 text-[13px] text-slate-700" data-threads-channel-handle>Not connected</p>
@@ -91,13 +91,13 @@
9191 <span class="px-2.5 py-1 font-bold text-[11px] text-zinc-500 whitespace-nowrap border border-zinc-200 rounded-full" data-mastodon-channel-status>Setup</span>
9292 </div>
9393 <form class="grid gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)_auto]" data-mastodon-connect-form>
94 <input class="px-3 h-9 text-neutral-950 text-sm bg-white border border-zinc-200 outline-none rounded-lg focus:border-blue-500 focus:ring-2 focus:ring-blue-100" name="instance" placeholder="mastodon.social" autocomplete="url" data-mastodon-instance required>
95 <input class="px-3 h-9 text-neutral-950 text-sm bg-white border border-zinc-200 outline-none rounded-lg focus:border-blue-500 focus:ring-2 focus:ring-blue-100" name="access_token" type="password" placeholder="Access token" autocomplete="off" data-mastodon-token required>
94 <input class="px-3 h-9 text-neutral-950 text-sm bg-white border border-zinc-200 outline-none rounded-lg focus:border-blue-500 focus:ring-2 focus:ring-blue-100" name="instance" placeholder="mastodon.social" autocomplete="url" aria-label="Mastodon instance" data-mastodon-instance required>
95 <input class="px-3 h-9 text-neutral-950 text-sm bg-white border border-zinc-200 outline-none rounded-lg focus:border-blue-500 focus:ring-2 focus:ring-blue-100" name="access_token" type="password" placeholder="Access token" autocomplete="off" aria-label="Mastodon access token" data-mastodon-token required>
9696 <button class="inline-flex items-center justify-center px-4 h-9 font-bold text-sm text-white bg-neutral-950 hover:bg-neutral-800 rounded-lg disabled:opacity-50 transition-colors" type="submit" data-mastodon-connect-submit>Connect</button>
9797 <p class="sm:col-span-3 m-0 text-xs text-zinc-500">Create a token in your instance's Preferences → Development → New application (needs the <code class="px-1 py-0.5 text-xs bg-zinc-100 rounded">write:statuses</code> + <code class="px-1 py-0.5 text-xs bg-zinc-100 rounded">write:media</code> scopes).</p>
9898 </form>
9999 </article>
100 <article class="flex gap-5 items-center justify-between p-3.5 bg-white border border-zinc-200 rounded-xl" data-channel="twitter">
100 <article class="flex flex-col gap-3 justify-between sm:flex-row sm:gap-5 sm:items-center p-3.5 bg-white border border-zinc-200 rounded-xl" data-channel="twitter">
101101 <div>
102102 <h2 class="my-0 font-bold text-[15px]">X (Twitter)</h2>
103103 <p class="my-1.5 text-[13px] text-slate-700" data-twitter-channel-handle>Not connected</p>
@@ -126,11 +126,12 @@
126126 const detail = root.querySelector('[data-bluesky-channel-detail]')
127127 const status = root.querySelector('[data-bluesky-channel-status]')
128128 const pill = root.querySelector('[data-bluesky-channel-pill]')
129 const toast = window.postlineToast?.show
130 const blueskyApi = window.postlineBluesky
131
132129 const notify = (message, tone = 'neutral', options = {}) => {
133 if (toast) toast({ message, tone: tone === 'success' ? 'success' : tone === 'error' ? 'error' : 'info', title: options.title })
130 // Resolve the toast helper at call time — it's a deferred module that
131 // loads after this inline script parses, so an eager capture is
132 // undefined on a full page load (e.g. the OAuth redirect back here).
133 const show = window.postlineToast?.show
134 if (show) show({ message, tone: tone === 'success' ? 'success' : tone === 'error' ? 'error' : 'info', title: options.title })
134135 else console.log(message)
135136 }
136137
@@ -162,7 +163,7 @@
162163
163164 const refreshStatus = async () => {
164165 try {
165 const data = await blueskyApi?.fetchStatus()
166 const data = await window.postlineBluesky?.fetchStatus()
166167 renderStatus(data)
167168 }
168169 catch (error) {
@@ -658,7 +659,17 @@
658659 startMastodon()
659660 }
660661
661 void refreshStatus()
662 // Gate the initial status fetch on DOMContentLoaded so the deferred
663 // postline-bluesky module has loaded — otherwise a connected account
664 // renders "Not connected" on a full page load. (Mirrors the other
665 // providers.)
666 const startBluesky = () => void refreshStatus()
667 if (document.readyState === 'loading') {
668 document.addEventListener('DOMContentLoaded', startBluesky, { once: true })
669 }
670 else {
671 startBluesky()
672 }
662673 })()
663674 </script>
664675@endsection
resources/views/analytics.stxmodified+5-0
Changes to resources/views/analytics.stx
@@ -13,6 +13,11 @@
1313 </div>
1414 </header>
1515
16 <div class="hidden gap-3 items-center justify-between p-3.5 text-[13px] text-red-700 bg-red-50 border border-red-200 rounded-2xl" data-analytics-error>
17 <span data-analytics-error-message>Couldn't load analytics.</span>
18 <button class="inline-flex items-center justify-center px-3 h-8 font-bold text-white text-xs bg-neutral-950 hover:bg-neutral-800 rounded-lg transition-colors" type="button" data-analytics-retry>Retry</button>
19 </div>
20
1621 <div class="grid gap-3.5 sm:grid-cols-2 lg:grid-cols-4">
1722 <article class="grid gap-2 p-3.5 bg-white border border-zinc-200 rounded-2xl shadow-sm">
1823 <span class="text-xs text-zinc-500">Posts this week</span>
resources/views/index.stxmodified+7-3
Changes to resources/views/index.stx
@@ -113,7 +113,6 @@
113113 const connectionLabel = root.querySelector('[data-bluesky-connection-label]')
114114 const publishLabel = root.querySelector('[data-bluesky-publish-label]')
115115 const buttons = Array.from(root.querySelectorAll('[data-bluesky-publish], [data-bluesky-publish-top]'))
116 const toast = window.postlineToast?.show
117116 const providerToggles = root.querySelector('[data-provider-toggles]')
118117 const composeImageRow = root.querySelector('[data-compose-image-row]')
119118 const composeImage = root.querySelector('[data-compose-image]')
@@ -162,8 +161,13 @@
162161 let editRemoveImage = false
163162
164163 const setStatus = (message, tone = 'neutral', options = {}) => {
165 if (toast) {
166 toast({
164 // Resolve the toast helper at call time. It's a deferred module that
165 // loads AFTER this inline script parses, so capturing it eagerly (as
166 // this used to) left it undefined on every full page load — silently
167 // dropping all feedback to the console.
168 const show = window.postlineToast?.show
169 if (show) {
170 show({
167171 title: options.title,
168172 message,
169173 tone: tone === 'success' ? 'success' : tone === 'error' ? 'error' : 'info',
resources/views/layouts/postline.stxmodified+1-1
Changes to resources/views/layouts/postline.stx
@@ -446,7 +446,7 @@
446446
447447 // Number keys 1-6 jump between pages (the sidebar shows the badges).
448448 const bindKeyboardShortcuts = () => {
449 const routes = { 1: '/', 2: '/queue', 3: '/timeline', 4: '/analytics', 5: '/accounts', 6: '/blog', 7: '/settings' }
449 const routes = { 1: '/', 2: '/queue', 3: '/timeline', 4: '/analytics', 5: '/accounts', 6: '/settings' }
450450 document.addEventListener('keydown', (event) => {
451451 if (event.metaKey || event.ctrlKey || event.altKey) return
452452 const target = event.target
resources/views/login.stxmodified+18-0
Changes to resources/views/login.stx
@@ -51,6 +51,10 @@
5151 <span class="font-bold text-xs text-zinc-500">Password</span>
5252 <input class="px-3 h-10 text-neutral-950 text-sm bg-white border border-zinc-200 outline-none rounded-xl focus:border-blue-500 focus:ring-2 focus:ring-blue-100" name="password" type="password" autocomplete="current-password" placeholder="••••••••" required minlength="6" data-login-password>
5353 </label>
54 <label class="hidden gap-1.5" data-login-confirm-row>
55 <span class="font-bold text-xs text-zinc-500">Confirm password</span>
56 <input class="px-3 h-10 text-neutral-950 text-sm bg-white border border-zinc-200 outline-none rounded-xl focus:border-blue-500 focus:ring-2 focus:ring-blue-100" name="password_confirmation" type="password" autocomplete="new-password" placeholder="••••••••" minlength="6" data-login-confirm>
57 </label>
5458 <p class="hidden m-0 text-red-700 text-xs" data-login-error></p>
5559 <button class="inline-flex items-center justify-center px-4 h-10 font-bold text-sm text-white bg-neutral-950 hover:bg-neutral-800 rounded-xl disabled:opacity-50 transition-colors" type="submit" data-login-submit>Sign in</button>
5660 </form>
@@ -71,6 +75,8 @@
7175 const nameInput = root.querySelector('[data-login-name]')
7276 const emailInput = root.querySelector('[data-login-email]')
7377 const passwordInput = root.querySelector('[data-login-password]')
78 const confirmRow = root.querySelector('[data-login-confirm-row]')
79 const confirmInput = root.querySelector('[data-login-confirm]')
7480 const errorText = root.querySelector('[data-login-error]')
7581 const submit = root.querySelector('[data-login-submit]')
7682 const toggle = root.querySelector('[data-login-toggle]')
@@ -87,6 +93,12 @@
8793 registering = register
8894 nameRow?.classList.toggle('hidden', !register)
8995 nameRow?.classList.toggle('grid', register)
96 confirmRow?.classList.toggle('hidden', !register)
97 confirmRow?.classList.toggle('grid', register)
98 // The workspace-owning account has no recovery path, so a first-run
99 // typo must not silently create an account nobody can sign into.
100 nameInput?.toggleAttribute('required', register)
101 confirmInput?.toggleAttribute('required', register)
90102 if (heading) heading.textContent = register ? 'Create your workspace' : 'Sign in to Postline'
91103 if (subtitle) subtitle.textContent = register ? 'One account owns this Postline instance.' : 'Your personal publishing workspace.'
92104 if (submit) submit.textContent = register ? 'Create account' : 'Sign in'
@@ -105,6 +117,12 @@
105117 form?.addEventListener('submit', async (event) => {
106118 event.preventDefault()
107119 setError('')
120
121 if (registering && (passwordInput?.value || '') !== (confirmInput?.value || '')) {
122 setError('Passwords do not match.')
123 return
124 }
125
108126 submit?.setAttribute('disabled', 'disabled')
109127
110128 const body = new FormData()
resources/views/queue.stxmodified+21-10
Changes to resources/views/queue.stx
@@ -64,10 +64,14 @@
6464 </div>
6565 <strong class="text-[13px]" data-cal-title>—</strong>
6666 </header>
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>
67 <div class="overflow-x-auto">
68 <div class="min-w-[560px]">
69 <div class="grid grid-cols-7 font-bold text-[11px] text-zinc-500 border-b border-zinc-200">
70 <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>
71 </div>
72 <div class="grid grid-cols-7" data-cal-grid></div>
73 </div>
6974 </div>
70 <div class="grid grid-cols-7" data-cal-grid></div>
7175 </section>
7276 </section>
7377
@@ -80,7 +84,10 @@
8084 const recentList = root.querySelector('[data-queue-recent]')
8185 const meta = root.querySelector('[data-queue-meta]')
8286 const recentMeta = root.querySelector('[data-queue-recent-meta]')
83 const toast = window.postlineToast?.show
87 // Resolve the toast helper at call time — it's a deferred module that
88 // loads after this inline script parses, so an eager capture would be
89 // undefined on a full page load and drop every message to the console.
90 const showToast = options => window.postlineToast?.show?.(options)
8491
8592 const PENDING = new Set(['draft', 'scheduled', 'publishing', 'failed'])
8693
@@ -113,6 +120,9 @@
113120 const liveUrlFor = (uri) => {
114121 const value = String(uri || '')
115122 if (value.startsWith('/')) return value
123 // A stored permalink (Mastodon, and any provider persisting a URL) is
124 // already linkable as-is.
125 if (/^https?:\/\//i.test(value)) return value
116126 const match = value.match(/^at:\/\/([^/]+)\/app\.bsky\.feed\.post\/(.+)$/)
117127 return match ? `https://bsky.app/profile/${encodeURIComponent(match[1])}/post/${encodeURIComponent(match[2])}` : null
118128 }
@@ -141,7 +151,7 @@
141151 </header>
142152 <p class="m-0 leading-normal text-[13px] text-slate-700">${escapeHtml(item.body)}</p>
143153 ${failure ? `<p class="m-0 text-red-700 text-xs">${escapeHtml(failure)}</p>` : ''}
144 <footer class="flex gap-2 items-center justify-end">
154 <footer class="flex flex-wrap gap-2 items-center justify-end">
145155 ${liveUrl ? `<a class="inline-flex items-center justify-center px-2.5 h-7 font-bold text-neutral-950 text-xs bg-white hover:bg-zinc-50 border border-zinc-200 rounded-lg shadow-sm transition-colors" href="${escapeHtml(liveUrl)}" target="_blank" rel="noopener noreferrer">View</a>` : ''}
146156 ${actionable ? `<a class="inline-flex items-center justify-center px-2.5 h-7 font-bold text-neutral-950 text-xs bg-white hover:bg-zinc-50 border border-zinc-200 rounded-lg shadow-sm transition-colors" href="/?edit=${item.id}" data-stx-link>Edit</a>` : ''}
147157 ${actionable ? `<button class="inline-flex items-center justify-center px-2.5 h-7 font-bold text-white text-xs bg-neutral-950 hover:bg-neutral-800 rounded-lg transition-colors" type="button" data-queue-publish="${item.id}">Publish now</button>` : ''}
@@ -285,7 +295,7 @@
285295 const message = error instanceof Error ? error.message : String(error)
286296 if (pendingList) pendingList.innerHTML = `<p class="m-0 p-3 text-[13px] text-zinc-500">${escapeHtml(message)}</p>`
287297 if (meta) meta.textContent = 'Could not load'
288 if (toast) toast({ title: 'Queue', message, tone: 'error' })
298 showToast({ title: 'Queue', message, tone: 'error' })
289299 }
290300 }
291301
@@ -307,10 +317,10 @@
307317 publishButton.setAttribute('disabled', 'disabled')
308318 try {
309319 await post('/api/postline/queue/publish-now', publishButton.dataset.queuePublish)
310 if (toast) toast({ title: 'Published', message: 'Post published.', tone: 'success' })
320 showToast({ title: 'Published', message: 'Post published.', tone: 'success' })
311321 }
312322 catch (error) {
313 if (toast) toast({ title: 'Publish failed', message: error instanceof Error ? error.message : String(error), tone: 'error' })
323 showToast({ title: 'Publish failed', message: error instanceof Error ? error.message : String(error), tone: 'error' })
314324 }
315325 await refresh()
316326 return
@@ -318,13 +328,14 @@
318328
319329 const deleteButton = target.closest('[data-queue-delete]')
320330 if (deleteButton) {
331 if (!window.confirm('Delete this post? This cannot be undone.')) return
321332 deleteButton.setAttribute('disabled', 'disabled')
322333 try {
323334 await post('/api/postline/queue/delete', deleteButton.dataset.queueDelete)
324 if (toast) toast({ title: 'Removed', message: 'Post removed from the queue.', tone: 'success' })
335 showToast({ title: 'Removed', message: 'Post removed from the queue.', tone: 'success' })
325336 }
326337 catch (error) {
327 if (toast) toast({ title: 'Delete failed', message: error instanceof Error ? error.message : String(error), tone: 'error' })
338 showToast({ title: 'Delete failed', message: error instanceof Error ? error.message : String(error), tone: 'error' })
328339 }
329340 await refresh()
330341 }