also looking at this
fix: MVP-readiness fixes — restore lost UI feedback, register safety, analytics error state, mobile + a11y
#31
7 files
+123
-29
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -37,7 +37,7 @@ | ||
| 37 | 37 | <span class="text-xs text-zinc-500" data-bluesky-channel-pill>Loading…</span> |
| 38 | 38 | </header> |
| 39 | 39 | <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"> | |
| 41 | 41 | <div> |
| 42 | 42 | <h2 class="my-0 font-bold text-[15px]">Bluesky</h2> |
| 43 | 43 | <p class="my-1.5 text-[13px] text-slate-700" data-bluesky-channel-handle>Not connected</p> |
| @@ -45,7 +45,7 @@ | ||
| 45 | 45 | </div> |
| 46 | 46 | <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> |
| 47 | 47 | </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"> | |
| 49 | 49 | <div> |
| 50 | 50 | <h2 class="my-0 font-bold text-[15px]">LinkedIn</h2> |
| 51 | 51 | <p class="my-1.5 text-[13px] text-slate-700" data-linkedin-channel-handle>Not connected</p> |
| @@ -57,7 +57,7 @@ | ||
| 57 | 57 | <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> |
| 58 | 58 | </div> |
| 59 | 59 | </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"> | |
| 61 | 61 | <div> |
| 62 | 62 | <h2 class="my-0 font-bold text-[15px]">Instagram</h2> |
| 63 | 63 | <p class="my-1.5 text-[13px] text-slate-700" data-instagram-channel-handle>Not connected</p> |
| @@ -69,7 +69,7 @@ | ||
| 69 | 69 | <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> |
| 70 | 70 | </div> |
| 71 | 71 | </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"> | |
| 73 | 73 | <div> |
| 74 | 74 | <h2 class="my-0 font-bold text-[15px]">Threads</h2> |
| 75 | 75 | <p class="my-1.5 text-[13px] text-slate-700" data-threads-channel-handle>Not connected</p> |
| @@ -91,13 +91,13 @@ | ||
| 91 | 91 | <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> |
| 92 | 92 | </div> |
| 93 | 93 | <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> | |
| 96 | 96 | <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> |
| 97 | 97 | <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> |
| 98 | 98 | </form> |
| 99 | 99 | </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"> | |
| 101 | 101 | <div> |
| 102 | 102 | <h2 class="my-0 font-bold text-[15px]">X (Twitter)</h2> |
| 103 | 103 | <p class="my-1.5 text-[13px] text-slate-700" data-twitter-channel-handle>Not connected</p> |
| @@ -51,6 +51,10 @@ | ||
| 51 | 51 | <span class="font-bold text-xs text-zinc-500">Password</span> |
| 52 | 52 | <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> |
| 53 | 53 | </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> | |
| 54 | 58 | <p class="hidden m-0 text-red-700 text-xs" data-login-error></p> |
| 55 | 59 | <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> |
| 56 | 60 | </form> |
| @@ -71,6 +75,8 @@ | ||
| 71 | 75 | const nameInput = root.querySelector('[data-login-name]') |
| 72 | 76 | const emailInput = root.querySelector('[data-login-email]') |
| 73 | 77 | const passwordInput = root.querySelector('[data-login-password]') |
| 78 | const confirmRow = root.querySelector('[data-login-confirm-row]') | |
| 79 | const confirmInput = root.querySelector('[data-login-confirm]') | |
| 74 | 80 | const errorText = root.querySelector('[data-login-error]') |
| 75 | 81 | const submit = root.querySelector('[data-login-submit]') |
| 76 | 82 | const toggle = root.querySelector('[data-login-toggle]') |
| @@ -87,6 +93,12 @@ | ||
| 87 | 93 | registering = register |
| 88 | 94 | nameRow?.classList.toggle('hidden', !register) |
| 89 | 95 | 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) | |
| 90 | 102 | if (heading) heading.textContent = register ? 'Create your workspace' : 'Sign in to Postline' |
| 91 | 103 | if (subtitle) subtitle.textContent = register ? 'One account owns this Postline instance.' : 'Your personal publishing workspace.' |
| 92 | 104 | if (submit) submit.textContent = register ? 'Create account' : 'Sign in' |
| @@ -105,6 +117,12 @@ | ||
| 105 | 117 | form?.addEventListener('submit', async (event) => { |
| 106 | 118 | event.preventDefault() |
| 107 | 119 | setError('') |
| 120 | ||
| 121 | if (registering && (passwordInput?.value || '') !== (confirmInput?.value || '')) { | |
| 122 | setError('Passwords do not match.') | |
| 123 | return | |
| 124 | } | |
| 125 | ||
| 108 | 126 | submit?.setAttribute('disabled', 'disabled') |
| 109 | 127 | |
| 110 | 128 | const body = new FormData() |
| @@ -64,10 +64,14 @@ | ||
| 64 | 64 | </div> |
| 65 | 65 | <strong class="text-[13px]" data-cal-title>—</strong> |
| 66 | 66 | </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> | |
| 69 | 74 | </div> |
| 70 | <div class="grid grid-cols-7" data-cal-grid></div> | |
| 71 | 75 | </section> |
| 72 | 76 | </section> |
| 73 | 77 | |
| @@ -116,6 +120,9 @@ | ||
| 116 | 120 | const liveUrlFor = (uri) => { |
| 117 | 121 | const value = String(uri || '') |
| 118 | 122 | 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 | |
| 119 | 126 | const match = value.match(/^at:\/\/([^/]+)\/app\.bsky\.feed\.post\/(.+)$/) |
| 120 | 127 | return match ? `https://bsky.app/profile/${encodeURIComponent(match[1])}/post/${encodeURIComponent(match[2])}` : null |
| 121 | 128 | } |
| @@ -144,7 +151,7 @@ | ||
| 144 | 151 | </header> |
| 145 | 152 | <p class="m-0 leading-normal text-[13px] text-slate-700">${escapeHtml(item.body)}</p> |
| 146 | 153 | ${failure ? `<p class="m-0 text-red-700 text-xs">${escapeHtml(failure)}</p>` : ''} |
| 147 | <footer class="flex gap-2 items-center justify-end"> | |
| 154 | <footer class="flex flex-wrap gap-2 items-center justify-end"> | |
| 148 | 155 | ${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>` : ''} |
| 149 | 156 | ${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>` : ''} |
| 150 | 157 | ${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>` : ''} |