wip bsky client for the web & android
0
fork

Configure Feed

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

feat: wip feed view & items

willow dabdb11a 5113202e

+626 -78
+164
src/components/Feed/FeedItem.vue
··· 1 + <script setup lang="ts"> 2 + import { AppBskyFeedDefs } from '@atcute/bluesky' 3 + import { IconRefreshRounded } from '@iconify-prerendered/vue-material-symbols' 4 + 5 + defineProps<{ 6 + item: AppBskyFeedDefs.FeedViewPost 7 + }>() 8 + 9 + const formatTime = (dateString: string) => { 10 + const date = new Date(dateString) 11 + const now = new Date() 12 + const diff = (now.getTime() - date.getTime()) / 1000 13 + 14 + if (diff < 60) return 'now' 15 + if (diff < 3600) return `${Math.floor(diff / 60)}m` 16 + if (diff < 86400) return `${Math.floor(diff / 3600)}h` 17 + return `${Math.floor(diff / 86400)}d` 18 + } 19 + </script> 20 + 21 + <template> 22 + <article :key="item.post.uri" class="feed-item" @mousedown.middle="console.log(item)"> 23 + <div v-if="item.reason?.$type === 'app.bsky.feed.defs#reasonRepost'" class="repost-indicator"> 24 + <IconRefreshRounded class="repost-icon" /> 25 + <span>Reposted by {{ item.reason.by.displayName || item.reason.by.handle }}</span> 26 + </div> 27 + 28 + <div class="post-layout"> 29 + <div class="post-avatar"> 30 + <img 31 + v-if="item.post.author.avatar" 32 + :src="item.post.author.avatar" 33 + alt="avatar" 34 + loading="lazy" 35 + /> 36 + <div v-else class="avatar-fallback"></div> 37 + </div> 38 + 39 + <div class="post-content"> 40 + <div class="post-header"> 41 + <span class="display-name">{{ 42 + item.post.author.displayName || item.post.author.handle 43 + }}</span> 44 + <span class="handle">@{{ item.post.author.handle }}</span> 45 + <span class="dot" aria-hidden="true">•</span> 46 + <span class="time">{{ formatTime(item.post.indexedAt) }}</span> 47 + </div> 48 + 49 + <div class="post-text" v-if="item.post.record.text"> 50 + {{ item.post.record.text }} 51 + </div> 52 + </div> 53 + </div> 54 + </article> 55 + </template> 56 + 57 + <style lang="scss" scoped> 58 + .feed-item { 59 + padding: 1rem; 60 + border-bottom: 1px solid hsla(var(--surface2) / 0.3); 61 + display: flex; 62 + flex-direction: column; 63 + gap: 0.5rem; 64 + 65 + &:hover { 66 + background-color: hsla(var(--surface0) / 0.3); 67 + cursor: pointer; 68 + } 69 + 70 + .repost-indicator { 71 + display: flex; 72 + align-items: center; 73 + gap: 0.5rem; 74 + font-size: 0.8rem; 75 + color: hsl(var(--subtext0)); 76 + font-weight: 600; 77 + margin-left: 2.5rem; 78 + margin-bottom: -0.25rem; 79 + 80 + .repost-icon { 81 + font-size: 1rem; 82 + color: hsl(var(--green)); 83 + } 84 + } 85 + 86 + .post-layout { 87 + display: flex; 88 + gap: 0.75rem; 89 + 90 + .post-avatar { 91 + flex-shrink: 0; 92 + width: 2.5rem; 93 + height: 2.5rem; 94 + 95 + img { 96 + width: 100%; 97 + height: 100%; 98 + border-radius: 50%; 99 + object-fit: cover; 100 + background-color: hsl(var(--surface1)); 101 + } 102 + 103 + .avatar-fallback { 104 + width: 100%; 105 + height: 100%; 106 + border-radius: 50%; 107 + background-color: hsl(var(--surface2)); 108 + } 109 + } 110 + 111 + .post-content { 112 + flex: 1; 113 + min-width: 0; 114 + display: flex; 115 + flex-direction: column; 116 + gap: 0.25rem; 117 + 118 + .post-header { 119 + display: flex; 120 + align-items: baseline; 121 + gap: 0.35rem; 122 + font-size: 0.95rem; 123 + line-height: 1.3; 124 + 125 + * { 126 + min-width: 0; 127 + text-wrap: nowrap; 128 + text-overflow: ellipsis; 129 + overflow: hidden; 130 + } 131 + 132 + .display-name { 133 + font-weight: 700; 134 + color: hsl(var(--text)); 135 + text-emphasis: none; 136 + } 137 + 138 + .handle { 139 + color: hsl(var(--subtext0)); 140 + font-weight: 500; 141 + } 142 + 143 + .dot { 144 + user-select: none; 145 + color: hsl(var(--surface2)); 146 + } 147 + 148 + .time { 149 + color: hsl(var(--subtext0)); 150 + font-size: 0.85rem; 151 + } 152 + } 153 + 154 + .post-text { 155 + color: hsl(var(--text)); 156 + font-size: 1rem; 157 + line-height: 1.5; 158 + white-space: pre-wrap; 159 + word-wrap: break-word; 160 + } 161 + } 162 + } 163 + } 164 + </style>
+374
src/components/Feed/FeedList.vue
··· 1 + <script setup lang="ts"> 2 + import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue' 3 + import { IconRefreshRounded } from '@iconify-prerendered/vue-material-symbols' 4 + import { AppBskyFeedDefs } from '@atcute/bluesky' 5 + import type { ResourceUri } from '@atcute/lexicons' 6 + import { ok } from '@atcute/client' 7 + 8 + import { useNavigationStore } from '@/stores/navigation' 9 + import { useAuthStore } from '@/stores/auth' 10 + import Button from '@/components/UI/BaseButton.vue' 11 + import SkeletonLoader from '@/components/UI/SkeletonLoader.vue' 12 + import FeedItem from './FeedItem.vue' 13 + 14 + const props = defineProps<{ 15 + type: 'timeline' | 'userFeed' | 'listFeed' 16 + /** the AT URI of the feed or list; omit for timeline */ 17 + uri?: ResourceUri | null 18 + }>() 19 + 20 + const auth = useAuthStore() 21 + const nav = useNavigationStore() 22 + 23 + const feed = ref<AppBskyFeedDefs.FeedViewPost[]>([]) 24 + const cursor = ref<string | undefined>(undefined) 25 + 26 + const loading = ref(false) 27 + const error = ref<string | null>(null) 28 + 29 + const loadOnScroll = ref(false) 30 + const loadMoreTrigger = ref<HTMLElement | null>(null) 31 + let observer: IntersectionObserver | null = null 32 + 33 + const fetchTimeline = async () => { 34 + if (!auth.isAuthenticated) return 35 + if (loading.value) return 36 + 37 + if (!props.uri && props.type !== 'timeline') { 38 + error.value = 'No feed URI provided' 39 + return 40 + } 41 + 42 + loading.value = true 43 + error.value = null 44 + 45 + try { 46 + const rpc = auth.getRpc() 47 + const route = 48 + props.type === 'timeline' 49 + ? 'app.bsky.feed.getTimeline' 50 + : props.type === 'userFeed' 51 + ? 'app.bsky.feed.getFeed' 52 + : 'app.bsky.feed.getListFeed' 53 + 54 + const data = ok( 55 + await rpc.get(route, { 56 + params: { 57 + feed: props.uri, 58 + limit: 50, 59 + cursor: cursor.value || undefined, 60 + }, 61 + }), 62 + ) 63 + 64 + if (!cursor.value) feed.value = data.feed 65 + else feed.value.push(...data.feed) 66 + cursor.value = data.cursor 67 + } catch (err) { 68 + console.error('failed to fetch feed', err) 69 + if (err instanceof Error) error.value = err.message || 'Could not load feed' 70 + else error.value = 'Could not load feed' 71 + } finally { 72 + loading.value = false 73 + } 74 + } 75 + 76 + const setupObserver = () => { 77 + if (observer) observer.disconnect() 78 + if (!loadOnScroll.value) return 79 + if (!loadMoreTrigger.value) return 80 + 81 + const scrollContainer = loadMoreTrigger.value.closest('.page-content') 82 + 83 + observer = new IntersectionObserver( 84 + (entries) => { 85 + const target = entries[0] 86 + if (target?.isIntersecting && !loading.value && cursor.value) fetchTimeline() 87 + }, 88 + { 89 + root: scrollContainer, 90 + rootMargin: '0px 0px 1500px 0px', 91 + threshold: 0, 92 + }, 93 + ) 94 + 95 + observer.observe(loadMoreTrigger.value) 96 + } 97 + 98 + onMounted(() => { 99 + if (auth.isAuthenticated) { 100 + fetchTimeline().then(async () => { 101 + await nextTick() 102 + setupObserver() 103 + }) 104 + } 105 + }) 106 + 107 + onUnmounted(() => { 108 + if (observer) observer.disconnect() 109 + }) 110 + 111 + watch( 112 + () => auth.isAuthenticated, 113 + (isAuth) => { 114 + if (isAuth) fetchTimeline() 115 + }, 116 + ) 117 + 118 + watch(loadMoreTrigger, (el) => { 119 + if (el && auth.isAuthenticated) setupObserver() 120 + }) 121 + watch(loadOnScroll, () => { 122 + setupObserver() 123 + }) 124 + </script> 125 + 126 + <template> 127 + <div class="feed-container"> 128 + <div v-if="!auth.isAuthenticated" class="state-message"> 129 + <p>Please sign in to view your timeline.</p> 130 + <Button variant="primary" @click="nav.push('login')">Sign In</Button> 131 + </div> 132 + 133 + <div v-else-if="error" class="state-message"> 134 + <p class="error-text">{{ error }}</p> 135 + <Button @click="fetchTimeline" variant="secondary"> <IconRefreshRounded /> Retry </Button> 136 + </div> 137 + 138 + <div v-else-if="loading && feed.length === 0" class="loading-stack"> 139 + <div v-for="i in 15" :key="i" class="feed-item skeleton-item"> 140 + <div class="post-header"> 141 + <SkeletonLoader circle width="2.5rem" height="2.5rem" /> 142 + <div class="header-text"> 143 + <SkeletonLoader width="30%" height="1rem" /> 144 + <SkeletonLoader width="20%" height="0.8rem" /> 145 + </div> 146 + </div> 147 + <div class="post-body"> 148 + <SkeletonLoader width="90%" height="1rem" /> 149 + <SkeletonLoader width="60%" height="1rem" /> 150 + </div> 151 + </div> 152 + </div> 153 + 154 + <div v-else class="feed-list"> 155 + <FeedItem v-for="item in feed" :key="item.post.uri" :item="item" /> 156 + 157 + <div class="feed-end" ref="loadMoreTrigger"> 158 + <div v-if="loading" class="spinner-container"> 159 + <div class="spinner"></div> 160 + </div> 161 + <template v-else-if="cursor"> 162 + <Button variant="subtle-alt" @click="fetchTimeline"> Load More </Button> 163 + <p> 164 + You've scrolled past <span class="post-count">{{ feed.length }} posts</span> so far. 165 + </p> 166 + </template> 167 + <p v-else class="end-text">You've reached the end!</p> 168 + </div> 169 + </div> 170 + </div> 171 + </template> 172 + 173 + <style scoped lang="scss"> 174 + .feed-container { 175 + width: 100%; 176 + min-height: 100%; 177 + padding-bottom: 4rem; 178 + } 179 + 180 + .state-message { 181 + display: flex; 182 + flex-direction: column; 183 + align-items: center; 184 + justify-content: center; 185 + padding: 4rem 1rem; 186 + gap: 1rem; 187 + text-align: center; 188 + color: hsl(var(--subtext0)); 189 + 190 + .error-text { 191 + color: hsl(var(--red)); 192 + } 193 + } 194 + 195 + .loading-stack { 196 + display: flex; 197 + flex-direction: column; 198 + 199 + .skeleton-item { 200 + padding: 1rem; 201 + display: flex; 202 + flex-direction: column; 203 + gap: 1rem; 204 + 205 + .post-header { 206 + display: flex; 207 + gap: 1rem; 208 + align-items: center; 209 + 210 + .header-text { 211 + flex: 1; 212 + display: flex; 213 + flex-direction: column; 214 + gap: 0.5rem; 215 + } 216 + .post-body { 217 + display: flex; 218 + flex-direction: column; 219 + gap: 0.5rem; 220 + padding-left: 3.5rem; 221 + } 222 + } 223 + } 224 + } 225 + 226 + .feed-item { 227 + padding: 1rem; 228 + border-bottom: 1px solid hsla(var(--surface2) / 0.3); 229 + display: flex; 230 + flex-direction: column; 231 + gap: 0.5rem; 232 + 233 + &:hover { 234 + background-color: hsla(var(--surface0) / 0.3); 235 + cursor: pointer; 236 + } 237 + 238 + .repost-indicator { 239 + display: flex; 240 + align-items: center; 241 + gap: 0.5rem; 242 + font-size: 0.8rem; 243 + color: hsl(var(--subtext0)); 244 + font-weight: 600; 245 + margin-left: 2.5rem; 246 + margin-bottom: -0.25rem; 247 + 248 + .repost-icon { 249 + font-size: 1rem; 250 + color: hsl(var(--green)); 251 + } 252 + } 253 + 254 + .post-layout { 255 + display: flex; 256 + gap: 0.75rem; 257 + 258 + .post-avatar { 259 + flex-shrink: 0; 260 + width: 2.5rem; 261 + height: 2.5rem; 262 + 263 + img { 264 + width: 100%; 265 + height: 100%; 266 + border-radius: 50%; 267 + object-fit: cover; 268 + background-color: hsl(var(--surface1)); 269 + } 270 + 271 + .avatar-fallback { 272 + width: 100%; 273 + height: 100%; 274 + border-radius: 50%; 275 + background-color: hsl(var(--surface2)); 276 + } 277 + } 278 + 279 + .post-content { 280 + flex: 1; 281 + min-width: 0; 282 + display: flex; 283 + flex-direction: column; 284 + gap: 0.25rem; 285 + 286 + .post-header { 287 + display: flex; 288 + align-items: baseline; 289 + gap: 0.35rem; 290 + font-size: 0.95rem; 291 + line-height: 1.3; 292 + 293 + * { 294 + min-width: 0; 295 + text-wrap: nowrap; 296 + text-overflow: ellipsis; 297 + overflow: hidden; 298 + } 299 + 300 + .display-name { 301 + font-weight: 700; 302 + color: hsl(var(--text)); 303 + text-emphasis: none; 304 + } 305 + 306 + .handle { 307 + color: hsl(var(--subtext0)); 308 + font-weight: 500; 309 + } 310 + 311 + .dot { 312 + user-select: none; 313 + color: hsl(var(--surface2)); 314 + } 315 + 316 + .time { 317 + color: hsl(var(--subtext0)); 318 + font-size: 0.85rem; 319 + } 320 + } 321 + 322 + .post-text { 323 + color: hsl(var(--text)); 324 + font-size: 1rem; 325 + line-height: 1.5; 326 + white-space: pre-wrap; 327 + word-wrap: break-word; 328 + } 329 + } 330 + } 331 + } 332 + 333 + .feed-end { 334 + display: flex; 335 + flex-direction: column; 336 + align-items: center; 337 + justify-content: center; 338 + 339 + gap: 0.5rem; 340 + color: hsl(var(--subtext0)); 341 + padding: 2rem; 342 + min-height: 100px; 343 + 344 + .post-count { 345 + font-weight: 600; 346 + color: hsl(var(--text)); 347 + } 348 + 349 + .end-text { 350 + font-size: 0.9rem; 351 + } 352 + 353 + .spinner-container { 354 + display: flex; 355 + justify-content: center; 356 + padding: 1rem; 357 + 358 + .spinner { 359 + width: 1.5rem; 360 + height: 1.5rem; 361 + border: 2px solid hsl(var(--subtext0)); 362 + border-right-color: transparent; 363 + border-radius: 50%; 364 + animation: spin 0.75s linear infinite; 365 + } 366 + } 367 + } 368 + 369 + @keyframes spin { 370 + to { 371 + transform: rotate(360deg); 372 + } 373 + } 374 + </style>
+36 -35
src/components/Navigation/AppLink.vue
··· 5 5 import { getPageByName, compileUrl } from '@/router' 6 6 7 7 const props = defineProps<{ 8 - name: PageNames 9 - params?: Record<string, string> 10 - ariaLabel?: string 8 + name: PageNames 9 + params?: Record<string, string> 10 + ariaLabel?: string 11 11 }>() 12 12 13 13 const nav = useNavigationStore() 14 14 15 15 const href = computed(() => { 16 - const page = getPageByName(props.name) 17 - if (!page) return '#' 16 + const page = getPageByName(props.name) 17 + if (!page) return '#' 18 18 19 - const { url, remainingProps } = compileUrl(page.path, props.params || {}) 19 + const { url, remainingProps } = compileUrl(page.path, props.params || {}) 20 20 21 - let path = url 22 - if (Object.keys(remainingProps).length > 0) { 23 - const searchParams = new URLSearchParams() 24 - Object.entries(remainingProps).forEach(([key, value]) => { 25 - if (typeof value === 'object') { 26 - searchParams.set(key, JSON.stringify(value)) 27 - } else { 28 - searchParams.set(key, String(value)) 29 - } 30 - }) 31 - path += `?${searchParams.toString()}` 32 - } 21 + let path = url 22 + if (Object.keys(remainingProps).length > 0) { 23 + const searchParams = new URLSearchParams() 24 + Object.entries(remainingProps).forEach(([key, value]) => { 25 + if (typeof value === 'object') { 26 + searchParams.set(key, JSON.stringify(value)) 27 + } else { 28 + searchParams.set(key, String(value)) 29 + } 30 + }) 31 + path += `?${searchParams.toString()}` 32 + } 33 33 34 - return path 34 + return path 35 35 }) 36 36 37 37 const handleClick = (e: MouseEvent) => { 38 - if (e.metaKey || e.ctrlKey) return 39 - e.preventDefault() 40 - nav.push(props.name, { 41 - props: props.params, 42 - }) 38 + if (e.metaKey || e.ctrlKey) return 39 + e.preventDefault() 40 + console.log('Navigating to', props.name, props.params) 41 + nav.push(props.name, { 42 + props: props.params, 43 + }) 43 44 } 44 45 </script> 45 46 46 47 <template> 47 - <a class="app-link" :href="href" @click="handleClick" :aria-label="ariaLabel"> 48 - <slot /> 49 - </a> 48 + <a class="app-link" :href="href" @click="handleClick" :aria-label="ariaLabel"> 49 + <slot /> 50 + </a> 50 51 </template> 51 52 52 53 <style scoped> 53 54 .app-link { 54 - text-decoration: none; 55 - color: inherit; 56 - cursor: pointer; 57 - background: none; 58 - border: none; 59 - padding: 0; 60 - font: inherit; 61 - display: inline-block; 55 + text-decoration: none; 56 + color: inherit; 57 + cursor: pointer; 58 + background: none; 59 + border: none; 60 + padding: 0; 61 + font: inherit; 62 + display: inline-block; 62 63 } 63 64 </style>
+43 -34
src/components/Navigation/PageLayout.vue
··· 1 1 <script setup lang="ts"> 2 - import { nextTick, ref, onMounted } from "vue"; 3 - import { useScrollHide } from "@/composables/useScrollHide"; 2 + import { nextTick, ref, onMounted } from 'vue' 3 + import { useScrollHide } from '@/composables/useScrollHide' 4 4 5 - import AppBar from "./AppBar.vue"; 6 - import NavigationBar from "./NavigationBar.vue"; 5 + import AppBar from './AppBar.vue' 6 + import NavigationBar from './NavigationBar.vue' 7 7 8 8 const props = defineProps<{ 9 - title: string; 10 - }>(); 9 + title: string 10 + noPadding?: boolean 11 + }>() 11 12 12 13 const key = 13 - Math.random().toString(36).substring(2, 15) + 14 - Math.random().toString(36).substring(2, 15); 14 + Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15) 15 15 16 - const pageContent = ref<HTMLElement | null>(null); 17 - const appBar = ref<InstanceType<typeof AppBar> | null>(null); 18 - const navBar = ref<InstanceType<typeof NavigationBar> | null>(null); 16 + const pageContent = ref<HTMLElement | null>(null) 17 + const appBar = ref<InstanceType<typeof AppBar> | null>(null) 18 + const navBar = ref<InstanceType<typeof NavigationBar> | null>(null) 19 19 20 20 const announcePageChange = () => { 21 - const announcement = document.createElement("div"); 22 - announcement.setAttribute("aria-live", "polite"); 23 - announcement.setAttribute("aria-atomic", "true"); 24 - announcement.className = "sr-only"; 25 - announcement.textContent = `Navigated to ${props.title}`; 26 - document.body.appendChild(announcement); 21 + const announcement = document.createElement('div') 22 + announcement.setAttribute('aria-live', 'polite') 23 + announcement.setAttribute('aria-atomic', 'true') 24 + announcement.className = 'sr-only' 25 + announcement.textContent = `Navigated to ${props.title}` 26 + document.body.appendChild(announcement) 27 27 28 28 setTimeout(() => { 29 - document.body.removeChild(announcement); 30 - }, 1000); 31 - }; 29 + document.body.removeChild(announcement) 30 + }, 1000) 31 + } 32 32 33 33 onMounted(async () => { 34 - await nextTick(); 35 - if (!pageContent.value) return; 34 + await nextTick() 35 + if (!pageContent.value) return 36 36 37 37 const scrollHide = useScrollHide({ 38 38 scrollContainer: pageContent.value, 39 39 appBarEl: appBar.value?.$el, 40 40 navBarEl: navBar.value?.$el, 41 - }); 41 + }) 42 42 43 - scrollHide.measureElements(); 44 - scrollHide.attachScrollListener(); 43 + scrollHide.measureElements() 44 + scrollHide.attachScrollListener() 45 45 46 - const skipToContent = document.querySelector("#skip-to-content"); 46 + const skipToContent = document.querySelector('#skip-to-content') 47 47 if (document.activeElement === skipToContent) { 48 - pageContent.value.focus(); 48 + pageContent.value.focus() 49 49 } else { 50 - pageContent.value.setAttribute("tabindex", "-1"); 51 - pageContent.value.focus(); 50 + pageContent.value.setAttribute('tabindex', '-1') 51 + pageContent.value.focus() 52 52 } 53 53 54 - announcePageChange(); 55 - }); 54 + announcePageChange() 55 + }) 56 56 </script> 57 57 58 58 <template> 59 - <div class="page-layout" :id="key"> 60 - <AppBar :title="title" ref="appBar"/> 59 + <div class="page-layout" :id="key" :class="{ 'no-padding': noPadding }"> 60 + <AppBar :title="title" ref="appBar" /> 61 61 <main class="page-content" ref="pageContent"> 62 62 <div class="content-container"> 63 - <slot/> 63 + <slot /> 64 64 </div> 65 65 </main> 66 66 </div> ··· 134 134 clip: rect(0, 0, 0, 0); 135 135 white-space: nowrap; 136 136 border: 0; 137 + } 138 + 139 + .no-padding { 140 + .page-content { 141 + padding-top: calc(var(--safe-area-inset-top, 3.5rem)); 142 + } 143 + .content-container { 144 + padding: 0; 145 + } 137 146 } 138 147 </style>
+5 -5
src/views/Root/HomeView.vue
··· 1 1 <script lang="ts" setup> 2 - import PageLayout from "@/components/Navigation/PageLayout.vue"; 3 - import AppLink from "@/components/Navigation/AppLink.vue"; 2 + import PageLayout from '@/components/Navigation/PageLayout.vue' 3 + 4 + import FeedList from '@/components/Feed/FeedList.vue' 4 5 </script> 5 6 6 7 <template> 7 - <PageLayout title="Home"> 8 - <h1>Home</h1> 9 - <AppLink name="subpage">Go to SubPage</AppLink> 8 + <PageLayout no-padding title="Home"> 9 + <FeedList type="timeline" /> 10 10 </PageLayout> 11 11 </template>
+4 -4
src/views/Root/SearchView.vue
··· 1 1 <script lang="ts" setup> 2 - import PageLayout from "@/components/Navigation/PageLayout.vue"; 3 - import AppLink from "@/components/Navigation/AppLink.vue"; 2 + import PageLayout from '@/components/Navigation/PageLayout.vue' 3 + import AppLink from '@/components/Navigation/AppLink.vue' 4 4 </script> 5 5 6 6 <template> 7 7 <PageLayout title="Search"> 8 8 <h1>search placeholder!</h1> 9 9 10 - <div style="height: 1500px;">meow meow meow</div> 10 + <div style="height: 1500px">meow meow meow</div> 11 11 12 12 <AppLink name="home">Go to SubPage</AppLink> 13 - <AppLink name="search" :params="{ userId: 123, mode: 'edit' }"> 13 + <AppLink name="subpage" :params="{ userId: 123, mode: 'edit' }"> 14 14 Go to SubPage with params 15 15 </AppLink> 16 16 </PageLayout>