Read-it-later social network

redesign PublicationCard, init toggleSubscription

+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.