bluesky client without react native baggage written in sveltekit
0
fork

Configure Feed

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

i literally dont remember everything

ansxor.ca 5bd38bea d95c7810

verified
+619 -565
+10 -13
.storybook/main.ts
··· 1 1 import type { StorybookConfig } from '@storybook/sveltekit'; 2 2 3 3 const config: StorybookConfig = { 4 - "stories": [ 5 - "../src/**/*.mdx", 6 - "../src/**/*.stories.@(js|ts|svelte)" 7 - ], 8 - "addons": [ 9 - "@storybook/addon-svelte-csf", 10 - "@chromatic-com/storybook", 11 - "@storybook/addon-vitest", 12 - "@storybook/addon-a11y", 13 - "@storybook/addon-docs" 14 - ], 15 - "framework": "@storybook/sveltekit" 4 + stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|ts|svelte)'], 5 + addons: [ 6 + '@storybook/addon-svelte-csf', 7 + '@chromatic-com/storybook', 8 + '@storybook/addon-vitest', 9 + '@storybook/addon-a11y', 10 + '@storybook/addon-docs' 11 + ], 12 + framework: '@storybook/sveltekit' 16 13 }; 17 - export default config; 14 + export default config;
+17 -17
.storybook/preview.ts
··· 1 - import type { Preview } from '@storybook/sveltekit' 2 - import '../src/routes/layout.css' 1 + import type { Preview } from '@storybook/sveltekit'; 2 + import '../src/routes/layout.css'; 3 3 4 4 const preview: Preview = { 5 - parameters: { 6 - controls: { 7 - matchers: { 8 - color: /(background|color)$/i, 9 - date: /Date$/i, 10 - }, 11 - }, 5 + parameters: { 6 + controls: { 7 + matchers: { 8 + color: /(background|color)$/i, 9 + date: /Date$/i 10 + } 11 + }, 12 12 13 - a11y: { 14 - // 'todo' - show a11y violations in the test UI only 15 - // 'error' - fail CI on a11y violations 16 - // 'off' - skip a11y checks entirely 17 - test: 'todo' 18 - } 19 - }, 13 + a11y: { 14 + // 'todo' - show a11y violations in the test UI only 15 + // 'error' - fail CI on a11y violations 16 + // 'off' - skip a11y checks entirely 17 + test: 'todo' 18 + } 19 + } 20 20 }; 21 21 22 - export default preview; 22 + export default preview;
+2 -2
.storybook/vitest.setup.ts
··· 1 - import * as a11yAddonAnnotations from "@storybook/addon-a11y/preview"; 1 + import * as a11yAddonAnnotations from '@storybook/addon-a11y/preview'; 2 2 import { setProjectAnnotations } from '@storybook/sveltekit'; 3 3 import * as projectAnnotations from './preview'; 4 4 5 5 // This is an important step to apply the right configuration when testing your stories. 6 6 // More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations 7 - setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]); 7 + setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]);
+1
package.json
··· 64 64 "@atcute/bluesky-richtext-segmenter": "^3.0.0", 65 65 "@atcute/client": "^4.2.1", 66 66 "@atcute/identity-resolver": "^1.2.2", 67 + "@atcute/lexicons": "^1.2.9", 67 68 "@atcute/oauth-browser-client": "^3.0.0", 68 69 "@atcute/tid": "^1.1.2" 69 70 }
+3
pnpm-lock.yaml
··· 26 26 '@atcute/identity-resolver': 27 27 specifier: ^1.2.2 28 28 version: 1.2.2(@atcute/identity@1.1.3) 29 + '@atcute/lexicons': 30 + specifier: ^1.2.9 31 + version: 1.2.9 29 32 '@atcute/oauth-browser-client': 30 33 specifier: ^3.0.0 31 34 version: 3.0.0(@atcute/identity@1.1.3)
+5 -3
src/lib/atproto.ts
··· 91 91 /** Fetch the logged-in user's profile */ 92 92 export async function getProfile(): Promise<AppBskyActorDefs.ProfileViewDetailed | null> { 93 93 if (!rpc || !currentSession) return null; 94 - const data = await ok(rpc.get('app.bsky.actor.getProfile', { 95 - params: { actor: currentSession.info.sub } 96 - })); 94 + const data = await ok( 95 + rpc.get('app.bsky.actor.getProfile', { 96 + params: { actor: currentSession.info.sub } 97 + }) 98 + ); 97 99 return data; 98 100 } 99 101
+9 -4
src/lib/components/Avatar.svelte
··· 1 1 <script lang="ts"> 2 - import { AppBskyActorDefs } from '@atcute/bluesky'; 3 - 4 - let { user }: { user: AppBskyActorDefs.ProfileViewDetailed } = $props() 2 + import { AppBskyActorDefs } from '@atcute/bluesky'; 3 + 4 + let { user }: { user: AppBskyActorDefs.ProfileViewDetailed } = $props(); 5 5 </script> 6 - <img class="w-10.5 h-10.5 block rounded-lg" src={user.avatar} alt={`${user.displayName || user.handle}'s avatar'`}> 6 + 7 + <img 8 + class="block h-10.5 w-10.5 rounded-lg" 9 + src={user.avatar} 10 + alt={`${user.displayName || user.handle}'s avatar'`} 11 + />
+46 -34
src/lib/components/Post.stories.svelte
··· 1 1 <script module> 2 - import { defineMeta } from '@storybook/addon-svelte-csf'; 3 - import Post from './Post.svelte'; 2 + import { defineMeta } from '@storybook/addon-svelte-csf'; 3 + import Post from './Post.svelte'; 4 4 5 - const basePost = { 6 - uri: 'at://did:plc:example/app.bsky.feed.post/123', 7 - cid: 'bafyreiexample', 8 - author: { 9 - did: 'did:plc:example', 10 - handle: 'user.bsky.social', 11 - displayName: 'Example User', 12 - avatar: 'https://picsum.photos/seed/avatar/48/48', 13 - }, 14 - record: { 15 - $type: 'app.bsky.feed.post', 16 - text: 'Hello world! This is an example Bluesky post.', 17 - createdAt: '2024-01-01T00:00:00Z', 18 - }, 19 - replyCount: 3, 20 - repostCount: 12, 21 - likeCount: 42, 22 - indexedAt: '2024-01-01T00:00:00Z', 23 - viewer: {}, 24 - }; 5 + const basePost = { 6 + uri: 'at://did:plc:example/app.bsky.feed.post/123', 7 + cid: 'bafyreiexample', 8 + author: { 9 + did: 'did:plc:example', 10 + handle: 'user.bsky.social', 11 + displayName: 'Example User', 12 + avatar: 'https://picsum.photos/seed/avatar/48/48' 13 + }, 14 + record: { 15 + $type: 'app.bsky.feed.post', 16 + text: 'Hello world! This is an example Bluesky post.', 17 + createdAt: '2024-01-01T00:00:00Z' 18 + }, 19 + replyCount: 3, 20 + repostCount: 12, 21 + likeCount: 42, 22 + indexedAt: '2024-01-01T00:00:00Z', 23 + viewer: {} 24 + }; 25 25 26 - const { Story } = defineMeta({ 27 - title: 'Components/Post', 28 - component: Post, 29 - tags: ['autodocs'], 30 - }); 26 + const { Story } = defineMeta({ 27 + title: 'Components/Post', 28 + component: Post, 29 + tags: ['autodocs'] 30 + }); 31 31 </script> 32 32 33 33 <Story name="Default" args={{ post: basePost }} /> ··· 36 36 37 37 <Story name="Reposted" args={{ post: { ...basePost, viewer: { repost: 'at://reposted' } } }} /> 38 38 39 - <Story name="WithImage" args={{ post: { 40 - ...basePost, 41 - embed: { 42 - $type: 'app.bsky.embed.images#view', 43 - images: [{ thumb: 'https://picsum.photos/seed/post/600/400', alt: 'Example image', fullsize: '', aspectRatio: { width: 600, height: 400 } }] 44 - } 45 - }}} /> 39 + <Story 40 + name="WithImage" 41 + args={{ 42 + post: { 43 + ...basePost, 44 + embed: { 45 + $type: 'app.bsky.embed.images#view', 46 + images: [ 47 + { 48 + thumb: 'https://picsum.photos/seed/post/600/400', 49 + alt: 'Example image', 50 + fullsize: '', 51 + aspectRatio: { width: 600, height: 400 } 52 + } 53 + ] 54 + } 55 + } 56 + }} 57 + />
+117 -105
src/lib/components/Post.svelte
··· 1 1 <script lang="ts"> 2 - import RichText from './RichText.svelte'; 3 - import Avatar from './Avatar.svelte'; 4 - import { getClient } from '$lib/atproto'; 5 - import { getUserContext } from '$lib/context'; 6 - import type { PostView } from '@atcute/bluesky/types/app/feed/defs'; 2 + import RichText from './RichText.svelte'; 3 + import Avatar from './Avatar.svelte'; 4 + import { getClient } from '$lib/atproto'; 5 + import { getUserContext } from '$lib/context'; 6 + import type { PostView } from '@atcute/bluesky/types/app/feed/defs'; 7 7 8 - let { post }: { post: PostView } = $props(); 9 - const user = getUserContext(); 10 - let liked = $state(Object.hasOwn(post.viewer, 'like')); 11 - let reposted = $state(Object.hasOwn(post.viewer, 'repost')); 12 - let likeCount = $derived( 13 - Object.hasOwn(post.viewer, 'like') ? post.likeCount - 1 : post.likeCount 14 - ); 15 - let repostCount = $derived( 16 - Object.hasOwn(post.viewer, 'repost') ? post.repostCount - 1 : post.repostCount 17 - ); 8 + let { post }: { post: PostView } = $props(); 9 + const user = getUserContext(); 10 + let liked = $state(!!post.viewer?.like); 11 + let reposted = $state(!!post.viewer?.repost); 12 + let likeCount = $derived(post.viewer?.like ? (post.likeCount ?? 0) - 1 : post.likeCount); 13 + let repostCount = $derived(post.viewer?.repost ? (post.repostCount ?? 0) - 1 : post.repostCount); 14 + const isAuthenticated = !!user.profile?.did; 18 15 19 - async function likePost() { 16 + async function likePost() { 20 17 liked = true; 21 18 const client = await getClient(); 22 19 23 20 if (!user.profile?.did) { 24 - liked = false; 25 - throw new Error('you must be authenticated to do this action'); 21 + liked = false; 22 + throw new Error('you must be authenticated to do this action'); 26 23 } 27 24 28 - const { data, ok } = await client.post('com.atproto.repo.createRecord', { 29 - input: { 30 - collection: 'app.bsky.feed.like', 31 - record: { 32 - $type: 'app.bsky.feed.like', 33 - createdAt: new Date().toISOString(), 34 - subject: { 35 - cid: post.cid, 36 - uri: post.uri 37 - } 38 - }, 39 - repo: user.profile.did 40 - } 25 + const { ok } = await client.post('com.atproto.repo.createRecord', { 26 + input: { 27 + collection: 'app.bsky.feed.like', 28 + record: { 29 + $type: 'app.bsky.feed.like', 30 + createdAt: new Date().toISOString(), 31 + subject: { 32 + cid: post.cid, 33 + uri: post.uri 34 + } 35 + }, 36 + repo: user.profile.did 37 + } 41 38 }); 42 39 43 40 if (!ok) { 44 - liked = false; 45 - throw new Error('failed to like the post'); 41 + liked = false; 42 + throw new Error('failed to like the post'); 46 43 } 47 - console.log('liked post!'); 48 - } 49 - 50 - async function unlikePost() { 44 + } 45 + 46 + async function unlikePost() { 51 47 liked = false; 52 48 const client = await getClient(); 53 49 54 50 if (!user.profile?.did) { 55 - liked = true; 56 - throw new Error('you must be authenticated to do this action (how did you even like this)'); 51 + liked = true; 52 + throw new Error('you must be authenticated to do this action (how did you even like this)'); 57 53 } 58 - const rkey = post.uri.split('/').at(-1); 59 - if (!rkey) { 60 - liked = true; 61 - throw new Error("couldn't properly extract rkey"); 62 - 63 - } 64 - const { data, ok } = await client.post('com.atproto.repo.deleteRecord', { 65 - input: { 66 - collection: 'app.bsky.feed.like', 67 - rkey, 68 - repo: user.profile.did 69 - } 54 + const rkey = post.uri.split('/').at(-1); 55 + if (!rkey) { 56 + liked = true; 57 + throw new Error("couldn't properly extract rkey"); 58 + } 59 + const { ok } = await client.post('com.atproto.repo.deleteRecord', { 60 + input: { 61 + collection: 'app.bsky.feed.like', 62 + rkey, 63 + repo: user.profile.did 64 + } 70 65 }); 71 66 72 67 if (!ok) { 73 - liked = true; 74 - throw new Error('failed to unlike the post'); 68 + liked = true; 69 + throw new Error('failed to unlike the post'); 75 70 } 76 71 console.log('liked post!'); 77 - } 72 + } 78 73 </script> 79 74 80 - <article class="flex border border-post-border pt-2 pr-4 pb-2 pl-2.5"> 81 - <div class="mr-2.5 ml-2 shrink-0"> 82 - <Avatar user={post.author} /> 83 - </div> 84 - <div> 85 - <div class="mb-1"> 86 - <a href="#"> 87 - <b>{post.author.displayName || post.author.handle}</b> 88 - <span class="text-secondary-text">@{post.author.handle}</span> 89 - </a> 90 - </div> 91 - <RichText text={post.record.text} facets={post.record.facets} /> 92 - {#if post.embed} 93 - {#if post.embed.$type === 'app.bsky.embed.images#view'} 94 - {#each post.embed.images as image} 95 - <img class="aspect-[1.23151 / 1] my-2 rounded-xl" src={image.thumb} alt={image.alt} /> 75 + <article 76 + class="flex border-x border-b border-post-border bg-body-background pt-2 pr-4 pb-2 pl-2.5 hover:bg-item-hover" 77 + > 78 + <div class="mr-2.5 ml-2 shrink-0"> 79 + <Avatar user={post.author} /> 80 + </div> 81 + <div> 82 + <div class="mb-1"> 83 + <a href="#"> 84 + <b>{post.author.displayName || post.author.handle}</b> 85 + <span class="text-secondary-text">@{post.author.handle}</span> 86 + </a> 87 + </div> 88 + <RichText text={post.record.text} facets={post.record.facets} /> 89 + {#if post.embed} 90 + {#if post.embed.$type === 'app.bsky.embed.images#view'} 91 + {#each post.embed.images as image (image.thumb)} 92 + <img class="aspect-[1.23151 / 1] my-2 rounded-xl" src={image.thumb} alt={image.alt} /> 96 93 {/each} 97 - {/if} 98 - {/if} 99 - <div class="mt-0.5 flex w-full"> 100 - <div class="flex w-[320px] max-w-[320px] justify-between"> 101 - <div class="grow"> 102 - <button class="flex items-center gap-1 py-1.25 pr-1.25" 103 - ><span class="text-4.5 icon-[boxicons--message-reply] h-4.5 w-4.5"></span> 104 - {post.replyCount}</button 105 - > 106 - </div> 107 - {#if reposted} 108 - <div class="flex grow items-center gap-1"> 109 - <span class="text-4.5 icon-[mdi--repost] h-4.5 w-4.5 text-green-500"></span> 110 - {(repostCount ?? 0) + 1} 111 - </div> 112 - {:else} 113 - <div class="flex grow items-center gap-1"> 114 - <span class="text-4.5 icon-[mdi--repost] h-4.5 w-4.5"></span> 115 - {repostCount} 116 - </div> 117 - {/if} 118 - {#if liked} 119 - <button aria-label={`Unlike this post, {likeCount+1} likes`} aria-pressed={true} class="flex grow items-center gap-1 hover:cursor-pointer" onclick={unlikePost}> 120 - <span class="text-4.5 icon-[icon-park-solid--like] h-4.5 w-4.5 text-red-500"></span> 121 - <span aria-hidden={true}>{(likeCount ?? 0) + 1}</span> 122 - </button> 123 - {:else} 124 - <button aria-label={`Like this post, {likeCount} likes`} aria-pressed={false} class="flex grow items-center gap-1 hover:cursor-pointer" onclick={likePost}> 125 - <span class="text-4.5 icon-[icon-park-outline--like] h-4.5 w-4.5"></span> 126 - <span aria-hidden={true}>{likeCount}</span> 127 - </button> 128 - {/if} 129 - </div> 130 - </div> 131 - </div> 94 + {/if} 95 + {/if} 96 + <div class="mt-0.5 flex w-full"> 97 + <div class="flex w-[320px] max-w-[320px] justify-between"> 98 + <div class="grow"> 99 + <button class="flex items-center gap-1 py-1.25 pr-1.25" 100 + ><span class="text-4.5 icon-[boxicons--message-reply] h-4.5 w-4.5"></span> 101 + {post.replyCount}</button 102 + > 103 + </div> 104 + {#if reposted} 105 + <div class="flex grow items-center gap-1"> 106 + <span class="text-4.5 icon-[mdi--repost] h-4.5 w-4.5 text-green-500"></span> 107 + {(repostCount ?? 0) + 1} 108 + </div> 109 + {:else} 110 + <div class="flex grow items-center gap-1"> 111 + <span class="text-4.5 icon-[mdi--repost] h-4.5 w-4.5"></span> 112 + {repostCount} 113 + </div> 114 + {/if} 115 + {#if !isAuthenticated} 116 + <div class="flex grow items-center gap-1"> 117 + <span class="text-4.5 icon-[icon-park-solid--like] h-4.5 w-4.5 text-gray-400"></span> 118 + <span aria-hidden={true}>{likeCount}</span> 119 + </div> 120 + {:else if liked} 121 + <button 122 + aria-label={`Unlike this post, {likeCount+1} likes`} 123 + aria-pressed={true} 124 + class="flex grow items-center gap-1 hover:cursor-pointer" 125 + onclick={unlikePost} 126 + > 127 + <span class="text-4.5 icon-[icon-park-solid--like] h-4.5 w-4.5 text-red-500"></span> 128 + <span aria-hidden={true}>{(likeCount ?? 0) + 1}</span> 129 + </button> 130 + {:else} 131 + <button 132 + aria-label={`Like this post, {likeCount} likes`} 133 + aria-pressed={false} 134 + class="flex grow items-center gap-1 hover:cursor-pointer" 135 + onclick={likePost} 136 + > 137 + <span class="text-4.5 icon-[icon-park-outline--like] h-4.5 w-4.5"></span> 138 + <span aria-hidden={true}>{likeCount}</span> 139 + </button> 140 + {/if} 141 + </div> 142 + </div> 143 + </div> 132 144 </article>
+5 -5
src/lib/components/RichText.svelte
··· 5 5 const segments = $derived(segmentize(text, facets)); 6 6 </script> 7 7 8 - {#each segments as {text, features}} 8 + {#each segments as { text, features }} 9 9 {#each features as feature} 10 - {#if feature.$type === "app.bsky.richtext.facet#mention"} 10 + {#if feature.$type === 'app.bsky.richtext.facet#mention'} 11 11 <a href={`#meow-${feature.did}`}>{text}</a> 12 - {:else if feature.$type === "app.bsky.richtext.facet#link"} 12 + {:else if feature.$type === 'app.bsky.richtext.facet#link'} 13 13 <a href={`#meow-${feature.uri}`}>{text}</a> 14 - {:else if feature.$type === "app.bsky.richtext.facet#tag"} 14 + {:else if feature.$type === 'app.bsky.richtext.facet#tag'} 15 15 <a href={`#meow-${feature.tag}`}>{text}</a> 16 16 {:else} 17 17 <span>{text}</span> ··· 19 19 {:else} 20 20 <span>{text}</span> 21 21 {/each} 22 - {/each} 22 + {/each}
+13 -14
src/routes/+layout.svelte
··· 3 3 import Avatar from '$lib/components/Avatar.svelte'; 4 4 import favicon from '$lib/assets/favicon.svg'; 5 5 import { setUserContext, getUserContext } from '$lib/context'; 6 - import type { AppBskyActorDefs } from '@atcute/bluesky'; 7 6 import * as TID from '@atcute/tid'; 8 7 import { getClient, login } from '$lib/atproto'; 9 8 ··· 17 16 const user = getUserContext(); 18 17 let handle = $state(''); 19 18 let loggingIn = $state(false); 20 - let composerDialog; 19 + let composerDialog: HTMLDialogElement; 21 20 let postContent = $state(''); 22 21 23 22 async function handleLogin() { ··· 37 36 } 38 37 39 38 const client = await getClient(); 40 - const { data, ok } = await client.post('com.atproto.repo.createRecord', { 39 + const { ok } = await client.post('com.atproto.repo.createRecord', { 41 40 input: { 42 41 repo: user.profile.did, 43 42 collection: 'app.bsky.feed.post', ··· 141 140 142 141 <style> 143 142 .sidebar { 144 - position: sticky; 145 - top: 0; 146 - bottom: 0; 147 - max-width: 279px; 148 - width: 100%; 143 + position: sticky; 144 + top: 0; 145 + bottom: 0; 146 + max-width: 279px; 147 + width: 100%; 149 148 } 150 149 .layout { 151 - height: 100%; 152 - overflow-y: scroll; 153 - display: flex; 154 - justify-content: center; 155 - align-items: flex-start; 150 + height: 100%; 151 + overflow-y: scroll; 152 + display: flex; 153 + justify-content: center; 154 + align-items: flex-start; 156 155 } 157 156 .main { 158 - width: 602px; 157 + width: 602px; 159 158 } 160 159 dialog::backdrop { 161 160 background-color: rgba(0, 0, 0, 0.8);
+1 -3
src/routes/+page.svelte
··· 2 2 import Post from '$lib/components/Post.svelte'; 3 3 4 4 let { data } = $props(); 5 - 6 5 </script> 7 6 8 7 {#each data.data.feed as entry, i (i)} 9 - <Post post={entry.post} /> 8 + <Post post={entry.post} /> 10 9 {/each} 11 -
-37
src/routes/feed/[[aturl]]/+layout.js
··· 1 - import { publicClient, rpc } from '$lib/atproto'; 2 - import { resumeSession, getProfile } from '$lib/atproto'; 3 - 4 - export async function load() { 5 - await resumeSession(); 6 - const client = rpc; 7 - if (!client) { 8 - throw new Error(`authenticated client not loaded`); 9 - } 10 - const { data, ok } = await client.get('app.bsky.actor.getPreferences', { 11 - params: {} 12 - }); 13 - if (!ok) { 14 - throw new Error(`couldn't load preferences`); 15 - } 16 - // extract the savedFeedsPrefV2 from this 17 - const savedFeedsPrefV2 = data.preferences.find(x => x.$type === "app.bsky.actor.defs#savedFeedsPrefV2"); 18 - 19 - if (savedFeedsPrefV2?.items) { 20 - const pinnedItems = savedFeedsPrefV2?.items.filter(x => x.pinned && x.type === 'feed') 21 - const { data, ok } = await client.get('app.bsky.feed.getFeedGenerators', { 22 - params: { 23 - feeds: /** @type {import('@atcute/lexicons').ResourceUri[]} */ (pinnedItems.map(x => x.value)) 24 - } 25 - }) 26 - 27 - if (!ok) { 28 - throw new Error(`failed to get list of feeds`); 29 - } 30 - 31 - const feedMap = Object.fromEntries(data.feeds.map(item => [item.uri, item])); 32 - console.log(feedMap) 33 - return { items: savedFeedsPrefV2.items, feedMap }; 34 - } 35 - 36 - return { items: [], feedMap: {} }; 37 - }
+37 -29
src/routes/feed/[[aturl]]/+layout.svelte
··· 1 1 <script lang="ts"> 2 - import { page } from '$app/state'; 3 - let { children, data } = $props(); 2 + import { page } from '$app/state'; 3 + let { children, data } = $props(); 4 4 </script> 5 5 6 - <div class="flex overflow-x-auto sticky top-0 z-10 bg-white"> 7 - {#each data.items as feedItem} 8 - {#if feedItem.pinned} 9 - {#if feedItem.type === "timeline" && feedItem.value === "following"} 10 - <a href="/feed" class="feedItem {!page.params.aturl ? 'feedItem--selected' : ''}">Following</a> 11 - {:else if feedItem.type === "feed" && data.feedMap[feedItem.value]} 12 - <a href="/feed/{encodeURIComponent(feedItem.value)}" class="feedItem {page.params.aturl === feedItem.value ? 'feedItem--selected' : ''}">{data.feedMap[feedItem.value]?.displayName ?? feedItem.value}</a> 6 + <div 7 + class="sticky top-0 z-10 flex overflow-x-auto border-x border-b border-post-border bg-body-background" 8 + > 9 + {#each data.items as feedItem} 10 + {#if feedItem.pinned} 11 + {#if feedItem.type === 'timeline' && feedItem.value === 'following'} 12 + <a href="/feed" class="feedItem {!page.params.aturl ? 'feedItem--selected' : ''}" 13 + >Following</a 14 + > 15 + {:else if feedItem.type === 'feed' && data.feedMap[feedItem.value]} 16 + <a 17 + href="/feed/{encodeURIComponent(feedItem.value)}" 18 + class="feedItem {page.params.aturl === feedItem.value ? 'feedItem--selected' : ''}" 19 + >{data.feedMap[feedItem.value]?.displayName ?? feedItem.value}</a 20 + > 21 + {/if} 13 22 {/if} 14 - {/if} 15 - {/each} 23 + {/each} 16 24 </div> 17 25 {@render children()} 18 26 19 27 <style> 20 - .feedItem { 21 - padding: 14px; 22 - white-space: nowrap; 23 - flex-shrink: 0; 24 - font-weight: 600; 25 - line-height: 20px; 26 - font-size: 15px; 27 - color: var(--color-feed-not-selected); 28 - border-top: 3px solid transparent; 29 - transition: background-color 200ms; 28 + .feedItem { 29 + padding: 14px; 30 + white-space: nowrap; 31 + flex-shrink: 0; 32 + font-weight: 600; 33 + line-height: 20px; 34 + font-size: 15px; 35 + color: var(--color-feed-not-selected); 36 + border-top: 3px solid transparent; 37 + transition: background-color 200ms; 30 38 31 - &:hover { 32 - background-color: var(--color-item-hover); 33 - } 34 - } 39 + &:hover { 40 + background-color: var(--color-item-hover); 41 + } 42 + } 35 43 36 - .feedItem--selected { 37 - color: var(--color-feed-selected); 38 - border-top: 3px solid var(--color-post-button); 39 - } 44 + .feedItem--selected { 45 + color: var(--color-feed-selected); 46 + border-top: 3px solid var(--color-post-button); 47 + } 40 48 </style>
+40
src/routes/feed/[[aturl]]/+layout.ts
··· 1 + import { rpc } from '$lib/atproto'; 2 + import type { ResourceUri } from '@atcute/lexicons'; 3 + import { resumeSession } from '$lib/atproto'; 4 + 5 + export async function load() { 6 + await resumeSession(); 7 + const client = rpc; 8 + if (!client) { 9 + throw new Error(`authenticated client not loaded`); 10 + } 11 + const { data, ok } = await client.get('app.bsky.actor.getPreferences', { 12 + params: {} 13 + }); 14 + if (!ok) { 15 + throw new Error(`couldn't load preferences`); 16 + } 17 + // extract the savedFeedsPrefV2 from this 18 + const savedFeedsPrefV2 = data.preferences.find( 19 + (x) => x.$type === 'app.bsky.actor.defs#savedFeedsPrefV2' 20 + ); 21 + 22 + if (savedFeedsPrefV2?.items) { 23 + const pinnedItems = savedFeedsPrefV2?.items.filter((x) => x.pinned && x.type === 'feed'); 24 + const { data, ok } = await client.get('app.bsky.feed.getFeedGenerators', { 25 + params: { 26 + feeds: pinnedItems.map((x) => x.value as ResourceUri) 27 + } 28 + }); 29 + 30 + if (!ok) { 31 + throw new Error(`failed to get list of feeds`); 32 + } 33 + 34 + const feedMap = Object.fromEntries(data.feeds.map((item) => [item.uri, item])); 35 + console.log(feedMap); 36 + return { items: savedFeedsPrefV2.items, feedMap }; 37 + } 38 + 39 + return { items: [], feedMap: {} }; 40 + }
+5 -3
src/routes/feed/[[aturl]]/+page.js src/routes/feed/[[aturl]]/+page.ts
··· 1 1 import { getClient } from '$lib/atproto'; 2 - 2 + import type { ResourceUri } from '@atcute/lexicons'; 3 3 const cache = new Map(); 4 4 5 5 export async function load({ params, depends }) { ··· 12 12 } 13 13 14 14 const promise = params.aturl 15 - ? client.get('app.bsky.feed.getFeed', { params: { feed: params.aturl, limit: 30 } }) 15 + ? client.get('app.bsky.feed.getFeed', { 16 + params: { feed: params.aturl as ResourceUri, limit: 30 } 17 + }) 16 18 : client.get('app.bsky.feed.getTimeline', { params: { limit: 30 } }); 17 19 18 - promise.then(result => { 20 + promise.then((result) => { 19 21 if (result.ok !== false) cache.set(key, result); 20 22 }); 21 23
+10 -4
src/routes/layout.css
··· 1 1 @import 'tailwindcss'; 2 2 @plugin '@iconify/tailwind4'; 3 3 4 + :root { 5 + color-scheme: light dark; 6 + } 7 + 4 8 @theme { 5 - --color-post-border: rgb(222, 225, 234); 9 + --color-body-background: light-dark(white, rgb(24, 28, 40)); 10 + --color-post-border: light-dark(rgb(222, 225, 234), rgb(50, 56, 78)); 6 11 --color-secondary-text: rgb(71, 79, 104); 7 12 --color-post-button: rgb(0, 106, 255); 8 13 --color-secondary-blue: rgb(0, 89, 214); 9 - --color-feed-selected: rgb(0, 0, 0); 10 - --color-feed-not-selected: rgb(64, 81, 104); 11 - --color-item-hover: rgb(249, 250, 251); 14 + --color-feed-selected: light-dark(rgb(0, 0, 0), white); 15 + --color-feed-not-selected: light-dark(rgb(64, 81, 104), rgb(176, 182, 201)); 16 + --color-item-hover: light-dark(rgb(249, 250, 251), rgb(33, 37, 54)); 12 17 --color-modal-border: rgb(192, 202, 216); 13 18 } 14 19 15 20 html, 16 21 body, 17 22 #layout { 23 + background-color: var(--color-body-background); 18 24 position: fixed; 19 25 bottom: 0; 20 26 right: 0;
+2 -2
src/routes/oauth/callback/+page.svelte
··· 6 6 let error = $state(''); 7 7 8 8 onMount(async () => { 9 - const params = new URLSearchParams(location.hash.slice(1)); 10 - console.log(params) 9 + const params = new URLSearchParams(location.hash.slice(1)); 10 + console.log(params); 11 11 12 12 try { 13 13 await handleCallback(params);
+19 -19
src/stories/Button.stories.svelte
··· 1 1 <script module> 2 - import { defineMeta } from '@storybook/addon-svelte-csf'; 3 - import Button from './Button.svelte'; 4 - import { fn } from 'storybook/test'; 2 + import { defineMeta } from '@storybook/addon-svelte-csf'; 3 + import Button from './Button.svelte'; 4 + import { fn } from 'storybook/test'; 5 5 6 - // More on how to set up stories at: https://storybook.js.org/docs/writing-stories 7 - const { Story } = defineMeta({ 8 - title: 'Example/Button', 9 - component: Button, 10 - tags: ['autodocs'], 11 - argTypes: { 12 - backgroundColor: { control: 'color' }, 13 - size: { 14 - control: { type: 'select' }, 15 - options: ['small', 'medium', 'large'], 16 - }, 17 - }, 18 - args: { 19 - onclick: fn(), 20 - } 21 - }); 6 + // More on how to set up stories at: https://storybook.js.org/docs/writing-stories 7 + const { Story } = defineMeta({ 8 + title: 'Example/Button', 9 + component: Button, 10 + tags: ['autodocs'], 11 + argTypes: { 12 + backgroundColor: { control: 'color' }, 13 + size: { 14 + control: { type: 'select' }, 15 + options: ['small', 'medium', 'large'] 16 + } 17 + }, 18 + args: { 19 + onclick: fn() 20 + } 21 + }); 22 22 </script> 23 23 24 24 <!-- More on writing stories with args: https://storybook.js.org/docs/writing-stories/args -->
+22 -22
src/stories/Button.svelte
··· 1 1 <script lang="ts"> 2 - import './button.css'; 2 + import './button.css'; 3 3 4 - interface Props { 5 - /** Is this the principal call to action on the page? */ 6 - primary?: boolean; 7 - /** What background color to use */ 8 - backgroundColor?: string; 9 - /** How large should the button be? */ 10 - size?: 'small' | 'medium' | 'large'; 11 - /** Button contents */ 12 - label: string; 13 - /** The onclick event handler */ 14 - onclick?: () => void; 15 - } 4 + interface Props { 5 + /** Is this the principal call to action on the page? */ 6 + primary?: boolean; 7 + /** What background color to use */ 8 + backgroundColor?: string; 9 + /** How large should the button be? */ 10 + size?: 'small' | 'medium' | 'large'; 11 + /** Button contents */ 12 + label: string; 13 + /** The onclick event handler */ 14 + onclick?: () => void; 15 + } 16 + 17 + const { primary = false, backgroundColor, size = 'medium', label, ...props }: Props = $props(); 16 18 17 - const { primary = false, backgroundColor, size = 'medium', label, ...props }: Props = $props(); 18 - 19 - let mode = $derived(primary ? 'storybook-button--primary' : 'storybook-button--secondary'); 20 - let style = $derived(backgroundColor ? `background-color: ${backgroundColor}` : ''); 19 + let mode = $derived(primary ? 'storybook-button--primary' : 'storybook-button--secondary'); 20 + let style = $derived(backgroundColor ? `background-color: ${backgroundColor}` : ''); 21 21 </script> 22 22 23 23 <button 24 - type="button" 25 - class={['storybook-button', `storybook-button--${size}`, mode].join(' ')} 26 - {style} 27 - {...props} 24 + type="button" 25 + class={['storybook-button', `storybook-button--${size}`, mode].join(' ')} 26 + {style} 27 + {...props} 28 28 > 29 - {label} 29 + {label} 30 30 </button>
+36 -31
src/stories/Configure.mdx
··· 1 - import { Meta } from "@storybook/addon-docs/blocks"; 1 + import { Meta } from '@storybook/addon-docs/blocks'; 2 2 3 - import Github from "./assets/github.svg"; 4 - import Discord from "./assets/discord.svg"; 5 - import Youtube from "./assets/youtube.svg"; 6 - import Tutorials from "./assets/tutorials.svg"; 7 - import Styling from "./assets/styling.png"; 8 - import Context from "./assets/context.png"; 9 - import Assets from "./assets/assets.png"; 10 - import Docs from "./assets/docs.png"; 11 - import Share from "./assets/share.png"; 12 - import FigmaPlugin from "./assets/figma-plugin.png"; 13 - import Testing from "./assets/testing.png"; 14 - import Accessibility from "./assets/accessibility.png"; 15 - import Theming from "./assets/theming.png"; 16 - import AddonLibrary from "./assets/addon-library.png"; 3 + import Github from './assets/github.svg'; 4 + import Discord from './assets/discord.svg'; 5 + import Youtube from './assets/youtube.svg'; 6 + import Tutorials from './assets/tutorials.svg'; 7 + import Styling from './assets/styling.png'; 8 + import Context from './assets/context.png'; 9 + import Assets from './assets/assets.png'; 10 + import Docs from './assets/docs.png'; 11 + import Share from './assets/share.png'; 12 + import FigmaPlugin from './assets/figma-plugin.png'; 13 + import Testing from './assets/testing.png'; 14 + import Accessibility from './assets/accessibility.png'; 15 + import Theming from './assets/theming.png'; 16 + import AddonLibrary from './assets/addon-library.png'; 17 17 18 - export const RightArrow = () => <svg 19 - viewBox="0 0 14 14" 20 - width="8px" 21 - height="14px" 22 - style={{ 23 - marginLeft: '4px', 24 - display: 'inline-block', 25 - shapeRendering: 'inherit', 26 - verticalAlign: 'middle', 27 - fill: 'currentColor', 28 - 'path fill': 'currentColor' 29 - }} 30 - > 31 - <path d="m11.1 7.35-5.5 5.5a.5.5 0 0 1-.7-.7L10.04 7 4.9 1.85a.5.5 0 1 1 .7-.7l5.5 5.5c.2.2.2.5 0 .7Z" /> 32 - </svg> 18 + export const RightArrow = () => ( 19 + <svg 20 + viewBox="0 0 14 14" 21 + width="8px" 22 + height="14px" 23 + style={{ 24 + marginLeft: '4px', 25 + display: 'inline-block', 26 + shapeRendering: 'inherit', 27 + verticalAlign: 'middle', 28 + fill: 'currentColor', 29 + 'path fill': 'currentColor' 30 + }} 31 + > 32 + <path d="m11.1 7.35-5.5 5.5a.5.5 0 0 1-.7-.7L10.04 7 4.9 1.85a.5.5 0 1 1 .7-.7l5.5 5.5c.2.2.2.5 0 .7Z" /> 33 + </svg> 34 + ); 33 35 34 36 <Meta title="Configure your project" /> 35 37 ··· 38 40 # Configure your project 39 41 40 42 Because Storybook works separately from your app, you'll need to configure it for your specific stack and setup. Below, explore guides for configuring Storybook with popular frameworks and tools. If you get stuck, learn how you can ask for help from our community. 43 + 41 44 </div> 42 45 <div className="sb-section"> 43 46 <div className="sb-section-item"> ··· 84 87 # Do more with Storybook 85 88 86 89 Now that you know the basics, let's explore other parts of Storybook that will improve your experience. This list is just to get you started. You can customise Storybook in many ways to fit your needs. 90 + 87 91 </div> 88 92 89 93 <div className="sb-section"> ··· 203 207 target="_blank" 204 208 >Discover tutorials<RightArrow /></a> 205 209 </div> 210 + 206 211 </div> 207 212 208 213 <style> 209 - {` 214 + {` 210 215 .sb-container { 211 216 margin-bottom: 48px; 212 217 }
+19 -19
src/stories/Header.stories.svelte
··· 1 1 <script module> 2 - import { defineMeta } from '@storybook/addon-svelte-csf'; 3 - import Header from './Header.svelte'; 4 - import { fn } from 'storybook/test'; 2 + import { defineMeta } from '@storybook/addon-svelte-csf'; 3 + import Header from './Header.svelte'; 4 + import { fn } from 'storybook/test'; 5 5 6 - // More on how to set up stories at: https://storybook.js.org/docs/writing-stories 7 - const { Story } = defineMeta({ 8 - title: 'Example/Header', 9 - component: Header, 10 - // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs 11 - tags: ['autodocs'], 12 - parameters: { 13 - // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout 14 - layout: 'fullscreen', 15 - }, 16 - args: { 17 - onLogin: fn(), 18 - onLogout: fn(), 19 - onCreateAccount: fn(), 20 - } 21 - }); 6 + // More on how to set up stories at: https://storybook.js.org/docs/writing-stories 7 + const { Story } = defineMeta({ 8 + title: 'Example/Header', 9 + component: Header, 10 + // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs 11 + tags: ['autodocs'], 12 + parameters: { 13 + // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout 14 + layout: 'fullscreen' 15 + }, 16 + args: { 17 + onLogin: fn(), 18 + onLogout: fn(), 19 + onCreateAccount: fn() 20 + } 21 + }); 22 22 </script> 23 23 24 24 <Story name="Logged In" args={{ user: { name: 'Jane Doe' } }} />
+38 -38
src/stories/Header.svelte
··· 1 1 <script lang="ts"> 2 - import './header.css'; 3 - import Button from './Button.svelte'; 2 + import './header.css'; 3 + import Button from './Button.svelte'; 4 4 5 - interface Props { 6 - user?: { name: string }; 7 - onLogin?: () => void; 8 - onLogout?: () => void; 9 - onCreateAccount?: () => void; 10 - } 5 + interface Props { 6 + user?: { name: string }; 7 + onLogin?: () => void; 8 + onLogout?: () => void; 9 + onCreateAccount?: () => void; 10 + } 11 11 12 - const { user, onLogin, onLogout, onCreateAccount }: Props = $props(); 12 + const { user, onLogin, onLogout, onCreateAccount }: Props = $props(); 13 13 </script> 14 14 15 15 <header> 16 - <div class="storybook-header"> 17 - <div> 18 - <svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg"> 19 - <g fill="none" fill-rule="evenodd"> 20 - <path 21 - d="M10 0h12a10 10 0 0110 10v12a10 10 0 01-10 10H10A10 10 0 010 22V10A10 10 0 0110 0z" 22 - fill="#FFF" 23 - /> 24 - <path 25 - d="M5.3 10.6l10.4 6v11.1l-10.4-6v-11zm11.4-6.2l9.7 5.5-9.7 5.6V4.4z" 26 - fill="#555AB9" 27 - /> 28 - <path d="M27.2 10.6v11.2l-10.5 6V16.5l10.5-6zM15.7 4.4v11L6 10l9.7-5.5z" fill="#91BAF8" /> 29 - </g> 30 - </svg> 31 - <h1>Acme</h1> 32 - </div> 33 - <div> 34 - {#if user} 35 - <span class="welcome"> 36 - Welcome, <b>{user.name}</b>! 37 - </span> 38 - <Button size="small" onclick={onLogout} label="Log out" /> 39 - {:else} 40 - <Button size="small" onclick={onLogin} label="Log in" /> 41 - <Button primary size="small" onclick={onCreateAccount} label="Sign up" /> 42 - {/if} 43 - </div> 44 - </div> 16 + <div class="storybook-header"> 17 + <div> 18 + <svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg"> 19 + <g fill="none" fill-rule="evenodd"> 20 + <path 21 + d="M10 0h12a10 10 0 0110 10v12a10 10 0 01-10 10H10A10 10 0 010 22V10A10 10 0 0110 0z" 22 + fill="#FFF" 23 + /> 24 + <path 25 + d="M5.3 10.6l10.4 6v11.1l-10.4-6v-11zm11.4-6.2l9.7 5.5-9.7 5.6V4.4z" 26 + fill="#555AB9" 27 + /> 28 + <path d="M27.2 10.6v11.2l-10.5 6V16.5l10.5-6zM15.7 4.4v11L6 10l9.7-5.5z" fill="#91BAF8" /> 29 + </g> 30 + </svg> 31 + <h1>Acme</h1> 32 + </div> 33 + <div> 34 + {#if user} 35 + <span class="welcome"> 36 + Welcome, <b>{user.name}</b>! 37 + </span> 38 + <Button size="small" onclick={onLogout} label="Log out" /> 39 + {:else} 40 + <Button size="small" onclick={onLogin} label="Log in" /> 41 + <Button primary size="small" onclick={onCreateAccount} label="Sign up" /> 42 + {/if} 43 + </div> 44 + </div> 45 45 </header>
+24 -22
src/stories/Page.stories.svelte
··· 1 1 <script module> 2 - import { defineMeta } from '@storybook/addon-svelte-csf'; 3 - import { expect, userEvent, waitFor, within } from 'storybook/test'; 4 - import Page from './Page.svelte'; 5 - import { fn } from 'storybook/test'; 2 + import { defineMeta } from '@storybook/addon-svelte-csf'; 3 + import { expect, userEvent, waitFor, within } from 'storybook/test'; 4 + import Page from './Page.svelte'; 5 + import { fn } from 'storybook/test'; 6 6 7 - // More on how to set up stories at: https://storybook.js.org/docs/writing-stories 8 - const { Story } = defineMeta({ 9 - title: 'Example/Page', 10 - component: Page, 11 - parameters: { 12 - // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout 13 - layout: 'fullscreen', 14 - }, 15 - }); 7 + // More on how to set up stories at: https://storybook.js.org/docs/writing-stories 8 + const { Story } = defineMeta({ 9 + title: 'Example/Page', 10 + component: Page, 11 + parameters: { 12 + // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout 13 + layout: 'fullscreen' 14 + } 15 + }); 16 16 </script> 17 17 18 - <Story name="Logged In" play={async ({ canvasElement }) => { 19 - const canvas = within(canvasElement); 20 - const loginButton = canvas.getByRole('button', { name: /Log in/i }); 21 - await expect(loginButton).toBeInTheDocument(); 22 - await userEvent.click(loginButton); 23 - await waitFor(() => expect(loginButton).not.toBeInTheDocument()); 18 + <Story 19 + name="Logged In" 20 + play={async ({ canvasElement }) => { 21 + const canvas = within(canvasElement); 22 + const loginButton = canvas.getByRole('button', { name: /Log in/i }); 23 + await expect(loginButton).toBeInTheDocument(); 24 + await userEvent.click(loginButton); 25 + await waitFor(() => expect(loginButton).not.toBeInTheDocument()); 24 26 25 - const logoutButton = canvas.getByRole('button', { name: /Log out/i }); 26 - await expect(logoutButton).toBeInTheDocument(); 27 - }} 27 + const logoutButton = canvas.getByRole('button', { name: /Log out/i }); 28 + await expect(logoutButton).toBeInTheDocument(); 29 + }} 28 30 /> 29 31 30 32 <Story name="Logged Out" />
+61 -61
src/stories/Page.svelte
··· 1 1 <script lang="ts"> 2 - import './page.css'; 3 - import Header from './Header.svelte'; 2 + import './page.css'; 3 + import Header from './Header.svelte'; 4 4 5 - let user = $state<{ name: string }>(); 5 + let user = $state<{ name: string }>(); 6 6 </script> 7 7 8 8 <article> 9 - <Header 10 - {user} 11 - onLogin={() => (user = { name: 'Jane Doe' })} 12 - onLogout={() => (user = undefined)} 13 - onCreateAccount={() => (user = { name: 'Jane Doe' })} 14 - /> 9 + <Header 10 + {user} 11 + onLogin={() => (user = { name: 'Jane Doe' })} 12 + onLogout={() => (user = undefined)} 13 + onCreateAccount={() => (user = { name: 'Jane Doe' })} 14 + /> 15 15 16 - <section class="storybook-page"> 17 - <h2>Pages in Storybook</h2> 18 - <p> 19 - We recommend building UIs with a 20 - <a 21 - href="https://blog.hichroma.com/component-driven-development-ce1109d56c8e" 22 - target="_blank" 23 - rel="noopener noreferrer" 24 - > 25 - <strong>component-driven</strong> 26 - </a> 27 - process starting with atomic components and ending with pages. 28 - </p> 29 - <p> 30 - Render pages with mock data. This makes it easy to build and review page states without 31 - needing to navigate to them in your app. Here are some handy patterns for managing page data 32 - in Storybook: 33 - </p> 34 - <ul> 35 - <li> 36 - Use a higher-level connected component. Storybook helps you compose such data from the 37 - "args" of child component stories 38 - </li> 39 - <li> 40 - Assemble data in the page component from your services. You can mock these services out 41 - using Storybook. 42 - </li> 43 - </ul> 44 - <p> 45 - Get a guided tutorial on component-driven development at 46 - <a href="https://storybook.js.org/tutorials/" target="_blank" rel="noopener noreferrer"> 47 - Storybook tutorials 48 - </a> 49 - . Read more in the 50 - <a href="https://storybook.js.org/docs" target="_blank" rel="noopener noreferrer">docs</a> 51 - . 52 - </p> 53 - <div class="tip-wrapper"> 54 - <span class="tip">Tip</span> 55 - Adjust the width of the canvas with the 56 - <svg width="10" height="10" viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg"> 57 - <g fill="none" fill-rule="evenodd"> 58 - <path 59 - d="M1.5 5.2h4.8c.3 0 .5.2.5.4v5.1c-.1.2-.3.3-.4.3H1.4a.5.5 0 16 + <section class="storybook-page"> 17 + <h2>Pages in Storybook</h2> 18 + <p> 19 + We recommend building UIs with a 20 + <a 21 + href="https://blog.hichroma.com/component-driven-development-ce1109d56c8e" 22 + target="_blank" 23 + rel="noopener noreferrer" 24 + > 25 + <strong>component-driven</strong> 26 + </a> 27 + process starting with atomic components and ending with pages. 28 + </p> 29 + <p> 30 + Render pages with mock data. This makes it easy to build and review page states without 31 + needing to navigate to them in your app. Here are some handy patterns for managing page data 32 + in Storybook: 33 + </p> 34 + <ul> 35 + <li> 36 + Use a higher-level connected component. Storybook helps you compose such data from the 37 + "args" of child component stories 38 + </li> 39 + <li> 40 + Assemble data in the page component from your services. You can mock these services out 41 + using Storybook. 42 + </li> 43 + </ul> 44 + <p> 45 + Get a guided tutorial on component-driven development at 46 + <a href="https://storybook.js.org/tutorials/" target="_blank" rel="noopener noreferrer"> 47 + Storybook tutorials 48 + </a> 49 + . Read more in the 50 + <a href="https://storybook.js.org/docs" target="_blank" rel="noopener noreferrer">docs</a> 51 + . 52 + </p> 53 + <div class="tip-wrapper"> 54 + <span class="tip">Tip</span> 55 + Adjust the width of the canvas with the 56 + <svg width="10" height="10" viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg"> 57 + <g fill="none" fill-rule="evenodd"> 58 + <path 59 + d="M1.5 5.2h4.8c.3 0 .5.2.5.4v5.1c-.1.2-.3.3-.4.3H1.4a.5.5 0 60 60 01-.5-.4V5.7c0-.3.2-.5.5-.5zm0-2.1h6.9c.3 0 .5.2.5.4v7a.5.5 0 01-1 0V4H1.5a.5.5 0 61 61 010-1zm0-2.1h9c.3 0 .5.2.5.4v9.1a.5.5 0 01-1 0V2H1.5a.5.5 0 010-1zm4.3 5.2H2V10h3.8V6.2z" 62 - id="a" 63 - fill="#999" 64 - /> 65 - </g> 66 - </svg> 67 - Viewports addon in the toolbar 68 - </div> 69 - </section> 62 + id="a" 63 + fill="#999" 64 + /> 65 + </g> 66 + </svg> 67 + Viewports addon in the toolbar 68 + </div> 69 + </section> 70 70 </article>
+18 -18
src/stories/button.css
··· 1 1 .storybook-button { 2 - display: inline-block; 3 - cursor: pointer; 4 - border: 0; 5 - border-radius: 3em; 6 - font-weight: 700; 7 - line-height: 1; 8 - font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; 2 + display: inline-block; 3 + cursor: pointer; 4 + border: 0; 5 + border-radius: 3em; 6 + font-weight: 700; 7 + line-height: 1; 8 + font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; 9 9 } 10 10 .storybook-button--primary { 11 - background-color: #555ab9; 12 - color: white; 11 + background-color: #555ab9; 12 + color: white; 13 13 } 14 14 .storybook-button--secondary { 15 - box-shadow: rgba(0, 0, 0, 0.15) 0px 0px 0px 1px inset; 16 - background-color: transparent; 17 - color: #333; 15 + box-shadow: rgba(0, 0, 0, 0.15) 0px 0px 0px 1px inset; 16 + background-color: transparent; 17 + color: #333; 18 18 } 19 19 .storybook-button--small { 20 - padding: 10px 16px; 21 - font-size: 12px; 20 + padding: 10px 16px; 21 + font-size: 12px; 22 22 } 23 23 .storybook-button--medium { 24 - padding: 11px 20px; 25 - font-size: 14px; 24 + padding: 11px 20px; 25 + font-size: 14px; 26 26 } 27 27 .storybook-button--large { 28 - padding: 12px 24px; 29 - font-size: 16px; 28 + padding: 12px 24px; 29 + font-size: 16px; 30 30 }
+18 -18
src/stories/header.css
··· 1 1 .storybook-header { 2 - display: flex; 3 - justify-content: space-between; 4 - align-items: center; 5 - border-bottom: 1px solid rgba(0, 0, 0, 0.1); 6 - padding: 15px 20px; 7 - font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; 2 + display: flex; 3 + justify-content: space-between; 4 + align-items: center; 5 + border-bottom: 1px solid rgba(0, 0, 0, 0.1); 6 + padding: 15px 20px; 7 + font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; 8 8 } 9 9 10 10 .storybook-header svg { 11 - display: inline-block; 12 - vertical-align: top; 11 + display: inline-block; 12 + vertical-align: top; 13 13 } 14 14 15 15 .storybook-header h1 { 16 - display: inline-block; 17 - vertical-align: top; 18 - margin: 6px 0 6px 10px; 19 - font-weight: 700; 20 - font-size: 20px; 21 - line-height: 1; 16 + display: inline-block; 17 + vertical-align: top; 18 + margin: 6px 0 6px 10px; 19 + font-weight: 700; 20 + font-size: 20px; 21 + line-height: 1; 22 22 } 23 23 24 24 .storybook-header button + button { 25 - margin-left: 10px; 25 + margin-left: 10px; 26 26 } 27 27 28 28 .storybook-header .welcome { 29 - margin-right: 10px; 30 - color: #333; 31 - font-size: 14px; 29 + margin-right: 10px; 30 + color: #333; 31 + font-size: 14px; 32 32 }
+39 -39
src/stories/page.css
··· 1 1 .storybook-page { 2 - margin: 0 auto; 3 - padding: 48px 20px; 4 - max-width: 600px; 5 - color: #333; 6 - font-size: 14px; 7 - line-height: 24px; 8 - font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; 2 + margin: 0 auto; 3 + padding: 48px 20px; 4 + max-width: 600px; 5 + color: #333; 6 + font-size: 14px; 7 + line-height: 24px; 8 + font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; 9 9 } 10 10 11 11 .storybook-page h2 { 12 - display: inline-block; 13 - vertical-align: top; 14 - margin: 0 0 4px; 15 - font-weight: 700; 16 - font-size: 32px; 17 - line-height: 1; 12 + display: inline-block; 13 + vertical-align: top; 14 + margin: 0 0 4px; 15 + font-weight: 700; 16 + font-size: 32px; 17 + line-height: 1; 18 18 } 19 19 20 20 .storybook-page p { 21 - margin: 1em 0; 21 + margin: 1em 0; 22 22 } 23 23 24 24 .storybook-page a { 25 - color: inherit; 25 + color: inherit; 26 26 } 27 27 28 28 .storybook-page ul { 29 - margin: 1em 0; 30 - padding-left: 30px; 29 + margin: 1em 0; 30 + padding-left: 30px; 31 31 } 32 32 33 33 .storybook-page li { 34 - margin-bottom: 8px; 34 + margin-bottom: 8px; 35 35 } 36 36 37 37 .storybook-page .tip { 38 - display: inline-block; 39 - vertical-align: top; 40 - margin-right: 10px; 41 - border-radius: 1em; 42 - background: #e7fdd8; 43 - padding: 4px 12px; 44 - color: #357a14; 45 - font-weight: 700; 46 - font-size: 11px; 47 - line-height: 12px; 38 + display: inline-block; 39 + vertical-align: top; 40 + margin-right: 10px; 41 + border-radius: 1em; 42 + background: #e7fdd8; 43 + padding: 4px 12px; 44 + color: #357a14; 45 + font-weight: 700; 46 + font-size: 11px; 47 + line-height: 12px; 48 48 } 49 49 50 50 .storybook-page .tip-wrapper { 51 - margin-top: 40px; 52 - margin-bottom: 40px; 53 - font-size: 13px; 54 - line-height: 20px; 51 + margin-top: 40px; 52 + margin-bottom: 40px; 53 + font-size: 13px; 54 + line-height: 20px; 55 55 } 56 56 57 57 .storybook-page .tip-wrapper svg { 58 - display: inline-block; 59 - vertical-align: top; 60 - margin-top: 3px; 61 - margin-right: 4px; 62 - width: 12px; 63 - height: 12px; 58 + display: inline-block; 59 + vertical-align: top; 60 + margin-top: 3px; 61 + margin-right: 4px; 62 + width: 12px; 63 + height: 12px; 64 64 } 65 65 66 66 .storybook-page .tip-wrapper svg path { 67 - fill: #1ea7fd; 67 + fill: #1ea7fd; 68 68 }
+1 -2
vite.config.ts
··· 25 25 config(_conf, { command }) { 26 26 const redirectUri = `http://${SERVER_HOST}:${SERVER_PORT}/oauth/callback`; 27 27 if (command === 'serve') { 28 - process.env.VITE_OAUTH_CLIENT_ID = 29 - `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent(OAUTH_SCOPE)}`; 28 + process.env.VITE_OAUTH_CLIENT_ID = `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent(OAUTH_SCOPE)}`; 30 29 process.env.VITE_OAUTH_REDIRECT_URI = redirectUri; 31 30 } 32 31 process.env.VITE_OAUTH_SCOPE = OAUTH_SCOPE;
+1 -1
vitest.shims.d.ts
··· 1 - /// <reference types="@vitest/browser-playwright" /> 1 + /// <reference types="@vitest/browser-playwright" />