beatufitull front end for ozone modration ,, wit catpucoin and ebergarden !
ozone moderation
5
fork

Configure Feed

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

SEARCH

+396 -88
+14 -1
src/components/Header.svelte
··· 2 2 import { session } from '$lib/stores/auth'; 3 3 import { accentColours, inputAccentColor, type InputAccentColor } from '$lib/stores/ui'; 4 4 import { createQuery } from '@tanstack/svelte-query'; 5 - import { Cat, LogOut, MenuIcon, Paintbrush, TriangleAlert } from 'lucide-svelte'; 5 + import { Cat, LogOut, MenuIcon, Paintbrush, SearchIcon, TriangleAlert } from 'lucide-svelte'; 6 6 import Button from './ui/Button.svelte'; 7 7 import PopupMenu from './ui/PopupMenu.svelte'; 8 8 import { navItems } from '$lib/nav'; 9 9 import { page } from '$app/state'; 10 10 import { onMount } from 'svelte'; 11 11 import { ads } from '$lib/ads'; 12 + 13 + let { onSearch } = $props<{ 14 + onSearch: () => void; 15 + }>(); 12 16 13 17 async function handleLogout() { 14 18 if ($session) { ··· 52 56 <Cat class="text-ctp-subtext1 hover:text-ctp-text" /> 53 57 </a> 54 58 <div class="flex items-center gap-2"> 59 + <Button 60 + variant="ghost" 61 + size="icon" 62 + on:click={() => onSearch()} 63 + className="items-center size-10" 64 + aria-label="Menu" 65 + > 66 + <SearchIcon class="size-5" /> 67 + </Button> 55 68 <PopupMenu 56 69 wrapperClass="relative" 57 70 menuClass="absolute top-full right-0 z-50 w-48 rounded-md border border-ctp-surface1 bg-ctp-base p-1.5 shadow-lg"
+238
src/components/Search.svelte
··· 1 + <script lang="ts"> 2 + import { browser } from '$app/environment'; 3 + import { goto } from '$app/navigation'; 4 + import { page } from '$app/state'; 5 + import { AtpAgent, type AppBskyActorDefs } from '@atproto/api'; 6 + import { createQuery } from '@tanstack/svelte-query'; 7 + import { SearchIcon, TrashIcon } from 'lucide-svelte'; 8 + 9 + type SearchSuggestion = { 10 + handle: string; 11 + displayName?: string; 12 + avatar?: string; 13 + }; 14 + 15 + type HistoricalSearchSuggestion = { 16 + handle: string; 17 + did: string; 18 + }; 19 + 20 + const RECENT_SUGGESTIONS_KEY = 'meowzone-search-recent-suggestions'; 21 + const MAX_RECENT_SUGGESTIONS = 6; 22 + 23 + const { isOpen = false, onClose } = $props<{ 24 + isOpen?: boolean; 25 + onClose: () => void; 26 + }>(); 27 + let searchQuery = $state(''); 28 + let debouncedQuery = $state(''); 29 + let debounceTimer: ReturnType<typeof setTimeout> | null = null; 30 + let recentSuggestions = $state<HistoricalSearchSuggestion[]>([]); 31 + const defaultSuggestions = [ 32 + { 33 + did: 'did:plc:73gqgbnvpx5syidcponjrics', 34 + handle: 'coil-habdle.ebil.club' 35 + }, 36 + { 37 + handle: 'koi.rip', 38 + did: 'did:plc:b26ewgkrnx3yvsp2cdao3ntu' 39 + }, 40 + { 41 + handle: 'olaren.dev', 42 + did: 'did:plc:6if5m2yo6kroprmmency3gt5' 43 + } 44 + ] satisfies HistoricalSearchSuggestion[]; 45 + 46 + if (browser) { 47 + try { 48 + const saved = localStorage.getItem(RECENT_SUGGESTIONS_KEY); 49 + if (saved) { 50 + recentSuggestions = JSON.parse(saved) as HistoricalSearchSuggestion[]; 51 + } 52 + } catch (error) { 53 + console.error('Failed to read recent search suggestions:', error); 54 + } 55 + } 56 + 57 + $effect(() => { 58 + if (!isOpen) { 59 + if (debounceTimer) clearTimeout(debounceTimer); 60 + debouncedQuery = ''; 61 + return; 62 + } 63 + 64 + const trimmed = searchQuery.trim(); 65 + if (debounceTimer) clearTimeout(debounceTimer); 66 + 67 + debounceTimer = setTimeout(() => { 68 + debouncedQuery = trimmed; 69 + }, 250); 70 + 71 + return () => { 72 + if (debounceTimer) clearTimeout(debounceTimer); 73 + }; 74 + }); 75 + 76 + const searchSuggestions = createQuery(() => ({ 77 + queryKey: ['search-suggestions', debouncedQuery], 78 + enabled: isOpen && debouncedQuery.length >= 2, 79 + staleTime: 30_000, 80 + queryFn: async (): Promise<AppBskyActorDefs.ProfileView[]> => { 81 + const agent = new AtpAgent({ service: 'https://public.api.bsky.app' }); 82 + try { 83 + const res = await agent.searchActors({ 84 + term: debouncedQuery, 85 + limit: 5 86 + }); 87 + 88 + return res.data.actors; 89 + } catch (err) { 90 + console.error('Failed to fetch search suggestions:', err); 91 + return []; 92 + } 93 + } 94 + })); 95 + 96 + function handleBackdropClick() { 97 + onClose(); 98 + } 99 + 100 + function handleKeydown(e: KeyboardEvent) { 101 + if (e.key === 'Escape') { 102 + onClose(); 103 + } 104 + } 105 + 106 + function persistRecentSuggestions(nextSuggestions: HistoricalSearchSuggestion[]) { 107 + recentSuggestions = nextSuggestions; 108 + if (!browser) return; 109 + 110 + try { 111 + localStorage.setItem(RECENT_SUGGESTIONS_KEY, JSON.stringify(nextSuggestions)); 112 + } catch (error) { 113 + console.error('Failed to persist recent search suggestions:', error); 114 + } 115 + } 116 + 117 + function addRecentSuggestion(suggestion: HistoricalSearchSuggestion) { 118 + const nextSuggestions = [ 119 + suggestion, 120 + ...recentSuggestions.filter((item) => item.did !== suggestion.did) 121 + ].slice(0, MAX_RECENT_SUGGESTIONS); 122 + 123 + persistRecentSuggestions(nextSuggestions); 124 + } 125 + 126 + function buildUserViewUrl(did: string) { 127 + const url = new URL(page.url); 128 + url.searchParams.set('view', 'user'); 129 + url.searchParams.set('did', did); 130 + url.searchParams.delete('uri'); 131 + return `${url.pathname}${url.search}${url.hash}`; 132 + } 133 + 134 + async function handleSelectSuggestion(suggestion: HistoricalSearchSuggestion) { 135 + addRecentSuggestion(suggestion); 136 + onClose(); 137 + await goto(buildUserViewUrl(suggestion.did), { 138 + replaceState: true, 139 + keepFocus: true, 140 + noScroll: true 141 + }); 142 + } 143 + 144 + const buttonClass = 145 + 'block w-full cursor-pointer text-left text-sm text-ctp-text hover:bg-ctp-surface1'; 146 + </script> 147 + 148 + <svelte:window onkeydown={handleKeydown} /> 149 + 150 + {#if isOpen} 151 + <div 152 + class="fixed inset-0 z-40" 153 + onclick={handleBackdropClick} 154 + role="presentation" 155 + tabindex="-1" 156 + ></div> 157 + <div class="pointer-events-none fixed inset-0 z-50 flex items-start justify-center p-4 md:pt-15"> 158 + <div 159 + class="pointer-events-auto relative flex max-h-[calc(100vh-2rem)] w-full max-w-lg flex-col overflow-hidden rounded-lg border border-ctp-surface1 bg-ctp-surface0 shadow-lg" 160 + role="dialog" 161 + aria-modal="true" 162 + tabindex="0" 163 + > 164 + <div class="overflow-y-auto"> 165 + <div class="flex items-center gap-2 border-b border-ctp-surface1 px-4 py-2"> 166 + <SearchIcon class="text-ctp-text" size={15} /> 167 + <input 168 + type="text" 169 + placeholder="Search..." 170 + class="w-full text-ctp-text focus:outline-none" 171 + bind:value={searchQuery} 172 + /> 173 + </div> 174 + {#if debouncedQuery.length < 2} 175 + <div class="mt-2 space-y-1"> 176 + <div class="mb-2 flex items-center justify-between px-3"> 177 + <p class="text-xs text-ctp-subtext1"> 178 + {recentSuggestions.length > 0 ? 'Recent searches' : 'Examples'} 179 + </p> 180 + {#if recentSuggestions.length > 0} 181 + <button 182 + type="button" 183 + onclick={() => persistRecentSuggestions([])} 184 + class="flex cursor-pointer items-center gap-1 rounded text-xs text-ctp-subtext0 hover:text-ctp-red" 185 + > 186 + <TrashIcon size={16} /> clear 187 + </button> 188 + {/if} 189 + </div> 190 + {#each recentSuggestions.length > 0 ? recentSuggestions : defaultSuggestions as suggestion} 191 + <button 192 + type="button" 193 + onclick={() => handleSelectSuggestion(suggestion)} 194 + class={buttonClass} 195 + > 196 + <div class="px-3 py-2"> 197 + <p>@{suggestion.handle}</p> 198 + </div> 199 + </button> 200 + {/each} 201 + </div> 202 + {:else if searchSuggestions.isLoading} 203 + <p class="px-4 py-3 text-sm text-ctp-subtext1">loading suggestions...</p> 204 + {:else if searchSuggestions.isError} 205 + <p class="px-4 text-sm text-ctp-red">failed to load suggestions</p> 206 + {:else if searchSuggestions.data && searchSuggestions.data.length > 0} 207 + <div class="space-y-1"> 208 + {#each searchSuggestions.data as actor} 209 + <button 210 + type="button" 211 + onclick={() => 212 + handleSelectSuggestion({ 213 + handle: actor.handle, 214 + did: actor.did 215 + })} 216 + class={buttonClass} 217 + > 218 + <div class="flex items-center gap-2 px-3 py-0.5"> 219 + {#if actor.avatar} 220 + <img src={actor.avatar} alt={actor.handle} class="size-6 rounded-full" /> 221 + {/if} 222 + <div> 223 + {#if actor.displayName} 224 + <h3 class="font-medium text-ctp-text">{actor.displayName}</h3> 225 + {/if} 226 + <p class="text-ctp-subtext0">@{actor.handle}</p> 227 + </div> 228 + </div> 229 + </button> 230 + {/each} 231 + </div> 232 + {:else} 233 + <p class="px-4 text-sm text-ctp-subtext1">no suggestions found</p> 234 + {/if} 235 + </div> 236 + </div> 237 + </div> 238 + {/if}
+3 -2
src/components/ui/Button.svelte
··· 33 33 : variant === 'primary' 34 34 ? primaryHoverClass 35 35 : variant === 'surface' || variant === 'ghost' 36 - ? 'hover:bg-ctp-surface1' 36 + ? 'hover:bg-ctp-surface0' 37 37 : variant === 'danger' 38 38 ? 'hover:bg-ctp-red/10' 39 39 : 'hover:opacity-80'; ··· 42 42 size === 'sm' ? 'px-3 py-1 text-sm' : size === 'icon' ? 'p-1' : 'px-4 py-2 font-semibold'; 43 43 44 44 $: buttonClass = [ 45 - 'rounded-lg flex items-center justify-center gap-2', 45 + size == 'icon' ? 'rounded-md' : 'rounded-lg', 46 + 'flex items-center justify-center gap-2', 46 47 variantClass, 47 48 sizeClass, 48 49 fullWidth ? 'w-full' : '',
+8
src/components/view/Post.svelte
··· 11 11 import Actions from './Actions.svelte'; 12 12 import type { RecordViewDetail } from '@atproto/api/dist/client/types/tools/ozone/moderation/defs'; 13 13 import EventsContainer from './EventsContainer.svelte'; 14 + import Witchsky from '$lib/assets/witchsky.svelte'; 14 15 15 16 let { 16 17 isOpen = false, ··· 138 139 alt="Catsky" 139 140 class="size-6 opacity-30 transition-opacity hover:opacity-100" 140 141 /> 142 + </a> 143 + <a 144 + href={`https://witchsky.app/profile/${uri.host}/post/${uri.rkey}`} 145 + target="_blank" 146 + rel="noopener noreferrer" 147 + > 148 + <Witchsky className="size-6 fill-ctp-red/30 hover:fill-ctp-red transition-colors" /> 141 149 </a> 142 150 <a 143 151 href={`https://bsky.app/profile/${uri.host}/post/${uri.rkey}`}
+8
src/components/view/User.svelte
··· 10 10 import Events from '$components/view/Events.svelte'; 11 11 import Actions from '$components/view/Actions.svelte'; 12 12 import EventsContainer from './EventsContainer.svelte'; 13 + import Witchsky from '$lib/assets/witchsky.svelte'; 13 14 14 15 let { 15 16 isOpen = false, ··· 127 128 alt="Catsky" 128 129 class="size-6 opacity-30 transition-opacity hover:opacity-100" 129 130 /> 131 + </a> 132 + <a 133 + href={`https://witchsky.app/profile/${actor}`} 134 + target="_blank" 135 + rel="noopener noreferrer" 136 + > 137 + <Witchsky className="size-6 fill-ctp-red/30 hover:fill-ctp-red transition-colors" /> 130 138 </a> 131 139 <a href={`https://bsky.app/profile/${actor}`} target="_blank" rel="noopener noreferrer"> 132 140 <Bluesky className="size-6 fill-ctp-blue/30 hover:fill-ctp-blue transition-colors" />
+26
src/lib/assets/witchsky.svelte
··· 1 + <script> 2 + export let className = ''; 3 + </script> 4 + 5 + <svg 6 + width="100%" 7 + height="100%" 8 + viewBox="0 0 512 512" 9 + version="1.1" 10 + class={className} 11 + style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;" 12 + > 13 + <g> 14 + <clipPath id="_clip1"> 15 + <rect x="0" y="0" width="512" height="512" /> 16 + </clipPath> 17 + <g clip-path="url(#_clip1)"> 18 + <g transform="matrix(1.4564,0,0,1.4564,-116.844,-116.331)"> 19 + <path 20 + d="M339.304,116.583C334.517,111.718 327.102,110.538 321,113.382C320.109,113.798 316.629,115.436 311.407,118.09C305.447,121.12 297.184,125.491 287.922,130.886C269.676,141.513 246.442,156.741 229.573,174.071C206.776,197.493 187.606,234.56 174.465,264.204C167.769,279.309 162.406,293.032 158.715,302.981C157.782,305.498 156.954,307.776 156.237,309.779C137.305,312.551 121.384,316.222 109.413,320.764C102.481,323.394 95.923,326.664 90.815,330.916C85.77,335.115 80.228,341.972 80.228,351.506C80.229,359.522 84.358,365.538 88.284,369.375C92.182,373.186 97.074,376.096 101.934,378.395C111.734,383.029 124.895,386.781 139.877,389.777C170.105,395.821 211.154,399.445 256.005,399.445C300.856,399.445 341.905,395.821 372.132,389.777C387.115,386.781 400.275,383.029 410.076,378.395C414.935,376.096 419.828,373.186 423.726,369.375C427.59,365.598 431.652,359.71 431.778,351.88L431.781,351.506L431.778,351.061C431.609,341.759 426.165,335.049 421.196,330.913C416.087,326.659 409.528,323.389 402.596,320.76C390.121,316.029 373.357,312.243 353.368,309.434C352.752,308.437 352.072,307.33 351.343,306.119C347.744,300.145 342.932,291.754 338.083,282.101C328.075,262.178 319.114,239.197 318.41,221.294C317.787,205.462 323.248,184.345 329.704,165.853C332.833,156.89 336.015,149.039 338.414,143.436C339.61,140.64 340.606,138.418 341.293,136.914C341.636,136.163 341.901,135.592 342.076,135.22C342.164,135.034 342.228,134.897 342.269,134.812C342.289,134.77 342.303,134.74 342.311,134.724C342.313,134.719 342.315,134.715 342.316,134.713C345.235,128.639 344.031,121.386 339.304,116.583Z" 21 + style="fill-rule:nonzero;" 22 + /> 23 + </g> 24 + </g> 25 + </g> 26 + </svg>
+44 -1
src/routes/+layout.svelte
··· 13 13 import Sidebar from '$components/Sidebar.svelte'; 14 14 import Header from '$components/Header.svelte'; 15 15 import { page } from '$app/state'; 16 + import { goto } from '$app/navigation'; 17 + import { AtUri } from '@atproto/api'; 18 + import Search from '$components/Search.svelte'; 19 + import UserModal from '$components/view/User.svelte'; 20 + import PostModal from '$components/view/Post.svelte'; 16 21 17 22 let { children } = $props(); 18 23 let currentPath = $derived(page.url.pathname); ··· 88 93 () => moderationConfigQueryOptions($session?.agent), 89 94 () => queryClient 90 95 ); 96 + 97 + let searchOpen = $state(false); 98 + 99 + const modalView = $derived(page.url.searchParams.get('view')); 100 + const modalDid = $derived(page.url.searchParams.get('did')); 101 + const modalUri = $derived.by(() => { 102 + const uriValue = page.url.searchParams.get('uri'); 103 + if (!uriValue) return null; 104 + 105 + try { 106 + return new AtUri(uriValue); 107 + } catch { 108 + return null; 109 + } 110 + }); 111 + 112 + function clearModalFromUrl() { 113 + const url = new URL(page.url); 114 + url.searchParams.delete('view'); 115 + url.searchParams.delete('did'); 116 + url.searchParams.delete('uri'); 117 + 118 + void goto(`${url.pathname}${url.search}${url.hash}`, { 119 + replaceState: true, 120 + keepFocus: true, 121 + noScroll: true 122 + }); 123 + } 91 124 </script> 92 125 93 126 <svelte:head><link rel="icon" href={favicon} /></svelte:head> ··· 103 136 <LabelerSetup /> 104 137 {:else if !redirecting} 105 138 {#if currentPath !== '/login'} 139 + {#if modalView === 'user' && modalDid} 140 + {#key modalDid} 141 + <UserModal isOpen={true} did={modalDid} onClose={clearModalFromUrl} /> 142 + {/key} 143 + {:else if modalView === 'post' && modalUri} 144 + {#key modalUri.toString()} 145 + <PostModal isOpen={true} uri={modalUri} onClose={clearModalFromUrl} /> 146 + {/key} 147 + {/if} 148 + <Search isOpen={searchOpen} onClose={() => (searchOpen = false)} /> 106 149 <div class="w-full bg-ctp-base"> 107 150 <!-- <div class="hidden md:block"> 108 151 <Sidebar /> ··· 110 153 <div class="px-4 pt-4 md:hidden"> 111 154 <Header /> 112 155 </div> --> 113 - <Header /> 156 + <Header onSearch={() => (searchOpen = true)} /> 114 157 {@render children()} 115 158 </div> 116 159 {:else}
+26 -68
src/routes/+page.svelte
··· 11 11 } from 'lucide-svelte'; 12 12 import { createInfiniteQuery, createQuery, QueryClient } from '@tanstack/svelte-query'; 13 13 import { AtUri, ComAtprotoRepoStrongRef } from '@atproto/api'; 14 - import Header from '../components/Header.svelte'; 15 - import Modal from '../components/Modal.svelte'; 16 - import UserModal from '../components/view/User.svelte'; 17 - import Button from '../components/ui/Button.svelte'; 18 14 import type { Report } from '$lib/types'; 19 15 import { isRepoRef } from '@atproto/api/dist/client/types/com/atproto/admin/defs'; 20 16 import { formatDate } from '$lib/time'; 21 - import Post from '$components/view/Post.svelte'; 22 17 import { page } from '$app/state'; 23 - import Sidebar from '$components/Sidebar.svelte'; 24 - import Advertisement from '$components/Advertisement.svelte'; 25 - import { ads } from '$lib/ads'; 26 - import { browser } from '$app/environment'; 27 - import { onMount } from 'svelte'; 18 + import { goto } from '$app/navigation'; 28 19 import AdvertisementOverlay from '$components/AdvertisementOverlay.svelte'; 29 20 30 - let selectedDid = $state<string | null>(null); 31 - let selectedPostUri = $state<AtUri | null>(null); 21 + function buildUrlForView(view: 'user' | 'post' | null, value?: string) { 22 + const url = new URL(page.url); 23 + url.searchParams.delete('view'); 24 + url.searchParams.delete('did'); 25 + url.searchParams.delete('uri'); 32 26 33 - function openUser(did: string) { 34 - selectedPostUri = null; 35 - selectedDid = did; 36 - } 27 + if (view === 'user' && value) { 28 + url.searchParams.set('view', 'user'); 29 + url.searchParams.set('did', value); 30 + } 37 31 38 - function openPost(uri: string) { 39 - selectedDid = null; 40 - selectedPostUri = new AtUri(uri); 41 - } 42 - 43 - function syncViewToUrl() { 44 - if (!browser) return; 45 - 46 - const url = new URL(window.location.href); 47 - if (selectedDid) { 48 - url.searchParams.set('view', 'user'); 49 - url.searchParams.set('did', selectedDid); 50 - url.searchParams.delete('uri'); 51 - } else if (selectedPostUri) { 32 + if (view === 'post' && value) { 52 33 url.searchParams.set('view', 'post'); 53 - url.searchParams.set('uri', selectedPostUri.toString()); 54 - url.searchParams.delete('did'); 55 - } else { 56 - url.searchParams.delete('view'); 57 - url.searchParams.delete('did'); 58 - url.searchParams.delete('uri'); 34 + url.searchParams.set('uri', value); 59 35 } 60 36 61 - history.replaceState(history.state, '', url); 37 + return `${url.pathname}${url.search}${url.hash}`; 62 38 } 63 39 64 - onMount(() => { 65 - const view = page.url.searchParams.get('view'); 40 + function navigateView(view: 'user' | 'post' | null, value?: string) { 41 + void goto(buildUrlForView(view, value), { 42 + replaceState: true, 43 + keepFocus: true, 44 + noScroll: true 45 + }); 46 + } 66 47 67 - if (view === 'user') { 68 - const did = page.url.searchParams.get('did'); 69 - if (did) openUser(did); 70 - return; 71 - } 72 - 73 - if (view === 'post') { 74 - const uri = page.url.searchParams.get('uri'); 75 - if (uri) { 76 - try { 77 - openPost(uri); 78 - } catch (error) { 79 - console.error('Invalid post uri in URL:', error); 80 - } 81 - } 82 - } 83 - }); 48 + function openUser(did: string) { 49 + navigateView('user', did); 50 + } 84 51 85 - $effect(() => { 86 - syncViewToUrl(); 87 - }); 52 + function openPost(uri: string) { 53 + navigateView('post', uri); 54 + } 88 55 function getCollectionName(report: Report) { 89 56 try { 90 57 return report.subject.handle ? 'Post' : 'unknown'; ··· 146 113 </script> 147 114 148 115 <div class="min-h-screen w-full bg-ctp-base p-4"> 149 - {#if selectedDid} 150 - {#key selectedDid} 151 - <UserModal isOpen={true} did={selectedDid} onClose={() => (selectedDid = null)} /> 152 - {/key} 153 - {:else if selectedPostUri} 154 - {#key selectedPostUri} 155 - <Post isOpen={true} uri={selectedPostUri} onClose={() => (selectedPostUri = null)} /> 156 - {/key} 157 - {/if} 158 116 <AdvertisementOverlay /> 159 117 <div class="mx-auto max-w-2xl"> 160 118 {#if reportsQuery.isLoading}
+29 -16
src/routes/events/+page.svelte
··· 14 14 import type { Report } from '$lib/types'; 15 15 import { isRepoRef } from '@atproto/api/dist/client/types/com/atproto/admin/defs'; 16 16 import { formatDate } from '$lib/time'; 17 - import PostModal from '$components/view/Post.svelte'; 18 17 import { page } from '$app/state'; 19 - import Sidebar from '$components/Sidebar.svelte'; 20 - import UserModal from '$components/view/User.svelte'; 18 + import { goto } from '$app/navigation'; 21 19 import Events from '$components/view/Events.svelte'; 22 20 import AdvertisementOverlay from '$components/AdvertisementOverlay.svelte'; 23 21 24 - let selectedDid: string | null = null; 25 - let selectedPostUri: AtUri | null = null; 22 + function buildUrlForView(view: 'user' | 'post' | null, value?: string) { 23 + const url = new URL(page.url); 24 + url.searchParams.delete('view'); 25 + url.searchParams.delete('did'); 26 + url.searchParams.delete('uri'); 27 + 28 + if (view === 'user' && value) { 29 + url.searchParams.set('view', 'user'); 30 + url.searchParams.set('did', value); 31 + } 32 + 33 + if (view === 'post' && value) { 34 + url.searchParams.set('view', 'post'); 35 + url.searchParams.set('uri', value); 36 + } 37 + 38 + return `${url.pathname}${url.search}${url.hash}`; 39 + } 40 + 41 + function navigateView(view: 'user' | 'post' | null, value?: string) { 42 + void goto(buildUrlForView(view, value), { 43 + replaceState: true, 44 + keepFocus: true, 45 + noScroll: true 46 + }); 47 + } 26 48 27 49 // switch to infinite query so we can page with the cursor returned by the API 28 50 const eventsQuery = createInfiniteQuery(() => ({ ··· 76 98 </script> 77 99 78 100 <div class="min-h-screen w-full bg-ctp-base p-4"> 79 - {#if selectedDid} 80 - {#key selectedDid} 81 - <UserModal isOpen={true} did={selectedDid} onClose={() => (selectedDid = null)} /> 82 - {/key} 83 - {:else if selectedPostUri} 84 - {#key selectedPostUri} 85 - <PostModal isOpen={true} uri={selectedPostUri} onClose={() => (selectedPostUri = null)} /> 86 - {/key} 87 - {/if} 88 101 <AdvertisementOverlay /> 89 102 <div class="mx-auto max-w-2xl"> 90 103 <div class="flex items-center gap-2 pb-5 text-ctp-subtext0"> ··· 94 107 <Events 95 108 {eventsQuery} 96 109 elevation="lower" 97 - onUserClick={(did) => (selectedDid = did)} 98 - onPostClick={(uri) => (selectedPostUri = uri)} 110 + onUserClick={(did) => navigateView('user', did)} 111 + onPostClick={(uri) => navigateView('post', uri.toString())} 99 112 showInfo={true} 100 113 /> 101 114 <div use:infiniteScroll>