Read-it-later social network
12
fork

Configure Feed

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

redesign PublicationCard, init toggleSubscription

authored by

zeudev and committed by tangled.org 0b4c47ae 5f100f7c

+241 -37
bun.lockb

This is a binary file and will not be displayed.

+1
package.json
··· 32 32 "@oslojs/encoding": "^1.1.0", 33 33 "@tailwindcss/vite": "^4.1.13", 34 34 "@tanstack/svelte-query": "^6.0.9", 35 + "@tanstack/svelte-query-devtools": "^6.0.3", 35 36 "drizzle-orm": "^0.44.5", 36 37 "postgres": "^3.4.7", 37 38 "quickslice-client-js": "^0.3.0",
+6
src/app.css
··· 10 10 src: url("/Comico-Regular.woff2"); 11 11 } 12 12 13 + @font-face { 14 + font-family: "Azeret"; 15 + src: url("/AzeretMono-Variable.woff2"); 16 + } 17 + 13 18 @theme { 14 19 --font-neco: "Neco"; 15 20 --font-comico: "Comico"; 21 + --font-azeret: "Azeret"; 16 22 } 17 23 18 24 @utility border-groove {
+159 -18
src/lib/components/PublicationCard.svelte
··· 1 1 <script lang="ts"> 2 - import type { PublicationNode } from "$lib/utils"; 3 - import { createQuery } from "@tanstack/svelte-query"; 2 + import { getContext } from "svelte"; 3 + import { createQuery } from "@tanstack/svelte-query"; 4 + import { resolveHandle, type MiniDoc, type PublicationNode, type StandardSiteThemeColorRGB } from "$lib/utils"; 5 + import type { QuicksliceClient } from "quickslice-client-js"; 6 + 7 + const user = getContext("user") as MiniDoc; 8 + const atclient = getContext("atclient") as QuicksliceClient; 4 9 5 10 let { publication, showEmpty = false }: { publication: PublicationNode, showEmpty?: boolean } = $props(); 6 11 12 + let isSubscribeButtonHovered = $state(false); 13 + 14 + const miniDocQuery = createQuery(() => ({ 15 + queryKey: ["miniDoc", publication.did], 16 + queryFn: async () => { 17 + const miniDoc = await resolveHandle(publication.did); 18 + return miniDoc; 19 + }, 20 + staleTime: "static" 21 + })); 22 + 7 23 const countQuery = createQuery(() => ({ 8 - queryKey: ["publication", publication.uri], 24 + queryKey: ["counts", publication.uri], 9 25 queryFn: async () => { 10 26 const constellationUrl = new URL("https://constellation.microcosm.blue/links/all"); 11 27 constellationUrl.searchParams.set("target", publication.uri); ··· 19 35 const json = await response.json() as { links: Record<string, any> }; 20 36 return json; 21 37 }, 22 - staleTime: 30 * 60 * 1000, 23 38 select: (data) => { 24 - const documents = Number(data.links["site.standard.document"]?.[".site"]?.records) || 0; 39 + const documents = Number(data.links["site.standard.document"]?.[".site"]?.records) || 0; 25 40 const subscribers = Number(data.links["site.standard.graph.subscription"]?.[".publication"]?.records) || 0; 41 + 26 42 return { documents, subscribers } 27 43 } 28 44 })); 45 + 46 + const isSubscribedQuery = createQuery(() => ({ 47 + queryKey: ["isSubscribed", publication.uri, user.did], 48 + queryFn: async () => { 49 + const constellationUrl = new URL("https://constellation.microcosm.blue/xrpc/blue.microcosm.links.getBacklinks"); 50 + constellationUrl.searchParams.set("subject", publication.uri); 51 + constellationUrl.searchParams.set("source", "site.standard.graph.subscription:publication"); 52 + constellationUrl.searchParams.set("did", user.did); 53 + const response = await fetch(constellationUrl, { 54 + headers: { 55 + "Accept": "application/json" 56 + } 57 + }); 58 + 59 + 60 + const json = await response.json() as { total: number }; 61 + return json; 62 + }, 63 + })); 64 + 65 + let documents = $derived(countQuery.data?.documents || 0); 66 + let subscribers = $derived(countQuery.data?.subscribers || 0); 67 + let isSubscribed = $derived((isSubscribedQuery.data?.total ?? 0) === 1); 68 + let blobSyncUrl = $derived((`${miniDocQuery.data?.pds}/xrpc/com.atproto.sync.getBlob?did=${publication.did}&cid=${publication.value.icon?.ref.$link}`)); 69 + const theme = publication.value.basicTheme || { 70 + $type: "site.standard.theme.basic", 71 + background: { 72 + $type: "site.standard.theme.color#rgb", 73 + b: 255, 74 + g: 255, 75 + r: 255 76 + }, 77 + accentForeground: { 78 + $type: "site.standard.theme.color#rgb", 79 + b: 0, 80 + g: 0, 81 + r: 0, 82 + }, 83 + foreground: { 84 + $type: "site.standard.theme.color#rgb", 85 + b: 0, 86 + g: 0, 87 + r: 0 88 + }, 89 + accent: { 90 + $type: "site.standard.theme.color#rgb", 91 + b: 36, 92 + g: 191, 93 + r: 251 94 + }, 95 + }; 96 + 97 + // TODO: update with `site.standard.graph.subscription` create or delete on click with auth 98 + function toggleSubscribe() { 99 + const past = isSubscribed; 100 + isSubscribed = !isSubscribed; 101 + if (subscribers) { 102 + if (past) { 103 + subscribers--; 104 + } 105 + else { 106 + subscribers++; 107 + } 108 + } 109 + } 29 110 </script> 30 111 31 - {#if countQuery.isFetching} 32 - <p>Fetching...</p> 33 - {:else if countQuery.isSuccess} 34 - {#if countQuery.data.documents > 0 || showEmpty} 35 - <li class="flex flex-col gap-4 border p-4"> 36 - <a href={publication.value.url} class="w-fit">{publication.value.name}</a> 37 - <a href={`https://pdsls.dev/${publication.uri}`} target="_blank" class="w-fit border">Go to Record</a> 38 - <div class="flex gap-4"> 39 - <p>{countQuery.data.documents} Documents</p> 40 - <p>{countQuery.data.subscribers} Subscribers</p> 112 + <div 113 + class="flex flex-col lg:flex-row overflow-hidden rounded border shadow-sm" 114 + style={` 115 + background-color: rgb(${theme.background.r},${theme.background.g},${theme.background.b}); 116 + color: rgb(${theme.foreground.r},${theme.foreground.g},${theme.foreground.b}); 117 + `} 118 + > 119 + <div class="flex flex-1 flex-col items-center justify-center gap-3 p-8"> 120 + {#if publication.value.icon} 121 + <img 122 + src={blobSyncUrl.toString()} 123 + alt={publication.value.name} 124 + class="size-24 rounded-xl hover:-rotate-15 transition-transform duration-150" 125 + /> 126 + {/if} 127 + <h3 class="text-xl font-semibold text-center text-balance"> 128 + {publication.value.name} 129 + </h3> 130 + <a 131 + href={`https://bsky.app/profile/${publication.actorHandle}`} 132 + style={` 133 + color: rgb(${theme.foreground.r},${theme.foreground.g},${theme.foreground.b}); 134 + `} 135 + class="hover:!text-blue-500" 136 + > 137 + by @{publication.actorHandle} 138 + </a> 139 + <p class="text-xs text-center max-w-md leading-relaxed font-neco"> 140 + {publication.value.description} 141 + </p> 142 + </div> 143 + 144 + <div class="flex w-full lg:w-32 border-t lg:flex-col lg:border-t-0 lg:border-l bg-muted/50"> 145 + <div 146 + class="group flex flex-1 flex-col items-center justify-center gap-1 border-r lg:border-r-0 lg:border-b border-border p-4 hover:cursor-pointer" 147 + style={` 148 + background-color: rgb(${theme.accent.r},${theme.accent.g},${theme.accent.b}); 149 + color: rgb(${theme.accentForeground.r},${theme.accentForeground.g},${theme.accentForeground.b}); 150 + `} 151 + > 152 + <span class="text-2xl font-bold text-card-foreground"> 153 + {documents} 154 + </span> 155 + <span class="flex gap-1 text-xs uppercase tracking-wide"> 156 + Documents 157 + <span class="group-hover:rotate-45 transition-transform duration-150">↗</span> 158 + </span> 41 159 </div> 42 - </li> 43 - {/if} 44 - {/if} 160 + <button 161 + onclick={toggleSubscribe} 162 + onmouseenter={() => isSubscribeButtonHovered = true} 163 + onmouseleave={() => isSubscribeButtonHovered = false} 164 + class={["flex flex-1 flex-col items-center justify-center gap-1 p-4 hover:cursor-pointer transition-all duration-150 hover:bg-green-500", isSubscribed && "bg-green-500 hover:bg-red-400"]}> 165 + <span class="text-2xl font-bold"> 166 + {subscribers} 167 + </span> 168 + <span class="text-xs uppercase tracking-wide"> 169 + {#if isSubscribed} 170 + {#if isSubscribeButtonHovered} 171 + Unsubscribe? 172 + {:else} 173 + Subscribed 174 + {/if} 175 + {:else} 176 + {#if isSubscribeButtonHovered} 177 + Subscribe? 178 + {:else} 179 + Subscribers 180 + {/if} 181 + {/if} 182 + </span> 183 + </button> 184 + </div> 185 + </div>
+31 -3
src/lib/utils.ts
··· 9 9 } 10 10 } 11 11 12 + export type MiniDoc = { 13 + did: string; 14 + handle: string; 15 + pds: string; 16 + signing_key: string; 17 + } 18 + 12 19 export async function resolveHandle(handle: string) { 13 20 const result = await fetch(`https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(handle)}`) 14 21 const info = await result.json(); 15 - return info; 22 + return info as MiniDoc; 16 23 } 17 24 18 25 export type Node = { ··· 23 30 actorHandle: string; 24 31 } 25 32 33 + export type ATBlob = { 34 + $type: string; 35 + ref: { $link: string; }; 36 + mimeType: string; 37 + size: number; 38 + } 39 + 40 + export type StandardSiteThemeColorRGB = { 41 + $type: "site.standard.theme.color#rgb", 42 + b: number; 43 + g: number; 44 + r: number; 45 + } 46 + 26 47 export type PublicationNode = Node & { value: { 27 48 url: string; 28 49 name: string; 29 50 description: string; 30 - icon?: string; 51 + icon?: ATBlob; 31 52 preferences?: { 32 53 showInDiscover?: boolean; 33 54 hideProfile?: boolean; 34 - } 55 + }; 56 + basicTheme?: { 57 + $type: "site.standard.theme.basic", 58 + background: StandardSiteThemeColorRGB; 59 + foreground: StandardSiteThemeColorRGB; 60 + accent: StandardSiteThemeColorRGB; 61 + accentForeground: StandardSiteThemeColorRGB; 62 + }; 35 63 }} 36 64 37 65 export type DocumentNode = Node & { value: {
+13 -7
src/routes/+layout.svelte
··· 1 1 <script lang="ts"> 2 2 import '../app.css'; 3 3 import { page } from '$app/state'; 4 + import { setContext } from 'svelte'; 4 5 import { QueryClient, QueryClientProvider } from "@tanstack/svelte-query"; 6 + import { SvelteQueryDevtools } from "@tanstack/svelte-query-devtools"; 5 7 6 8 let { data, children } = $props(); 7 9 const { atclient, user } = data; 10 + 11 + setContext("user", user); 12 + setContext("atclient", atclient); 8 13 9 14 let handleInput = $state(""); 10 15 ··· 29 34 </script> 30 35 31 36 <QueryClientProvider client={queryClient}> 32 - <div class="flex flex-col gap-8 w-screen h-full min-h-screen font-neco"> 37 + <SvelteQueryDevtools /> 38 + <div class="flex flex-col gap-8 w-screen h-full min-h-screen font-azeret"> 33 39 <header class="flex flex-col lg:flex-row lg:items-center w-full gap-4 px-8 py-4 border-b lg:border-none justify-between"> 34 - <a href="/" class="text-2xl hover:text-shadow-md">potatonet.app</a> 40 + <a href="/" class="text-2xl hover:text-shadow-md font-neco font-semibold">🥔 potatonet</a> 35 41 36 - <div class="flex gap-4 items-center text-lg flex-wrap"> 37 - <nav class="text-lg flex gap-4 flex-wrap items-center border-3 border-groove px-3 py-1.5"> 38 - <a href="/" class="hover:text-shadow-lg hover:underline" title="explore" aria-label="explore">🛰️ explore</a> 39 - <a href="https://tangled.sh/@zeu.dev/potatonet-app" class="hover:text-shadow-lg" title="source code" aria-label="source code">🧶 source code</a> 42 + <div class="flex gap-4 items-center flex-wrap"> 43 + <nav class="flex gap-4 flex-wrap items-center px-3 py-1.5"> 44 + <a href="/" class="hover:text-shadow-sm" title="explore" aria-label="explore">🛰️ explore</a> 45 + <a href="https://tangled.sh/@zeu.dev/potatonet-app" class="hover:text-shadow-sm" title="source code" aria-label="source code">🧶 source code</a> 40 46 {#if user} 41 - <a href={`/${user.handle}/bookmarks`} class="hover:text-shadow-lg" aria-label="logged in user's bookmarks">🔖 your bookmarks</a> 47 + <a href={`/${user.handle}/bookmarks`} class="hover:text-shadow-sm" aria-label="logged in user's bookmarks">🔖 your bookmarks</a> 42 48 <p>{user.handle}</p> 43 49 {/if} 44 50 </nav>
+3 -3
src/routes/+layout.ts
··· 1 1 import { redirect } from "@sveltejs/kit"; 2 2 import { createQuicksliceClient, QuicksliceClient } from "quickslice-client-js"; 3 3 import type { LayoutLoadEvent } from "./$types"; 4 - import { resolveHandle } from "$lib/utils"; 4 + import { resolveHandle, type MiniDoc } from "$lib/utils"; 5 5 6 6 export const ssr = false; 7 7 ··· 21 21 const user = await atclient.getUser(); 22 22 if (user) { 23 23 const info = await resolveHandle(user.did); 24 - return { atclient, user: info } as { atclient: QuicksliceClient, user: Record<string, string> | undefined } 24 + return { atclient, user: info } as { atclient: QuicksliceClient, user: MiniDoc | undefined } 25 25 } 26 26 } 27 27 28 - return { atclient, user: undefined } as { atclient: QuicksliceClient, user: Record<string, string> | undefined } 28 + return { atclient, user: undefined } as { atclient: QuicksliceClient, user: MiniDoc | undefined } 29 29 }
+28 -6
src/routes/+page.svelte
··· 44 44 })); 45 45 46 46 let currentPage = $derived(publicationsQuery.data?.slice(page*20, (page*20) + 20)); 47 - let showEmpty = $state(true); 48 47 </script> 49 48 50 49 <menu> 51 - <label for="showEmpty"> 52 - <input name="showEmpty" type="checkbox" bind:checked={showEmpty}> 53 - Show empty publication 54 - </label> 55 50 <button 56 51 onclick={() => { 57 52 if (page > 0) { ··· 84 79 <p>Error</p> 85 80 {:else} 86 81 {#each currentPage as publication (publication.uri)} 87 - <PublicationCard {publication} {showEmpty} /> 82 + <PublicationCard {publication} /> 88 83 {/each} 89 84 {/if} 85 + 86 + <menu> 87 + <button 88 + onclick={() => { 89 + if (page > 0) { 90 + page--; 91 + } 92 + }} 93 + class="border" 94 + > 95 + Prev Page 96 + </button> 97 + <number>{page + 1}</number> 98 + {#if publicationsQuery.hasNextPage} 99 + <button 100 + onclick={() => { 101 + page++; 102 + if ((page * 20) + 20 > (publicationsQuery.data?.length || 0)) { 103 + publicationsQuery.fetchNextPage(); 104 + } 105 + }} 106 + class="border" 107 + > 108 + Next Page 109 + </button> 110 + {/if} 111 + </menu>
static/AzeretMono-Variable.woff2

This is a binary file and will not be displayed.