Read-it-later social network
1<script lang="ts">
2 import { getContext } from "svelte";
3 import { createQuery } from "@tanstack/svelte-query";
4 import type { QuicksliceClient } from "quickslice-client-js";
5 import { defaultTheme, parseAtUri, resolveHandle, type MiniDoc, type PublicationNode, type SubscriptionNode } from "$lib/utils";
6
7 const user = getContext("user") as MiniDoc;
8 const atclient = getContext("atclient") as QuicksliceClient;
9
10 let { publication, hideEmptyPublications = false }: {
11 publication: PublicationNode & { viewerSiteStandardGraphSubscriptionViaPublication?: SubscriptionNode | null }, hideEmptyPublications?: boolean
12 } = $props();
13
14 const { rkey: pubRkey } = parseAtUri(publication.uri);
15
16 let disableSubscribeButton = $state(false);
17 let isSubscribeButtonHovered = $state(false);
18
19 const miniDocQuery = createQuery(() => ({
20 queryKey: ["miniDoc", publication.did],
21 queryFn: async () => {
22 const miniDoc = await resolveHandle(publication.did);
23 return miniDoc;
24 },
25 staleTime: "static"
26 }));
27
28 const countQuery = createQuery(() => ({
29 queryKey: ["counts", publication.uri],
30 queryFn: async () => {
31 const constellationUrl = new URL("https://constellation.microcosm.blue/links/all");
32 constellationUrl.searchParams.set("target", publication.uri);
33 const response = await fetch(constellationUrl, {
34 headers: {
35 "Accept": "application/json"
36 }
37 });
38
39
40 const json = await response.json() as { links: Record<string, any> };
41 return json;
42 },
43 select: (data) => {
44 const documents = Number(data.links["site.standard.document"]?.[".site"]?.records) || 0;
45 const subscribers = Number(data.links["site.standard.graph.subscription"]?.[".publication"]?.records) || 0;
46
47 return { documents, subscribers }
48 },
49 }));
50
51 let documents = $derived(countQuery.data?.documents || 0);
52 let subscribers = $derived(countQuery.data?.subscribers || 0);
53 let subscriptionRkey = $derived(parseAtUri(publication.viewerSiteStandardGraphSubscriptionViaPublication?.uri || "").rkey);
54 let blobSyncUrl = $derived(`${miniDocQuery.data?.pds}/xrpc/com.atproto.sync.getBlob?did=${publication.did}&cid=${publication.icon?.ref}`);
55 const theme = publication.basicTheme || defaultTheme;
56
57 async function toggleSubscribe() {
58 if (!user) { throw Error() }
59 disableSubscribeButton = true;
60
61 const pastRkey = subscriptionRkey;
62 if (pastRkey) {
63 subscribers--;
64 subscriptionRkey = undefined;
65 }
66 else {
67 subscribers++;
68 subscriptionRkey = "placeholder_rkey";
69 }
70
71 try {
72 if (pastRkey) {
73 const mutation = `
74 mutation {
75 deleteSiteStandardGraphSubscription(rkey: "${pastRkey}") {
76 uri
77 }
78 }
79 `;
80 await atclient.mutate(mutation) as { createSiteStandardGraphSubscription: { uri: string }};
81 subscriptionRkey = undefined;
82 }
83 else {
84 const mutation = `
85 mutation {
86 createSiteStandardGraphSubscription(input: {
87 publication: "${publication.uri}"
88 }) {
89 uri
90 }
91 }
92 `;
93 const result = await atclient.mutate(mutation) as { createSiteStandardGraphSubscription: { uri: string }};
94 const { rkey } = parseAtUri(result.createSiteStandardGraphSubscription.uri);
95 subscriptionRkey = rkey;
96 }
97
98 disableSubscribeButton = false;
99 }
100 catch (e) {
101 console.error(e);
102 // rollback initial changes
103 if (pastRkey) {
104 subscribers++;
105 subscriptionRkey = pastRkey;
106 }
107 else {
108 subscribers--;
109 subscriptionRkey = undefined;
110 }
111
112 disableSubscribeButton = false;
113 }
114 }
115</script>
116
117{#if (hideEmptyPublications && documents > 0) || !hideEmptyPublications}
118
119<div
120 class="flex flex-col lg:flex-row overflow-hidden rounded border shadow-sm"
121 style={`
122 background-color: rgb(${theme.background.r},${theme.background.g},${theme.background.b});
123 color: rgb(${theme.foreground.r},${theme.foreground.g},${theme.foreground.b});
124 `}
125>
126 <div class="flex flex-1 flex-col items-center justify-center gap-3 p-8">
127 {#if publication.icon}
128 <img
129 src={blobSyncUrl.toString()}
130 alt={publication.name}
131 class="size-24 rounded-xl hover:-rotate-15 transition-transform duration-150"
132 />
133 {/if}
134 <h3 class="text-xl font-semibold text-center text-balance">
135 {publication.name}
136 </h3>
137 <a
138 href={`/${publication.actorHandle}`}
139 style={`
140 color: rgb(${theme.foreground.r},${theme.foreground.g},${theme.foreground.b});
141 `}
142 class="hover:!text-blue-500"
143 >
144 by @{publication.actorHandle}
145 </a>
146 <p class="text-xs text-center max-w-md leading-relaxed font-neco">
147 {publication.description}
148 </p>
149 </div>
150
151 <div class="flex w-full lg:w-32 border-t lg:flex-col lg:border-t-0 lg:border-l bg-muted/50">
152 <a
153 href={`/${miniDocQuery.data?.handle}/${pubRkey}`}
154 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"
155 style={`
156 background-color: rgb(${theme.accent.r},${theme.accent.g},${theme.accent.b});
157 color: rgb(${theme.accentForeground.r},${theme.accentForeground.g},${theme.accentForeground.b});
158 `}
159 >
160 <span class="text-2xl font-bold text-card-foreground">
161 {documents}
162 </span>
163 <span class="flex gap-1 text-xs uppercase tracking-wide">
164 Documents
165 <span class="group-hover:rotate-45 transition-transform duration-150">↗</span>
166 </span>
167 </a>
168 <button
169 onclick={toggleSubscribe}
170 disabled={disableSubscribeButton}
171 onmouseenter={() => isSubscribeButtonHovered = true}
172 onmouseleave={() => isSubscribeButtonHovered = false}
173 class={[
174 "flex flex-1 flex-col items-center justify-center gap-1 p-4 hover:cursor-pointer transition-all duration-150 hover:bg-green-500",
175 subscriptionRkey && "bg-green-500 hover:bg-red-400"
176 ]}
177 >
178 <span class="gap-[0.5rem] text-2xl font-bold">
179 {subscribers}
180 </span>
181 <span class="text-xs uppercase tracking-wide flex">
182 {#if subscriptionRkey}
183 {#if isSubscribeButtonHovered}
184 Unsubscribe?
185 {:else}
186 Subscribed
187 {/if}
188 {:else}
189 {#if isSubscribeButtonHovered}
190 Subscribe?
191 {:else}
192 Subscribers
193 {/if}
194 {/if}
195
196 {#if disableSubscribeButton}
197 <p class="animate-spin">◝</p>
198 {/if}
199 </span>
200 </button>
201 </div>
202</div>
203
204{/if}