replies timeline only, appview-less bluesky client

refactor: separate into popup and tabs components for reusing

ptr.pet 6981b810 0183e9c5

verified
+42 -81
src/components/AccountSelector.svelte
··· 4 4 import type { Handle } from '@atcute/lexicons'; 5 5 import ProfilePicture from './ProfilePicture.svelte'; 6 6 import PfpPlaceholder from './PfpPlaceholder.svelte'; 7 + import Popup from './Popup.svelte'; 7 8 import { flow } from '$lib/at/oauth'; 8 9 import { isHandle, type AtprotoDid } from '@atcute/lexicons/syntax'; 9 10 import Icon from '@iconify/svelte'; ··· 45 46 isDropdownOpen = false; 46 47 loginHandle = ''; 47 48 loginError = ''; 49 + // HACK: i hate this but it works so it doesnt really matter 50 + setTimeout(() => document.getElementById('handle')?.focus(), 100); 48 51 }; 49 52 50 53 const closeLoginModal = () => { 54 + document.getElementById('handle')?.blur(); 51 55 isLoginModalOpen = false; 52 56 loginHandle = ''; 53 57 loginError = ''; ··· 79 83 }; 80 84 81 85 const handleKeydown = (event: KeyboardEvent) => { 82 - if (event.key === 'Escape') { 83 - closeLoginModal(); 84 - } else if (event.key === 'Enter' && !isLoggingIn) { 85 - handleLogin(); 86 - } 86 + if (event.key === 'Enter' && !isLoggingIn) handleLogin(); 87 87 }; 88 88 89 89 const closeDropdown = () => { ··· 182 182 {/if} 183 183 </div> 184 184 185 - {#if isLoginModalOpen} 186 - <div 187 - class="fixed inset-0 z-50 flex items-center justify-center bg-(--nucleus-bg)/80 backdrop-blur-sm" 188 - onclick={closeLoginModal} 189 - onkeydown={handleKeydown} 190 - role="button" 191 - tabindex="-1" 192 - > 193 - <!-- svelte-ignore a11y_interactive_supports_focus --> 194 - <!-- svelte-ignore a11y_click_events_have_key_events --> 195 - <div 196 - class="w-full max-w-md animate-fade-in-scale rounded-sm border-2 border-(--nucleus-accent) bg-(--nucleus-bg) p-4 shadow-2xl transition-all" 197 - onclick={(e) => e.stopPropagation()} 198 - role="dialog" 199 - > 200 - <div class="mb-6 flex items-center justify-between"> 201 - <div> 202 - <h2 class="text-2xl font-bold">add account</h2> 203 - <div class="mt-2 flex gap-2"> 204 - <div class="h-1 w-10 rounded-full bg-(--nucleus-accent)"></div> 205 - <div class="h-1 w-9 rounded-full bg-(--nucleus-accent2)"></div> 206 - </div> 207 - </div> 208 - <!-- svelte-ignore a11y_consider_explicit_label --> 209 - <button 210 - onclick={closeLoginModal} 211 - class="rounded-xl p-2 text-(--nucleus-fg)/40 transition-all hover:scale-110 hover:text-(--nucleus-fg)" 212 - > 213 - <svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 214 - <path 215 - stroke-linecap="round" 216 - stroke-linejoin="round" 217 - stroke-width="2.5" 218 - d="M6 18L18 6M6 6l12 12" 219 - /> 220 - </svg> 221 - </button> 185 + <Popup bind:isOpen={isLoginModalOpen} onClose={closeLoginModal} title="add account"> 186 + <!-- svelte-ignore a11y_no_static_element_interactions --> 187 + <div class="space-y-2" onkeydown={handleKeydown}> 188 + <div> 189 + <label for="handle" class="mb-2 block text-sm font-semibold text-(--nucleus-fg)/80"> 190 + account handle 191 + </label> 192 + <input 193 + id="handle" 194 + type="text" 195 + bind:value={loginHandle} 196 + placeholder="example.bsky.social" 197 + class="single-line-input border-(--nucleus-accent)/40 bg-(--nucleus-accent)/3" 198 + disabled={isLoggingIn} 199 + /> 200 + </div> 201 + 202 + {#if loginError} 203 + <div class="error-disclaimer"> 204 + <p> 205 + <Icon class="inline h-10 w-10" icon="heroicons:exclamation-triangle-16-solid" /> 206 + {loginError} 207 + </p> 222 208 </div> 209 + {/if} 223 210 224 - <div class="space-y-5"> 225 - <div> 226 - <label for="handle" class="mb-2 block text-sm font-semibold text-(--nucleus-fg)/80"> 227 - handle 228 - </label> 229 - <input 230 - id="handle" 231 - type="text" 232 - bind:value={loginHandle} 233 - placeholder="example.bsky.social" 234 - class="single-line-input border-(--nucleus-accent)/40 bg-(--nucleus-accent)/3" 235 - disabled={isLoggingIn} 236 - /> 237 - </div> 238 - 239 - {#if loginError} 240 - <div class="error-disclaimer"> 241 - <p> 242 - <Icon class="inline h-10 w-10" icon="heroicons:exclamation-triangle-16-solid" /> 243 - {loginError} 244 - </p> 245 - </div> 246 - {/if} 247 - 248 - <div class="flex gap-3 pt-3"> 249 - <button onclick={closeLoginModal} class="flex-1 action-button" disabled={isLoggingIn}> 250 - cancel 251 - </button> 252 - <button 253 - onclick={handleLogin} 254 - class="flex-1 action-button border-transparent text-(--nucleus-fg)" 255 - style="background: linear-gradient(135deg, var(--nucleus-accent), var(--nucleus-accent2));" 256 - disabled={isLoggingIn} 257 - > 258 - {isLoggingIn ? 'logging in...' : 'login'} 259 - </button> 260 - </div> 261 - </div> 211 + <div class="flex gap-3 pt-3"> 212 + <button onclick={closeLoginModal} class="flex-1 action-button" disabled={isLoggingIn}> 213 + cancel 214 + </button> 215 + <button 216 + onclick={handleLogin} 217 + class="flex-1 action-button border-transparent text-(--nucleus-fg)" 218 + style="background: linear-gradient(135deg, var(--nucleus-accent), var(--nucleus-accent2));" 219 + disabled={isLoggingIn} 220 + > 221 + {isLoggingIn ? 'logging in...' : 'login'} 222 + </button> 262 223 </div> 263 224 </div> 264 - {/if} 225 + </Popup>
+102
src/components/Popup.svelte
··· 1 + <script lang="ts"> 2 + import type { Snippet } from 'svelte'; 3 + 4 + interface Props { 5 + isOpen: boolean; 6 + onClose?: () => void; 7 + title: string; 8 + width?: string; 9 + height?: string; 10 + padding?: string; 11 + showHeaderDivider?: boolean; 12 + headerActions?: Snippet; 13 + children: Snippet; 14 + footer?: Snippet; 15 + } 16 + 17 + let { 18 + isOpen = $bindable(false), 19 + onClose = () => (isOpen = false), 20 + title, 21 + width = 'w-full max-w-md', 22 + height = 'auto', 23 + padding = 'p-4', 24 + showHeaderDivider = false, 25 + headerActions, 26 + children, 27 + footer 28 + }: Props = $props(); 29 + 30 + const handleKeydown = (event: KeyboardEvent) => { 31 + if (event.key === 'Escape') onClose(); 32 + }; 33 + </script> 34 + 35 + {#if isOpen} 36 + <div 37 + class="fixed inset-0 z-50 flex items-center justify-center bg-(--nucleus-bg)/80 backdrop-blur-sm" 38 + onclick={onClose} 39 + onkeydown={handleKeydown} 40 + role="button" 41 + tabindex="-1" 42 + > 43 + <!-- svelte-ignore a11y_interactive_supports_focus --> 44 + <!-- svelte-ignore a11y_click_events_have_key_events --> 45 + <div 46 + class="flex {height === 'auto' 47 + ? '' 48 + : 'h-[' + 49 + height + 50 + ']'} {width} shrink animate-fade-in-scale flex-col rounded-sm border-2 border-(--nucleus-accent) bg-(--nucleus-bg) shadow-2xl transition-all" 51 + style={height !== 'auto' ? `height: ${height}` : ''} 52 + onclick={(e) => e.stopPropagation()} 53 + role="dialog" 54 + > 55 + <!-- Header --> 56 + <div 57 + class="flex items-center gap-4 {showHeaderDivider 58 + ? 'border-b-2 border-(--nucleus-accent)/20' 59 + : ''} {padding}" 60 + > 61 + <div> 62 + <h2 class="text-2xl font-bold">{title}</h2> 63 + <div class="mt-2 flex gap-2"> 64 + <div class="h-1 w-8 rounded-full bg-(--nucleus-accent)"></div> 65 + <div class="h-1 w-9.5 rounded-full bg-(--nucleus-accent2)"></div> 66 + </div> 67 + </div> 68 + 69 + {#if headerActions} 70 + {@render headerActions()} 71 + {/if} 72 + 73 + <div class="grow"></div> 74 + 75 + <!-- svelte-ignore a11y_consider_explicit_label --> 76 + <button 77 + onclick={onClose} 78 + class="rounded-xl p-2 text-(--nucleus-fg)/40 transition-all hover:scale-110 hover:text-(--nucleus-fg)" 79 + > 80 + <svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 81 + <path 82 + stroke-linecap="round" 83 + stroke-linejoin="round" 84 + stroke-width="2.5" 85 + d="M6 18L18 6M6 6l12 12" 86 + /> 87 + </svg> 88 + </button> 89 + </div> 90 + 91 + <!-- Content --> 92 + <div class="{height === 'auto' ? '' : 'flex-1 overflow-y-auto'} {padding}"> 93 + {@render children()} 94 + </div> 95 + 96 + <!-- Footer --> 97 + {#if footer} 98 + {@render footer()} 99 + {/if} 100 + </div> 101 + </div> 102 + {/if}
+35 -83
src/components/SettingsPopup.svelte
··· 3 3 import { handleCache, didDocCache, recordCache } from '$lib/at/client'; 4 4 import { get } from 'svelte/store'; 5 5 import ColorPicker from 'svelte-awesome-color-picker'; 6 + import Popup from './Popup.svelte'; 7 + import Tabs from './Tabs.svelte'; 6 8 7 9 interface Props { 8 10 isOpen: boolean; ··· 11 13 12 14 let { isOpen = $bindable(false), onClose }: Props = $props(); 13 15 14 - type Tab = 'advanced' | 'moderation' | 'style'; 16 + type Tab = 'style' | 'moderation' | 'advanced'; 15 17 let activeTab = $state<Tab>('advanced'); 16 18 17 19 let localSettings = $state(get(settings)); ··· 32 34 33 35 const handleSave = () => { 34 36 settings.set(localSettings); 35 - // reload to update api endpoints 36 37 window.location.reload(); 37 38 }; 38 39 ··· 49 50 recordCache.clear(); 50 51 alert('cache cleared!'); 51 52 }; 52 - 53 - const handleKeydown = (event: KeyboardEvent) => { 54 - if (event.key === 'Escape') handleClose(); 55 - }; 56 53 </script> 57 54 58 55 {#snippet divider()} ··· 74 71 <label for={name} class="mb-2 block text-sm font-semibold text-(--nucleus-fg)/80"> 75 72 {desc} 76 73 </label> 77 - <!-- todo: add validation for url --> 78 74 <input 79 75 id={name} 80 76 type="url" ··· 143 139 </div> 144 140 {/snippet} 145 141 146 - {#if isOpen} 147 - <div 148 - class="fixed inset-0 z-50 flex items-center justify-center bg-(--nucleus-bg)/80 p-12 backdrop-blur-sm" 149 - onclick={handleClose} 150 - onkeydown={handleKeydown} 151 - role="button" 152 - tabindex="-1" 153 - > 154 - <!-- svelte-ignore a11y_interactive_supports_focus --> 155 - <!-- svelte-ignore a11y_click_events_have_key_events --> 156 - <div 157 - class="flex h-[60vh] w-[42vmax] max-w-2xl shrink animate-fade-in-scale flex-col rounded-sm border-2 border-(--nucleus-accent) bg-(--nucleus-bg) shadow-2xl transition-all" 158 - onclick={(e) => e.stopPropagation()} 159 - role="dialog" 160 - > 161 - <div class="flex items-center gap-4 border-b-2 border-(--nucleus-accent)/20 p-4"> 162 - <div> 163 - <h2 class="text-2xl font-bold">settings</h2> 164 - <div class="mt-2 flex gap-2"> 165 - <div class="h-1 w-8 rounded-full bg-(--nucleus-accent)"></div> 166 - <div class="h-1 w-9.5 rounded-full bg-(--nucleus-accent2)"></div> 167 - </div> 168 - </div> 169 - {#if hasReloadChanges} 170 - <button onclick={handleSave} class="shrink-0 action-button px-6"> save & reload </button> 171 - {/if} 172 - <div class="grow"></div> 173 - <!-- svelte-ignore a11y_consider_explicit_label --> 174 - <button 175 - onclick={handleClose} 176 - class="rounded-xl p-2 text-(--nucleus-fg)/40 transition-all hover:scale-110" 177 - > 178 - <svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 179 - <path 180 - stroke-linecap="round" 181 - stroke-linejoin="round" 182 - stroke-width="2.5" 183 - d="M6 18L18 6M6 6l12 12" 184 - /> 185 - </svg> 186 - </button> 187 - </div> 188 - 189 - <div class="flex-1 overflow-y-auto p-4"> 190 - {#if activeTab === 'advanced'} 191 - {@render advancedTab()} 192 - {:else if activeTab === 'moderation'} 193 - <div class="flex h-full items-center justify-center"> 194 - <div class="text-center"> 195 - <div class="mb-4 text-6xl opacity-50">🚧</div> 196 - <h3 class="text-xl font-bold opacity-80">todo</h3> 197 - </div> 198 - </div> 199 - {:else if activeTab === 'style'} 200 - {@render styleTab()} 201 - {/if} 202 - </div> 142 + <Popup 143 + bind:isOpen 144 + onClose={handleClose} 145 + title="settings" 146 + width="w-[42vmax] max-w-2xl" 147 + height="60vh" 148 + showHeaderDivider={true} 149 + > 150 + {#snippet headerActions()} 151 + {#if hasReloadChanges} 152 + <button onclick={handleSave} class="shrink-0 action-button"> save & reload </button> 153 + {/if} 154 + {/snippet} 203 155 204 - <div> 205 - <div class="flex"> 206 - {#snippet tabButton(name: Tab)} 207 - {@const isActive = activeTab === name} 208 - <button 209 - onclick={() => (activeTab = name)} 210 - class="flex-1 border-t-3 px-4 py-3 font-semibold transition-colors hover:cursor-pointer {isActive 211 - ? 'border-(--nucleus-accent) bg-(--nucleus-accent)/20 text-(--nucleus-accent)' 212 - : 'border-(--nucleus-accent)/20 bg-transparent text-(--nucleus-fg)/60 hover:bg-(--nucleus-accent)/10'}" 213 - > 214 - {name} 215 - </button> 216 - {/snippet} 217 - {#each ['style', 'moderation', 'advanced'] as Tab[] as tabName (tabName)} 218 - {@render tabButton(tabName)} 219 - {/each} 220 - </div> 156 + {#if activeTab === 'advanced'} 157 + {@render advancedTab()} 158 + {:else if activeTab === 'moderation'} 159 + <div class="flex h-full items-center justify-center"> 160 + <div class="text-center"> 161 + <div class="mb-4 text-6xl opacity-50">🚧</div> 162 + <h3 class="text-xl font-bold opacity-80">todo</h3> 221 163 </div> 222 164 </div> 223 - </div> 224 - {/if} 165 + {:else if activeTab === 'style'} 166 + {@render styleTab()} 167 + {/if} 168 + 169 + {#snippet footer()} 170 + <Tabs 171 + tabs={['style', 'moderation', 'advanced']} 172 + bind:activeTab 173 + onTabChange={(tab) => (activeTab = tab)} 174 + /> 175 + {/snippet} 176 + </Popup>
+23
src/components/Tabs.svelte
··· 1 + <script lang="ts" generics="T extends string"> 2 + interface Props { 3 + tabs: T[]; 4 + activeTab: T; 5 + onTabChange: (tab: T) => void; 6 + } 7 + 8 + let { tabs, activeTab = $bindable(), onTabChange }: Props = $props(); 9 + </script> 10 + 11 + <div class="flex"> 12 + {#each tabs as tab (tab)} 13 + {@const isActive = activeTab === tab} 14 + <button 15 + onclick={() => onTabChange(tab)} 16 + class="flex-1 border-t-3 px-4 py-3 font-semibold transition-colors hover:cursor-pointer {isActive 17 + ? 'border-(--nucleus-accent) bg-(--nucleus-accent)/20 text-(--nucleus-accent)' 18 + : 'border-(--nucleus-accent)/20 bg-transparent text-(--nucleus-fg)/60 hover:bg-(--nucleus-accent)/10'}" 19 + > 20 + {tab} 21 + </button> 22 + {/each} 23 + </div>