BlueSky & more on desktop lazurite.stormlightlabs.org/
tauri rust typescript bluesky appview atproto solid
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: account mgmt ui

+947 -324
+4 -4
docs/tasks/02-auth.md
··· 20 20 21 21 ### Frontend 22 22 23 - - [ ] login form with `Motion` spring shake on invalid handle 24 - - [ ] account switcher dropdown in sidebar with `Presence` avatar enter/exit 25 - - [ ] skeleton shimmer on profile card during session restore 26 - - [ ] inline re-auth prompt with pulse animation on session expiry 23 + - [x] login form with `Motion` spring shake on invalid handle 24 + - [x] account switcher dropdown in sidebar with `Presence` avatar enter/exit 25 + - [x] skeleton shimmer on profile card during session restore 26 + - [x] inline re-auth prompt with pulse animation on session expiry
+83 -224
src/App.css
··· 3 3 @plugin "@tailwindcss/forms"; 4 4 5 5 @theme inline { 6 - --font-sans: var(--font-stack); 7 - --color-surface-container-lowest: var(--surface-container-lowest); 8 - --color-surface: var(--surface); 9 - --color-surface-container: var(--surface-container); 10 - --color-surface-container-high: var(--surface-container-high); 11 - --color-surface-bright: var(--surface-bright); 12 - --color-primary: var(--primary); 13 - --color-on-primary-fixed: var(--on-primary-fixed); 14 - --color-on-surface: var(--on-surface); 15 - --color-on-surface-variant: var(--on-surface-variant); 16 - --color-on-secondary-container: var(--on-secondary-container); 17 - --color-error: var(--error); 18 - --color-error-surface: var(--error-surface); 19 - --radius-app-lg: var(--radius-lg); 20 - --radius-app-xl: var(--radius-xl); 21 - --radius-app-full: var(--radius-full); 22 - --shadow-ambient: var(--shadow-ambient); 6 + --font-sans: var(--font-stack); 7 + --color-surface-container-lowest: var(--surface-container-lowest); 8 + --color-surface: var(--surface); 9 + --color-surface-container: var(--surface-container); 10 + --color-surface-container-high: var(--surface-container-high); 11 + --color-surface-bright: var(--surface-bright); 12 + --color-primary: var(--primary); 13 + --color-on-primary-fixed: var(--on-primary-fixed); 14 + --color-on-surface: var(--on-surface); 15 + --color-on-surface-variant: var(--on-surface-variant); 16 + --color-on-secondary-container: var(--on-secondary-container); 17 + --color-error: var(--error); 18 + --color-error-surface: var(--error-surface); 23 19 } 24 20 25 21 :root { 26 - color-scheme: dark light; 27 - --surface-container-lowest: #000000; 28 - --surface: #0e0e0e; 29 - --surface-container: #191919; 30 - --surface-container-high: #1f1f1f; 31 - --surface-container-highest: rgba(36, 36, 36, 0.7); 32 - --surface-bright: rgba(255, 255, 255, 0.05); 33 - --outline-ghost: rgba(72, 72, 72, 0.2); 34 - --primary: #7dafff; 35 - --primary-dim: #0073de; 36 - --on-primary-fixed: #05080f; 37 - --on-surface: #f4f6fb; 38 - --on-surface-variant: #ababab; 39 - --on-secondary-container: #c9d1dd; 40 - --error: #ff8080; 41 - --error-surface: rgba(138, 31, 31, 0.72); 42 - --radius-lg: 1rem; 43 - --radius-xl: 1.5rem; 44 - --radius-full: 999px; 45 - --space-2: 0.5rem; 46 - --space-4: 1rem; 47 - --space-6: 1.5rem; 48 - --space-8: 2rem; 49 - --shadow-ambient: 0 24px 40px rgba(125, 175, 255, 0.05); 50 - --font-stack: "Google Sans Variable", "Segoe UI", "Avenir Next", sans-serif; 22 + color-scheme: dark light; 23 + --surface-container-lowest: #000000; 24 + --surface: #0e0e0e; 25 + --surface-container: #191919; 26 + --surface-container-high: #1f1f1f; 27 + --surface-container-highest: rgba(36, 36, 36, 0.7); 28 + --surface-bright: rgba(255, 255, 255, 0.05); 29 + --primary: #7dafff; 30 + --primary-dim: #0073de; 31 + --on-primary-fixed: #05080f; 32 + --on-surface: #f4f6fb; 33 + --on-surface-variant: #ababab; 34 + --on-secondary-container: #c9d1dd; 35 + --error: #ff8080; 36 + --error-surface: rgba(138, 31, 31, 0.72); 37 + --font-stack: "Google Sans Variable", "Segoe UI", "Avenir Next", sans-serif; 51 38 } 52 39 53 40 @media (prefers-color-scheme: light) { 54 - :root { 55 - --surface-container-lowest: #f2f5ff; 56 - --surface: #ffffff; 57 - --surface-container: #ebeffb; 58 - --surface-container-high: #e1e8fa; 59 - --surface-container-highest: rgba(234, 241, 255, 0.72); 60 - --surface-bright: rgba(24, 37, 66, 0.07); 61 - --outline-ghost: rgba(84, 106, 148, 0.26); 62 - --on-surface: #0f1523; 63 - --on-surface-variant: #4e5e7e; 64 - --on-secondary-container: #334368; 65 - --error: #8f1f1f; 66 - --error-surface: rgba(255, 179, 179, 0.75); 67 - --shadow-ambient: 0 24px 40px rgba(0, 115, 222, 0.12); 68 - } 41 + :root { 42 + --surface-container-lowest: #f2f5ff; 43 + --surface: #ffffff; 44 + --surface-container: #ebeffb; 45 + --surface-container-high: #e1e8fa; 46 + --surface-container-highest: rgba(234, 241, 255, 0.72); 47 + --surface-bright: rgba(24, 37, 66, 0.07); 48 + --on-surface: #0f1523; 49 + --on-surface-variant: #4e5e7e; 50 + --on-secondary-container: #334368; 51 + --error: #8f1f1f; 52 + --error-surface: rgba(255, 179, 179, 0.75); 53 + } 69 54 } 70 55 71 56 * { 72 - @apply box-border; 57 + @apply box-border; 73 58 } 74 59 75 60 body { 76 - @apply m-0 min-h-screen font-sans text-on-surface; 77 - background: 78 - radial-gradient(circle at 16% 15%, rgba(125, 175, 255, 0.16), transparent 40%), 79 - radial-gradient(circle at 90% 75%, rgba(0, 115, 222, 0.2), transparent 44%), 80 - var(--surface-container-lowest); 61 + @apply m-0 min-h-screen font-sans text-on-surface; 62 + background: 63 + radial-gradient(circle at 14% 12%, rgba(125, 175, 255, 0.22), transparent 32%), 64 + radial-gradient(circle at 88% 22%, rgba(0, 115, 222, 0.18), transparent 28%), 65 + radial-gradient(circle at 72% 88%, rgba(125, 175, 255, 0.12), transparent 30%), 66 + var(--surface-container-lowest); 81 67 } 82 68 83 - #root { 84 - @apply min-h-screen; 69 + body::before { 70 + content: ""; 71 + position: fixed; 72 + inset: 0; 73 + pointer-events: none; 74 + opacity: 0.18; 75 + background-image: 76 + linear-gradient(rgba(255, 255, 255, 0.04) 1px, transparent 1px), 77 + linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px); 78 + background-size: 120px 120px; 79 + mask-image: radial-gradient(circle at center, black 40%, transparent 90%); 85 80 } 86 81 87 - .app-shell { 88 - @apply min-h-screen grid; 89 - grid-template-columns: 4.5rem minmax(0, 1fr); 82 + #root { 83 + @apply min-h-screen; 90 84 } 91 85 92 - .app-rail { 93 - @apply flex flex-col bg-surface-container-lowest; 94 - gap: var(--space-4); 95 - padding: var(--space-6) var(--space-2); 86 + @utility overline-copy { 87 + @apply m-0 uppercase tracking-[0.12em]; 96 88 } 97 89 98 - .rail-button { 99 - @apply border-0 rounded-app-full bg-transparent grid place-items-center cursor-pointer text-on-surface-variant; 100 - height: 3rem; 101 - transition: background-color 180ms ease, color 180ms ease, transform 180ms ease; 90 + @utility panel-surface { 91 + @apply rounded-3xl bg-white/3 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.035)]; 102 92 } 103 93 104 - .rail-button:hover { 105 - @apply -translate-y-px text-on-surface bg-surface-bright; 94 + @utility pill-action { 95 + @apply inline-flex min-h-12 items-center justify-center gap-2 rounded-full px-4 transition-transform duration-150 ease-out hover:-translate-y-px disabled:translate-y-0 disabled:cursor-wait disabled:opacity-70; 106 96 } 107 97 108 - .rail-button--active { 109 - @apply text-primary bg-surface-container; 98 + .skeleton-block { 99 + position: relative; 100 + overflow: hidden; 101 + background: rgba(255, 255, 255, 0.05); 110 102 } 111 103 112 - .work-surface { 113 - @apply grid rounded-app-xl shadow-ambient; 114 - margin: var(--space-6); 115 - padding: var(--space-8); 116 - background: linear-gradient(160deg, var(--surface) 0%, var(--surface-container) 100%); 117 - grid-template-rows: auto auto 1fr; 118 - gap: var(--space-6); 104 + .skeleton-block::after { 105 + content: ""; 106 + position: absolute; 107 + inset: 0; 108 + transform: translateX(-100%); 109 + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.22), transparent); 110 + animation: shimmer 1.5s linear infinite; 119 111 } 120 112 121 - .surface-header { 122 - @apply flex items-baseline justify-between; 123 - gap: var(--space-8); 124 - } 125 - 126 - .headline { 127 - @apply m-0 leading-none; 128 - font-size: clamp(2rem, 3.2vw, 3.5rem); 129 - letter-spacing: -0.02em; 130 - } 131 - 132 - .meta { 133 - @apply m-0 uppercase text-on-surface-variant; 134 - font-size: 0.75rem; 135 - letter-spacing: 0.12em; 136 - } 137 - 138 - .session-panel, 139 - .accounts-panel { 140 - @apply rounded-app-lg bg-surface-container-high; 141 - padding: var(--space-6); 142 - } 143 - 144 - .panel-title { 145 - @apply m-0 uppercase text-on-surface-variant; 146 - font-size: 0.75rem; 147 - letter-spacing: 0.08em; 148 - } 149 - 150 - .panel-copy { 151 - @apply mt-2 text-on-secondary-container; 152 - font-size: 0.875rem; 153 - } 154 - 155 - .panel-subtle { 156 - @apply block text-on-surface-variant; 157 - margin-top: 0.2rem; 158 - font-size: 0.75rem; 159 - } 160 - 161 - .accounts-head { 162 - @apply flex justify-between items-baseline; 163 - gap: var(--space-4); 164 - } 165 - 166 - .account-list { 167 - @apply mt-4 grid; 168 - gap: var(--space-2); 169 - } 170 - 171 - .account-chip { 172 - @apply w-full text-left border-0 cursor-pointer text-on-surface rounded-app-lg; 173 - padding: 0.9rem 1rem; 174 - background: rgba(255, 255, 255, 0.02); 175 - transition: background-color 180ms ease, transform 180ms ease; 176 - } 177 - 178 - .account-chip:hover { 179 - @apply -translate-y-px; 180 - background: var(--surface-bright); 181 - } 182 - 183 - .account-chip--active { 184 - @apply text-on-primary-fixed; 185 - background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dim) 100%); 186 - } 187 - 188 - .account-handle, 189 - .account-meta { 190 - @apply block; 191 - } 192 - 193 - .account-handle { 194 - font-size: 0.9rem; 195 - } 196 - 197 - .account-meta { 198 - margin-top: 0.25rem; 199 - font-size: 0.75rem; 200 - opacity: 0.8; 201 - } 202 - 203 - .error-toast { 204 - @apply fixed left-1/2 grid items-center rounded-app-full text-on-surface shadow-ambient bg-error-surface; 205 - bottom: 1.5rem; 206 - transform: translateX(-50%); 207 - max-width: min(30rem, calc(100vw - 2rem)); 208 - width: max-content; 209 - grid-template-columns: auto 1fr auto; 210 - gap: 0.75rem; 211 - padding: 0.75rem 1rem; 212 - backdrop-filter: blur(20px); 213 - } 214 - 215 - .error-toast__glyph { 216 - @apply text-error; 217 - } 218 - 219 - .error-toast__message { 220 - @apply m-0 text-on-surface; 221 - font-size: 0.875rem; 222 - } 223 - 224 - .error-toast__dismiss { 225 - @apply border-0 rounded-full bg-transparent cursor-pointer; 226 - padding: 0.35rem; 227 - color: inherit; 228 - } 229 - 230 - .error-toast__dismiss:hover { 231 - background: var(--surface-bright); 232 - } 233 - 234 - @media (max-width: 820px) { 235 - .app-shell { 236 - grid-template-columns: 1fr; 237 - } 238 - 239 - .app-rail { 240 - @apply flex-row justify-center; 241 - padding: var(--space-4); 242 - } 243 - 244 - .work-surface { 245 - @apply m-0 rounded-none; 246 - min-height: calc(100vh - 5rem); 247 - padding: var(--space-6); 248 - } 249 - 250 - .surface-header { 251 - @apply flex-col items-start; 252 - gap: var(--space-2); 253 - } 254 - 255 - .error-toast { 256 - width: calc(100vw - 1.5rem); 257 - } 113 + @keyframes shimmer { 114 + to { 115 + transform: translateX(100%); 116 + } 258 117 }
+395 -64
src/App.tsx
··· 1 1 import { invoke } from "@tauri-apps/api/core"; 2 - import { createSignal, For, onMount, Show } from "solid-js"; 2 + import { listen } from "@tauri-apps/api/event"; 3 + import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show, startTransition } from "solid-js"; 4 + import { Motion } from "solid-motionone"; 3 5 import "@fontsource-variable/google-sans"; 4 6 import "./App.css"; 5 - import { ErrorToast } from "./components/ErrorToast"; 6 - import { AccountSummary, ActiveSession, AppBootstrap } from "./lib/types"; 7 + import { AccountSwitcher } from "./components/AccountSwitcher"; 8 + import { AvatarBadge } from "./components/AvatarBadge"; 9 + import { HeaderPanel } from "./components/panels/Header"; 10 + import { RailButton } from "./components/RailButton"; 11 + import { SessionSpotlight } from "./components/Session"; 12 + import { ErrorToast } from "./components/shared/ErrorToast"; 13 + import { Icon } from "./components/shared/Icon"; 14 + import { Wordmark } from "./components/Wordmark"; 15 + import type { AccountSummary, ActiveSession, AppBootstrap } from "./lib/types"; 16 + 17 + const ACCOUNT_SWITCH_EVENT = "auth:account-switched"; 18 + 19 + const panelTitleClass = "overline-copy text-[0.75rem] text-[color:var(--on-surface-variant)]"; 7 20 8 - type RailButtonProps = { label: string; icon: string; active?: boolean }; 21 + const subtleTextClass = "m-0 text-[0.78rem] leading-[1.55] text-[color:var(--on-surface-variant)]"; 9 22 10 - function RailButton(props: RailButtonProps) { 23 + const primaryButtonClass = 24 + "pill-action border-0 bg-[linear-gradient(135deg,var(--primary)_0%,var(--primary-dim)_100%)] text-[color:var(--on-primary-fixed)]"; 25 + 26 + const secondaryButtonClass = "pill-action border-0 bg-white/8 text-on-surface"; 27 + 28 + const ghostButtonClass = "pill-action border-0 bg-transparent text-[color:var(--on-surface-variant)]"; 29 + 30 + type LoginPanelProps = { 31 + value: string; 32 + pending: boolean; 33 + shakeCount: number; 34 + onInput: (value: string) => void; 35 + onSubmit: () => void; 36 + }; 37 + 38 + function LoginPanel(props: LoginPanelProps) { 39 + let input: HTMLInputElement | undefined; 40 + 41 + createEffect(() => { 42 + if (props.shakeCount > 0) { 43 + input?.focus(); 44 + input?.select(); 45 + } 46 + }); 47 + 11 48 return ( 12 - <button 13 - class="rail-button" 14 - classList={{ "rail-button--active": !!props.active }} 15 - type="button" 16 - aria-label={props.label}> 17 - <span class="flex items-center" aria-hidden="true"> 18 - <i class={props.icon} /> 19 - </span> 20 - </button> 49 + <article class="panel-surface grid gap-6 p-6"> 50 + <div class="flex items-baseline justify-between gap-3"> 51 + <p class={panelTitleClass}>Add account</p> 52 + <p class={subtleTextClass}>Enter the account you want to use.</p> 53 + </div> 54 + 55 + <Motion.form 56 + class="grid gap-4" 57 + initial={{ opacity: 0, y: 18 }} 58 + animate={{ opacity: 1, y: 0, x: props.shakeCount > 0 ? [0, -16, 10, -8, 0] : 0 }} 59 + transition={{ duration: props.shakeCount > 0 ? 0.42 : 0.24, easing: [0.22, 1, 0.36, 1] }} 60 + onSubmit={(event) => { 61 + event.preventDefault(); 62 + props.onSubmit(); 63 + }}> 64 + <label class="grid gap-[0.7rem]"> 65 + <span class="overline-copy text-[0.76rem] tracking-[0.08em] text-on-surface-variant"> 66 + Handle, DID, or URL 67 + </span> 68 + <input 69 + ref={(element) => { 70 + input = element; 71 + }} 72 + class="min-h-[3.4rem] w-full rounded-full border-0 bg-white/4 px-[1.15rem] text-on-surface shadow-[inset_0_0_0_1px_rgba(125,175,255,0.16)] focus:outline focus:outline-primary/50 focus:shadow-[inset_0_0_0_1px_rgba(125,175,255,0.35),0_0_28px_rgba(125,175,255,0.12)]" 73 + type="text" 74 + autocomplete="username" 75 + spellcheck={false} 76 + value={props.value} 77 + placeholder="alice.bsky.social" 78 + onInput={(event) => props.onInput(event.currentTarget.value)} /> 79 + </label> 80 + <LoginSubmitButton pending={props.pending} /> 81 + </Motion.form> 82 + </article> 21 83 ); 22 84 } 23 85 24 - type SessionPanelProps = { activeSession: ActiveSession | null }; 25 - 26 - function SessionPanel(props: SessionPanelProps) { 86 + function LoginSubmitButton(props: { pending: boolean }) { 27 87 return ( 28 - <article class="session-panel"> 29 - <p class="panel-title">Active session</p> 88 + <button class={primaryButtonClass} type="submit" disabled={props.pending}> 30 89 <Show 31 - when={props.activeSession} 32 - fallback={<p class="panel-copy">No active account yet. Authenticate to start syncing.</p>}> 33 - {(session) => ( 34 - <p class="panel-copy"> 35 - {session().handle} 36 - <span class="panel-subtle">{session().did}</span> 37 - </p> 38 - )} 90 + when={props.pending} 91 + fallback={ 92 + <> 93 + <Icon kind="ext-link" name="ext-link" aria-hidden="true" class="mr-1" /> 94 + <span>Continue</span> 95 + </> 96 + }> 97 + <> 98 + <Icon kind="loader" name="loader" aria-hidden="true" class="mr-1" /> 99 + <span>Opening sign-in...</span> 100 + </> 39 101 </Show> 40 - </article> 102 + </button> 41 103 ); 42 104 } 43 105 44 - type AccountsPanelProps = { accounts: AccountSummary[]; onActivate: (did: string) => void }; 106 + type AccountLedgerProps = { 107 + accounts: AccountSummary[]; 108 + activeDid: string | null; 109 + busyDid: string | null; 110 + logoutDid: string | null; 111 + onSwitch: (did: string) => void; 112 + onLogout: (did: string) => void; 113 + }; 45 114 46 - function AccountsPanel(props: AccountsPanelProps) { 115 + function AccountLedger(props: AccountLedgerProps) { 47 116 return ( 48 - <article class="accounts-panel"> 49 - <div class="accounts-head"> 50 - <p class="panel-title">Known accounts</p> 51 - <p class="panel-copy">{props.accounts.length} loaded</p> 117 + <article class="panel-surface grid gap-6 p-6"> 118 + <div class="flex items-baseline justify-between gap-3"> 119 + <p class={panelTitleClass}>Accounts</p> 120 + <p class={subtleTextClass}>{props.accounts.length} added</p> 52 121 </div> 53 - <div class="account-list" role="list"> 54 - <Show when={props.accounts.length > 0} fallback={<p class="panel-copy">No accounts stored yet.</p>}> 122 + 123 + <Show 124 + when={props.accounts.length > 0} 125 + fallback={ 126 + <p class="overline-copy text-[0.72rem] text-on-surface-variant">Accounts you add will show up here.</p> 127 + }> 128 + <div class="grid gap-3" role="list"> 55 129 <For each={props.accounts}> 56 - {(account) => <AccountChip account={account} onActivate={props.onActivate} />} 130 + {(account) => ( 131 + <AccountLedgerCard 132 + account={account} 133 + activeDid={props.activeDid} 134 + busyDid={props.busyDid} 135 + logoutDid={props.logoutDid} 136 + onSwitch={props.onSwitch} 137 + onLogout={props.onLogout} /> 138 + )} 57 139 </For> 58 - </Show> 59 - </div> 140 + </div> 141 + </Show> 60 142 </article> 61 143 ); 62 144 } 63 145 64 - type AccountChipProps = { account: AccountSummary; onActivate: (did: string) => void }; 146 + function LogoutButton( 147 + props: { isSwitching: boolean; isLoggingOut: boolean; did: string; onLogout: (did: string) => void }, 148 + ) { 149 + const isSwitching = () => props.isSwitching; 150 + const isLoggingOut = () => props.isLoggingOut; 151 + const did = () => props.did; 152 + return ( 153 + <button 154 + class={ghostButtonClass} 155 + type="button" 156 + disabled={isSwitching() || isLoggingOut()} 157 + onClick={() => props.onLogout(did())}> 158 + <Show 159 + when={isLoggingOut()} 160 + fallback={ 161 + <> 162 + <Icon kind="logout" name="logout" aria-hidden="true" /> 163 + <span>Logout</span> 164 + </> 165 + }> 166 + <> 167 + <Icon kind="loader" name="loader" aria-hidden="true" /> 168 + <span>Removing...</span> 169 + </> 170 + </Show> 171 + </button> 172 + ); 173 + } 174 + 175 + function AccountSwitchButton( 176 + props: { 177 + isActive: boolean; 178 + switching: boolean; 179 + loggingOut: boolean; 180 + account: AccountSummary; 181 + onSwitch: (did: string) => void; 182 + }, 183 + ) { 184 + const isActive = () => props.isActive; 185 + const switching = () => props.switching; 186 + const loggingOut = () => props.loggingOut; 187 + 188 + const content = createMemo(() => { 189 + const active = isActive(); 190 + const isSwitching = switching(); 191 + if (active) { 192 + return "Active"; 193 + } 65 194 66 - function AccountChip(props: AccountChipProps) { 195 + if (isSwitching) { 196 + return "Switching..."; 197 + } 198 + 199 + return "Switch"; 200 + }); 67 201 return ( 68 202 <button 69 - class="account-chip" 70 - classList={{ "account-chip--active": props.account.active }} 203 + class={secondaryButtonClass} 71 204 type="button" 205 + disabled={isActive() || switching() || loggingOut()} 206 + onClick={() => props.onSwitch(props.account.did)}> 207 + <Show when={switching()} fallback={<Icon kind="user" name="user" aria-hidden="true" class="mr-1" />}> 208 + <Icon kind="loader" name="loader" aria-hidden="true" class="mr-1" /> 209 + <span>{content()}</span> 210 + </Show> 211 + </button> 212 + ); 213 + } 214 + 215 + type AccountLedgerCardProps = { 216 + account: AccountSummary; 217 + activeDid: string | null; 218 + busyDid: string | null; 219 + logoutDid: string | null; 220 + onSwitch: (did: string) => void; 221 + onLogout: (did: string) => void; 222 + }; 223 + 224 + function AccountLedgerCard(props: AccountLedgerCardProps) { 225 + const isActive = () => props.activeDid === props.account.did; 226 + const switching = () => props.busyDid === props.account.did; 227 + const loggingOut = () => props.logoutDid === props.account.did; 228 + 229 + return ( 230 + <Motion.div 231 + class="grid items-center gap-4 rounded-2xl bg-white/2.5 p-4 max-[920px]:grid-cols-1 grid-cols-[minmax(0,1fr)_auto]" 232 + classList={{ "bg-[linear-gradient(135deg,rgba(125,175,255,0.12),rgba(0,115,222,0.08))]": isActive() }} 72 233 role="listitem" 73 - onClick={() => props.onActivate(props.account.did)}> 74 - <span class="account-handle">{props.account.handle || props.account.did}</span> 75 - <span class="account-meta">{props.account.pdsUrl || "PDS unavailable"}</span> 76 - </button> 234 + initial={{ opacity: 0, y: 18 }} 235 + animate={{ opacity: 1, y: 0 }} 236 + transition={{ duration: 0.24 }}> 237 + <div class="flex min-w-0 items-start gap-4"> 238 + <AvatarBadge label={props.account.handle || props.account.did} tone={isActive() ? "primary" : "muted"} /> 239 + <div class="min-w-0"> 240 + <p class="m-0 wrap-break-word text-[0.92rem] font-semibold">{props.account.handle || props.account.did}</p> 241 + <p class="m-0 wrap-break-word text-[0.78rem] text-on-surface-variant">{props.account.did}</p> 242 + <p class="m-0 wrap-break-word text-[0.78rem] text-on-surface-variant"> 243 + {props.account.pdsUrl || "PDS unavailable"} 244 + </p> 245 + </div> 246 + </div> 247 + 248 + <div class="flex items-center gap-2 max-[920px]:flex-col max-[920px]:items-stretch"> 249 + <AccountSwitchButton 250 + isActive={isActive()} 251 + switching={switching()} 252 + loggingOut={loggingOut()} 253 + account={props.account} 254 + onSwitch={props.onSwitch} /> 255 + <LogoutButton 256 + isSwitching={switching()} 257 + isLoggingOut={loggingOut()} 258 + did={props.account.did} 259 + onLogout={props.onLogout} /> 260 + </div> 261 + </Motion.div> 77 262 ); 78 263 } 79 264 80 265 function App() { 81 - const [bootstrapped, setBootstrapped] = createSignal(false); 266 + const [bootstrapping, setBootstrapping] = createSignal(true); 82 267 const [activeSession, setActiveSession] = createSignal<ActiveSession | null>(null); 83 268 const [accounts, setAccounts] = createSignal<AccountSummary[]>([]); 269 + const [loginValue, setLoginValue] = createSignal(""); 270 + const [loggingIn, setLoggingIn] = createSignal(false); 271 + const [switchingDid, setSwitchingDid] = createSignal<string | null>(null); 272 + const [logoutDid, setLogoutDid] = createSignal<string | null>(null); 273 + const [showSwitcher, setShowSwitcher] = createSignal(false); 84 274 const [errorMessage, setErrorMessage] = createSignal<string | null>(null); 275 + const [shakeCount, setShakeCount] = createSignal(0); 276 + const [reauthNeeded, setReauthNeeded] = createSignal(false); 277 + 278 + const activeAccount = createMemo(() => accounts().find((account) => account.did === activeSession()?.did) ?? null); 279 + const primaryAccount = createMemo(() => activeAccount() ?? accounts()[0] ?? null); 280 + const metaLabel = createMemo(() => { 281 + if (bootstrapping()) { 282 + return "signing you back in"; 283 + } 284 + 285 + if (activeSession()) { 286 + return "signed in"; 287 + } 288 + 289 + return "ready to sign in"; 290 + }); 85 291 86 292 async function loadBootstrap() { 293 + setBootstrapping(true); 294 + 87 295 try { 88 296 const payload = await invoke<AppBootstrap>("get_app_bootstrap"); 89 - setActiveSession(payload.activeSession); 90 - setAccounts(payload.accountList); 91 - setBootstrapped(true); 297 + startTransition(() => { 298 + setActiveSession(payload.activeSession); 299 + setAccounts(payload.accountList); 300 + setReauthNeeded(payload.accountList.length > 0 && !payload.activeSession); 301 + }); 92 302 } catch (error) { 93 303 setErrorMessage(`Failed to load app bootstrap: ${String(error)}`); 304 + } finally { 305 + setBootstrapping(false); 94 306 } 95 307 } 96 308 97 - async function activateAccount(did: string) { 309 + function closeSwitcher() { 310 + if (showSwitcher()) { 311 + setShowSwitcher(false); 312 + } 313 + } 314 + 315 + function triggerShake() { 316 + setShakeCount((count) => count + 1); 317 + } 318 + 319 + function markPotentialExpiry(error: unknown) { 320 + const message = String(error).toLowerCase(); 321 + if (message.includes("refresh failed") || message.includes("session does not exist")) { 322 + setReauthNeeded(true); 323 + } 324 + } 325 + 326 + async function submitLogin(identifier = loginValue()) { 327 + const trimmed = identifier.trim(); 328 + if (!validateIdentifier(trimmed)) { 329 + triggerShake(); 330 + setErrorMessage("Enter a valid Bluesky handle, DID, or PDS URL."); 331 + return; 332 + } 333 + 334 + setLoggingIn(true); 335 + try { 336 + await invoke("login", { handle: trimmed }); 337 + setLoginValue(""); 338 + closeSwitcher(); 339 + await loadBootstrap(); 340 + } catch (error) { 341 + markPotentialExpiry(error); 342 + setErrorMessage(`Authentication failed: ${String(error)}`); 343 + } finally { 344 + setLoggingIn(false); 345 + } 346 + } 347 + 348 + async function switchAccount(did: string) { 349 + setSwitchingDid(did); 98 350 try { 99 - await invoke("set_active_account", { did }); 351 + await invoke("switch_account", { did }); 352 + closeSwitcher(); 100 353 await loadBootstrap(); 101 354 } catch (error) { 355 + markPotentialExpiry(error); 102 356 setErrorMessage(`Failed to switch account: ${String(error)}`); 357 + } finally { 358 + setSwitchingDid(null); 103 359 } 104 360 } 105 361 362 + async function logout(did: string) { 363 + setLogoutDid(did); 364 + try { 365 + await invoke("logout", { did }); 366 + closeSwitcher(); 367 + await loadBootstrap(); 368 + } catch (error) { 369 + markPotentialExpiry(error); 370 + setErrorMessage(`Failed to logout account: ${String(error)}`); 371 + } finally { 372 + setLogoutDid(null); 373 + } 374 + } 375 + 376 + async function reauthorizePrimaryAccount() { 377 + const account = primaryAccount(); 378 + if (!account) { 379 + return; 380 + } 381 + 382 + await submitLogin(account.handle || account.did); 383 + } 384 + 106 385 onMount(() => { 386 + let unlisten: (() => void) | undefined; 387 + 107 388 void loadBootstrap(); 389 + 390 + void listen<ActiveSession | null>(ACCOUNT_SWITCH_EVENT, () => { 391 + void loadBootstrap(); 392 + }).then((dispose) => { 393 + unlisten = dispose; 394 + }); 395 + 396 + onCleanup(() => unlisten?.()); 108 397 }); 109 398 110 399 return ( 111 400 <> 112 - <main class="app-shell"> 113 - <aside class="app-rail" aria-label="Primary navigation"> 114 - <RailButton label="Accounts" icon="i-ri-user-3-line" active /> 115 - <RailButton label="Search" icon="i-ri-search-line" /> 401 + <main class="grid min-h-screen grid-cols-[16rem_minmax(0,1fr)] max-[1180px]:grid-cols-1"> 402 + <aside 403 + class="flex min-h-screen flex-col gap-8 bg-surface-container-lowest px-6 pb-6 pt-8 max-[1180px]:min-h-0 max-[1180px]:grid max-[1180px]:grid-cols-[auto_auto_minmax(18rem,1fr)] max-[1180px]:items-center max-[1180px]:gap-4 max-[1180px]:p-4 max-[760px]:grid-cols-1" 404 + aria-label="Primary navigation"> 405 + <Wordmark /> 406 + <div class="grid gap-2 max-[1180px]:flex max-[1180px]:items-center"> 407 + <RailButton label="Accounts" icon="profile" active /> 408 + <RailButton label="Search" icon="search" /> 409 + </div> 410 + <AccountSwitcher 411 + activeSession={activeSession()} 412 + accounts={accounts()} 413 + busyDid={switchingDid()} 414 + logoutDid={logoutDid()} 415 + open={showSwitcher()} 416 + onToggle={() => setShowSwitcher((open) => !open)} 417 + onSwitch={(did) => void switchAccount(did)} 418 + onLogout={(did) => void logout(did)} /> 116 419 </aside> 117 420 118 - <section class="work-surface" aria-busy={!bootstrapped()}> 119 - <header class="surface-header"> 120 - <h1 class="headline">Lazurite</h1> 121 - <p class="meta">backend bootstrap complete</p> 122 - </header> 421 + <section 422 + class="m-5 grid gap-8 rounded-4xl bg-[linear-gradient(160deg,rgba(14,14,14,0.92),rgba(25,25,25,0.98))] p-8 shadow-[0_24px_40px_rgba(125,175,255,0.05)] max-[1360px]:p-7 max-[1180px]:m-0 max-[1180px]:min-h-[calc(100vh-5.5rem)] max-[1180px]:rounded-none max-[1180px]:p-6 max-[760px]:gap-6 max-[760px]:p-5" 423 + aria-busy={bootstrapping()}> 424 + <HeaderPanel metaLabel={metaLabel()} /> 425 + 426 + <div class="grid gap-6 grid-cols-[minmax(0,1.25fr)_minmax(20rem,0.9fr)] max-[1320px]:grid-cols-1"> 427 + <SessionSpotlight 428 + activeSession={activeSession()} 429 + activeAccount={activeAccount()} 430 + bootstrapping={bootstrapping()} 431 + reauthNeeded={reauthNeeded()} 432 + onReauth={() => void reauthorizePrimaryAccount()} /> 433 + <LoginPanel 434 + value={loginValue()} 435 + pending={loggingIn()} 436 + shakeCount={shakeCount()} 437 + onInput={setLoginValue} 438 + onSubmit={() => void submitLogin()} /> 439 + </div> 123 440 124 - <SessionPanel activeSession={activeSession()} /> 125 - <AccountsPanel accounts={accounts()} onActivate={(did) => void activateAccount(did)} /> 441 + <AccountLedger 442 + accounts={accounts()} 443 + activeDid={activeSession()?.did ?? null} 444 + busyDid={switchingDid()} 445 + logoutDid={logoutDid()} 446 + onSwitch={(did) => void switchAccount(did)} 447 + onLogout={(did) => void logout(did)} /> 126 448 </section> 127 449 </main> 128 450 129 451 <ErrorToast message={errorMessage} onDismiss={() => setErrorMessage(null)} /> 130 452 </> 131 453 ); 454 + } 455 + 456 + function validateIdentifier(value: string) { 457 + const trimmed = value.trim(); 458 + const handlePattern = /^@?[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/i; 459 + const didPattern = /^did:[a-z]+:[a-zA-Z0-9._:%-]+$/; 460 + const urlPattern = /^https?:\/\/\S+$/i; 461 + 462 + return handlePattern.test(trimmed) || didPattern.test(trimmed) || urlPattern.test(trimmed); 132 463 } 133 464 134 465 export default App;
+94
src/components/AccountSwitcher.tsx
··· 1 + import { onCleanup, onMount, Show } from "solid-js"; 2 + import { Motion, Presence } from "solid-motionone"; 3 + import { AccountSummary, ActiveSession } from "../lib/types"; 4 + import { SwitcherIdentity } from "./AccountSwitcherIdentity"; 5 + import { AccountSwitcherMenuList } from "./AccountSwitcherMenuList"; 6 + import { ArrowIcon } from "./shared/Icon"; 7 + 8 + type AccountSwitcherProps = { 9 + activeSession: ActiveSession | null; 10 + accounts: AccountSummary[]; 11 + busyDid: string | null; 12 + logoutDid: string | null; 13 + open: boolean; 14 + onToggle: () => void; 15 + onSwitch: (did: string) => void; 16 + onLogout: (did: string) => void; 17 + }; 18 + 19 + export function AccountSwitcher(props: AccountSwitcherProps) { 20 + const isOpen = () => props.open; 21 + let container: HTMLDivElement | undefined; 22 + 23 + onMount(() => { 24 + const pointerListener = { 25 + handleEvent(event: Event) { 26 + if (!isOpen) { 27 + return; 28 + } 29 + 30 + if (container?.contains(event.target as Node)) { 31 + return; 32 + } 33 + 34 + props.onToggle(); 35 + }, 36 + }; 37 + 38 + globalThis.addEventListener("pointerdown", pointerListener); 39 + onCleanup(() => globalThis.removeEventListener("pointerdown", pointerListener)); 40 + }); 41 + 42 + return ( 43 + <div 44 + class="relative mt-auto w-full max-[1180px]:mt-0 max-[1180px]:max-w-[24rem] max-[1180px]:justify-self-end max-[760px]:max-w-none" 45 + ref={(element) => { 46 + container = element; 47 + }}> 48 + <button 49 + class="relative w-full cursor-pointer rounded-3xl border-0 bg-[linear-gradient(160deg,rgba(255,255,255,0.045),rgba(255,255,255,0.02))] px-4 py-[0.95rem] text-on-surface shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)] transition duration-150 ease-out hover:-translate-y-px hover:bg-[linear-gradient(160deg,rgba(255,255,255,0.08),rgba(255,255,255,0.03))]" 50 + type="button" 51 + aria-haspopup="menu" 52 + aria-expanded={props.open} 53 + onClick={() => props.onToggle()}> 54 + <Presence exitBeforeEnter> 55 + <Show 56 + when={props.activeSession} 57 + keyed 58 + fallback={<SwitcherIdentity label="?" name="Add account" meta="No active account" tone="muted" />}> 59 + {(session) => ( 60 + <SwitcherIdentity label={session.handle} name={session.handle} meta="Current account" tone="primary" /> 61 + )} 62 + </Show> 63 + </Presence> 64 + <span 65 + class="absolute right-[0.95rem] top-[1.15rem] flex items-center text-on-surface-variant" 66 + aria-hidden="true"> 67 + <Show when={props.open} fallback={<ArrowIcon direction="down" />}> 68 + <ArrowIcon direction="up" /> 69 + </Show> 70 + </span> 71 + </button> 72 + 73 + <Presence> 74 + <Show when={props.open}> 75 + <Motion.div 76 + class="absolute inset-x-0 bottom-[calc(100%+0.75rem)] rounded-3xl bg-(--surface-container-highest) p-4 shadow-[0_24px_40px_rgba(0,0,0,0.28)] backdrop-blur-[20px] max-[1180px]:bottom-auto max-[1180px]:top-[calc(100%+0.75rem)]" 77 + role="menu" 78 + initial={{ opacity: 0, y: 10, scale: 0.98 }} 79 + animate={{ opacity: 1, y: 0, scale: 1 }} 80 + exit={{ opacity: 0, y: 8, scale: 0.98 }} 81 + transition={{ duration: 0.2 }}> 82 + <p class="overline-copy text-[0.68rem] text-on-surface-variant">Accounts</p> 83 + <AccountSwitcherMenuList 84 + accounts={props.accounts} 85 + busyDid={props.busyDid} 86 + logoutDid={props.logoutDid} 87 + onSwitch={props.onSwitch} 88 + onLogout={props.onLogout} /> 89 + </Motion.div> 90 + </Show> 91 + </Presence> 92 + </div> 93 + ); 94 + }
+19
src/components/AccountSwitcherIdentity.tsx
··· 1 + import { Motion } from "solid-motionone"; 2 + import { AvatarBadge } from "./AvatarBadge"; 3 + 4 + export function SwitcherIdentity(props: { label: string; name: string; meta: string; tone: "primary" | "muted" }) { 5 + return ( 6 + <Motion.div 7 + class="flex items-center gap-3" 8 + initial={{ opacity: 0, y: 8, scale: 0.96 }} 9 + animate={{ opacity: 1, y: 0, scale: 1 }} 10 + exit={{ opacity: 0, y: -6, scale: 0.94 }} 11 + transition={{ duration: 0.24 }}> 12 + <AvatarBadge label={props.label} tone={props.tone} /> 13 + <div class="grid"> 14 + <span class="text-[0.92rem] font-semibold">{props.name}</span> 15 + <span class="text-[0.78rem] text-on-surface-variant">{props.meta}</span> 16 + </div> 17 + </Motion.div> 18 + ); 19 + }
+34
src/components/AccountSwitcherMenuList.tsx
··· 1 + import { For, Show } from "solid-js"; 2 + import { AccountSummary } from "../lib/types"; 3 + import { AccountSwitcherRow } from "./AccountSwitcherRow"; 4 + 5 + export function AccountSwitcherMenuList( 6 + props: { 7 + accounts: AccountSummary[]; 8 + busyDid: string | null; 9 + logoutDid: string | null; 10 + onSwitch: (did: string) => void; 11 + onLogout: (did: string) => void; 12 + }, 13 + ) { 14 + return ( 15 + <Show 16 + when={props.accounts.length > 0} 17 + fallback={ 18 + <p class="overline-copy mt-[0.9rem] text-[0.72rem] text-on-surface-variant">No stored accounts yet.</p> 19 + }> 20 + <div class="mt-[0.9rem] grid gap-2"> 21 + <For each={props.accounts}> 22 + {(account) => ( 23 + <AccountSwitcherRow 24 + account={account} 25 + busy={props.busyDid === account.did} 26 + loggingOut={props.logoutDid === account.did} 27 + onSwitch={props.onSwitch} 28 + onLogout={props.onLogout} /> 29 + )} 30 + </For> 31 + </div> 32 + </Show> 33 + ); 34 + }
+42
src/components/AccountSwitcherRow.tsx
··· 1 + import { AccountSummary } from "../lib/types"; 2 + import { AvatarBadge } from "./AvatarBadge"; 3 + import { Icon } from "./shared/Icon"; 4 + 5 + type AccountSwitcherRowProps = { 6 + account: AccountSummary; 7 + busy: boolean; 8 + loggingOut: boolean; 9 + onSwitch: (did: string) => void; 10 + onLogout: (did: string) => void; 11 + }; 12 + 13 + export function AccountSwitcherRow(props: AccountSwitcherRowProps) { 14 + const isLocked = () => props.busy || props.loggingOut; 15 + 16 + return ( 17 + <div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-2" role="menuitem"> 18 + <button 19 + class="flex items-center gap-3 rounded-2xl border-0 bg-white/2 p-3 text-left text-on-surface transition duration-150 ease-out hover:bg-surface-bright" 20 + classList={{ "bg-primary/15": props.account.active }} 21 + type="button" 22 + disabled={isLocked()} 23 + onClick={() => props.onSwitch(props.account.did)}> 24 + <AvatarBadge 25 + label={props.account.handle || props.account.did} 26 + tone={props.account.active ? "primary" : "muted"} /> 27 + <span class="grid"> 28 + <span class="text-[0.92rem] font-semibold">{props.account.handle || props.account.did}</span> 29 + <span class="text-[0.78rem] text-on-surface-variant">{props.account.pdsUrl || "PDS unavailable"}</span> 30 + </span> 31 + </button> 32 + <button 33 + class="rounded-full border-0 bg-transparent p-[0.6rem] text-on-surface-variant transition duration-150 ease-out hover:bg-surface-bright" 34 + type="button" 35 + aria-label={`Logout ${props.account.handle || props.account.did}`} 36 + disabled={isLocked()} 37 + onClick={() => props.onLogout(props.account.did)}> 38 + <Icon kind="logout" name="logout" aria-hidden="true" /> 39 + </button> 40 + </div> 41 + ); 42 + }
+22
src/components/AvatarBadge.tsx
··· 1 + import { createMemo } from "solid-js"; 2 + 3 + type AvatarBadgeProps = { label: string; tone?: "primary" | "muted" }; 4 + 5 + export function AvatarBadge(props: AvatarBadgeProps) { 6 + const label = createMemo(() => { 7 + const token = props.label.replace(/^@/, "").replace(/^did:[^:]+:/, "").split(/[./:-]/).find(Boolean); 8 + return (token ?? "?").slice(0, 2).toUpperCase(); 9 + }); 10 + 11 + return ( 12 + <span 13 + class="inline-flex h-10 w-10 items-center justify-center rounded-full text-[0.82rem] font-bold tracking-[0.08em]" 14 + classList={{ 15 + "bg-[linear-gradient(135deg,var(--primary)_0%,var(--primary-dim)_100%)] text-[color:var(--on-primary-fixed)]": 16 + props.tone === "primary", 17 + "bg-white/8 text-on-surface": props.tone !== "primary", 18 + }}> 19 + {label()} 20 + </span> 21 + ); 22 + }
-32
src/components/ErrorToast.tsx
··· 1 - import type { Accessor } from "solid-js"; 2 - import { Motion, Presence } from "solid-motionone"; 3 - 4 - type ErrorToastProps = { message: Accessor<string | null>; onDismiss: () => void }; 5 - 6 - export function ErrorToast(props: ErrorToastProps) { 7 - return ( 8 - <Presence> 9 - {props.message() && ( 10 - <Motion.div 11 - role="alert" 12 - aria-live="assertive" 13 - class="error-toast" 14 - initial={{ opacity: 0, y: 20, scale: 0.96 }} 15 - animate={{ opacity: 1, y: 0, scale: 1 }} 16 - exit={{ opacity: 0, y: 16, scale: 0.94 }} 17 - transition={{ duration: 0.2 }}> 18 - <span class="flex items-center error-toast__glyph" aria-hidden="true"> 19 - <i class="i-ri-error-warning-line" /> 20 - </span> 21 - <p class="error-toast__message">{props.message()}</p> 22 - <button type="button" class="error-toast__dismiss" onClick={props.onDismiss}> 23 - <span class="flex items-center" aria-hidden="true"> 24 - <i class="i-ri-close-line" /> 25 - </span> 26 - <span class="sr-only">Dismiss error</span> 27 - </button> 28 - </Motion.div> 29 - )} 30 - </Presence> 31 - ); 32 - }
+10
src/components/ProfileSkeleton.tsx
··· 1 + export function ProfileSkeleton() { 2 + return ( 3 + <div class="grid gap-[0.85rem]" aria-hidden="true"> 4 + <span class="skeleton-block h-18 w-18 rounded-full" /> 5 + <span class="skeleton-block h-[0.85rem] w-[min(16rem,80%)] rounded-full" /> 6 + <span class="skeleton-block h-[0.85rem] w-[min(11rem,64%)] rounded-full" /> 7 + <span class="skeleton-block h-[0.85rem] w-[min(9rem,48%)] rounded-full" /> 8 + </div> 9 + ); 10 + }
+15
src/components/RailButton.tsx
··· 1 + import { Icon, type IconKind } from "./shared/Icon"; 2 + 3 + type RailButtonProps = { label: string; icon: IconKind; active?: boolean }; 4 + 5 + export function RailButton(props: RailButtonProps) { 6 + return ( 7 + <button 8 + class="grid h-[3.3rem] place-items-center rounded-full border-0 bg-transparent text-on-surface-variant transition duration-150 ease-out hover:-translate-y-px hover:bg-surface-bright hover:text-on-surface" 9 + classList={{ "bg-surface-container text-primary": !!props.active }} 10 + type="button" 11 + aria-label={props.label}> 12 + <Icon kind={props.icon} name={props.label} aria-hidden="true" /> 13 + </button> 14 + ); 15 + }
+25
src/components/ReauthBanner.tsx
··· 1 + import { Motion } from "solid-motionone"; 2 + import { Icon } from "./shared/Icon"; 3 + 4 + export function ReauthBanner(props: { onReauth: () => void }) { 5 + return ( 6 + <Motion.div 7 + class="flex items-center justify-between gap-4 rounded-2xl bg-primary/12 px-[1.1rem] py-4 max-[920px]:flex-col max-[920px]:items-stretch" 8 + role="status" 9 + initial={{ opacity: 0, y: 12 }} 10 + animate={{ opacity: 1, y: 0, scale: [1, 1.015, 1] }} 11 + exit={{ opacity: 0, y: 8 }} 12 + transition={{ duration: 1.8, repeat: Number.POSITIVE_INFINITY, easing: "ease-in-out" }}> 13 + <div class="grid gap-[0.2rem]"> 14 + <p class="m-0 text-[0.95rem] font-semibold">Sign in again to reconnect this account.</p> 15 + <p class="m-0 text-[0.78rem] text-on-surface-variant"> 16 + We couldn&apos;t restore the last session automatically. 17 + </p> 18 + </div> 19 + <button class="pill-action border-0 bg-white/8 text-on-surface" type="button" onClick={() => props.onReauth()}> 20 + <Icon kind="refresh" name="refresh" aria-hidden="true" class="mr-1" /> 21 + Sign in again 22 + </button> 23 + </Motion.div> 24 + ); 25 + }
+87
src/components/Session.tsx
··· 1 + import { createMemo, Show } from "solid-js"; 2 + import { Presence } from "solid-motionone"; 3 + import { AccountSummary, ActiveSession } from "../lib/types"; 4 + import { AvatarBadge } from "./AvatarBadge"; 5 + import { ProfileSkeleton } from "./ProfileSkeleton"; 6 + import { ReauthBanner } from "./ReauthBanner"; 7 + 8 + export function SessionEmptyState() { 9 + return ( 10 + <div class="grid"> 11 + <h2 class="m-0 text-[clamp(1.4rem,2vw,1.85rem)] leading-[1.08] tracking-[-0.03em]">Sign in to get started.</h2> 12 + <p class="m-0 text-[0.78rem] leading-[1.55] text-on-surface-variant"> 13 + Add a Bluesky account now. You can switch or add more later. 14 + </p> 15 + </div> 16 + ); 17 + } 18 + 19 + export function SessionProfile(props: { session: ActiveSession; activeAccount: AccountSummary | null }) { 20 + return ( 21 + <div class="grid items-center gap-4 [align-content:start] grid-cols-[auto_minmax(0,1fr)]"> 22 + <AvatarBadge label={props.session.handle} tone="primary" /> 23 + <div class="grid"> 24 + <h2 class="m-0 text-[clamp(1.3rem,2vw,1.7rem)] tracking-[-0.02em]">{props.session.handle}</h2> 25 + <p class="m-0 text-[0.78rem] text-on-surface-variant">{props.session.did}</p> 26 + </div> 27 + <Show when={props.activeAccount}> 28 + {(account) => <p class="m-0 text-[0.78rem] text-on-surface-variant">{account().pdsUrl || "PDS unavailable"}</p>} 29 + </Show> 30 + </div> 31 + ); 32 + } 33 + 34 + type SessionSpotlightProps = { 35 + activeSession: ActiveSession | null; 36 + activeAccount: AccountSummary | null; 37 + bootstrapping: boolean; 38 + reauthNeeded: boolean; 39 + onReauth: () => void; 40 + }; 41 + 42 + export function SessionSpotlight(props: SessionSpotlightProps) { 43 + const bootstrapping = () => props.bootstrapping; 44 + const activeSession = () => props.activeSession; 45 + const label = createMemo(() => { 46 + if (bootstrapping()) { 47 + return "Restoring"; 48 + } 49 + 50 + if (activeSession()) { 51 + return "Signed in"; 52 + } 53 + 54 + return "Ready"; 55 + }); 56 + return ( 57 + <article class="panel-surface grid min-h-76 gap-6 p-6 max-[760px]:min-h-0"> 58 + <div class="flex items-baseline justify-between gap-3"> 59 + <p class="overline-copy text-[0.75rem] text-on-surface-variant">Current account</p> 60 + <p class="overline-copy text-[0.68rem] text-on-surface-variant">{label()}</p> 61 + </div> 62 + 63 + <SessionBody 64 + activeSession={props.activeSession} 65 + activeAccount={props.activeAccount} 66 + bootstrapping={props.bootstrapping} /> 67 + 68 + <Presence> 69 + <Show when={props.reauthNeeded}> 70 + <ReauthBanner onReauth={props.onReauth} /> 71 + </Show> 72 + </Presence> 73 + </article> 74 + ); 75 + } 76 + 77 + export function SessionBody( 78 + props: { activeSession: ActiveSession | null; activeAccount: AccountSummary | null; bootstrapping: boolean }, 79 + ) { 80 + return ( 81 + <Show when={!props.bootstrapping} fallback={<ProfileSkeleton />}> 82 + <Show when={props.activeSession} fallback={<SessionEmptyState />}> 83 + {(session) => <SessionProfile session={session()} activeAccount={props.activeAccount} />} 84 + </Show> 85 + </Show> 86 + ); 87 + }
+13
src/components/Wordmark.tsx
··· 1 + export function Wordmark() { 2 + return ( 3 + <div class="flex items-center gap-3"> 4 + <span 5 + class="h-[2.7rem] w-[0.95rem] rounded-full bg-[linear-gradient(180deg,var(--primary)_0%,var(--primary-dim)_100%)] shadow-[0_0_24px_rgba(125,175,255,0.24)]" 6 + aria-hidden="true" /> 7 + <div class="grid"> 8 + <p class="m-0 text-[0.9rem]">Lazurite</p> 9 + <p class="overline-copy text-[0.68rem] text-on-surface-variant">Desktop</p> 10 + </div> 11 + </div> 12 + ); 13 + }
+15
src/components/panels/Header.tsx
··· 1 + type HeaderPanelProps = { metaLabel: string }; 2 + 3 + export function HeaderPanel(props: HeaderPanelProps) { 4 + return ( 5 + <header class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_auto] xl:items-start"> 6 + <div class="max-w-3xl"> 7 + <p class="overline-copy text-[0.72rem] text-primary">Authentication</p> 8 + <h1 class="m-0 max-w-[11ch] text-balance text-[clamp(2.3rem,5vw,4.2rem)] leading-[0.94] tracking-[-0.03em] max-[760px]:text-[clamp(1.95rem,10vw,3.2rem)]"> 9 + All your accounts, right where you left them. 10 + </h1> 11 + </div> 12 + <p class="overline-copy text-[0.72rem] tracking-[0.18em] text-on-surface-variant xl:pt-2">{props.metaLabel}</p> 13 + </header> 14 + ); 15 + }
+35
src/components/shared/ErrorToast.tsx
··· 1 + import type { Accessor } from "solid-js"; 2 + import { Motion, Presence } from "solid-motionone"; 3 + 4 + type ErrorToastProps = { message: Accessor<string | null>; onDismiss: () => void }; 5 + 6 + export function ErrorToast(props: ErrorToastProps) { 7 + return ( 8 + <Presence> 9 + {props.message() && ( 10 + <Motion.div 11 + role="alert" 12 + aria-live="assertive" 13 + class="fixed bottom-6 left-1/2 grid w-max max-w-[min(30rem,calc(100vw-2rem))] -translate-x-1/2 grid-cols-[auto_1fr_auto] items-center gap-3 rounded-full bg-error-surface px-4 py-3 text-on-surface shadow-[0_24px_40px_rgba(125,175,255,0.05)] backdrop-blur-[20px] max-sm:w-[calc(100vw-1.5rem)]" 14 + initial={{ opacity: 0, y: 20, scale: 0.96 }} 15 + animate={{ opacity: 1, y: 0, scale: 1 }} 16 + exit={{ opacity: 0, y: 16, scale: 0.94 }} 17 + transition={{ duration: 0.2 }}> 18 + <span class="flex items-center text-error" aria-hidden="true"> 19 + <i class="i-ri-error-warning-line" /> 20 + </span> 21 + <p class="m-0 text-[0.875rem] text-on-surface">{props.message()}</p> 22 + <button 23 + type="button" 24 + class="cursor-pointer rounded-full border-0 bg-transparent p-[0.35rem] text-inherit hover:bg-surface-bright" 25 + onClick={props.onDismiss}> 26 + <span class="flex items-center" aria-hidden="true"> 27 + <i class="i-ri-close-line" /> 28 + </span> 29 + <span class="sr-only">Dismiss error</span> 30 + </button> 31 + </Motion.div> 32 + )} 33 + </Presence> 34 + ); 35 + }
+54
src/components/shared/Icon.tsx
··· 1 + import { Match, Switch } from "solid-js"; 2 + 3 + export type IconKind = "loader" | "user" | "logout" | "profile" | "search" | "refresh" | "ext-link"; 4 + 5 + export function Icon(props: { name: string; class?: string; kind: IconKind }) { 6 + return ( 7 + <span class="flex items-center justify-center" classList={{ [props.class ?? ""]: !!props.class }}> 8 + <Switch> 9 + <Match when={props.kind === "loader"}> 10 + <i class="i-ri-loader-4-line" /> 11 + </Match> 12 + <Match when={props.kind === "user"}> 13 + <i class="i-ri-user-shared-line" /> 14 + </Match> 15 + <Match when={props.kind === "logout"}> 16 + <i class="i-ri-logout-box-line" /> 17 + </Match> 18 + <Match when={props.kind === "profile"}> 19 + <i class="i-ri-user-3-line" /> 20 + </Match> 21 + <Match when={props.kind === "search"}> 22 + <i class="i-ri-search-line" /> 23 + </Match> 24 + <Match when={props.kind === "refresh"}> 25 + <i class="i-ri-refresh-line" /> 26 + </Match> 27 + <Match when={props.kind === "ext-link"}> 28 + <i class="i-ri-external-link-line" /> 29 + </Match> 30 + </Switch> 31 + </span> 32 + ); 33 + } 34 + 35 + export function ArrowIcon(props: { class?: string; direction: "up" | "down" | "left" | "right" }) { 36 + return ( 37 + <span class="flex items-center justify-center" classList={{ [props.class ?? ""]: !!props.class }}> 38 + <Switch> 39 + <Match when={props.direction === "up"}> 40 + <i class="i-ri-arrow-up-s-line" /> 41 + </Match> 42 + <Match when={props.direction === "down"}> 43 + <i class="i-ri-arrow-down-s-line" /> 44 + </Match> 45 + <Match when={props.direction === "left"}> 46 + <i class="i-ri-arrow-left-s-line" /> 47 + </Match> 48 + <Match when={props.direction === "right"}> 49 + <i class="i-ri-arrow-right-s-line" /> 50 + </Match> 51 + </Switch> 52 + </span> 53 + ); 54 + }