Monorepo for Tangled
Something went wrong. Try again.
Svelte
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262<script lang="ts"> import { resolve } from "$app/paths"; import { page } from "$app/state"; import Star from "$icon/star"; import TriangleAlert from "$icon/triangle-alert"; import { getAuth } from "$lib/auth.svelte"; import { loginWithReturn } from "$lib/auth/guards"; import { createStar, deleteStar, type StarSubject } from "$lib/api/graph"; import { createBobbinClient } from "$lib/api/client"; import { awaitDeletedRecord, awaitIndexedOrSaved } from "$lib/api/awaitRecord"; import Button from "$lib/components/ui/Button.svelte"; import ButtonGroup from "$lib/components/ui/ButtonGroup.svelte"; import Tooltip from "$lib/components/ui/Tooltip.svelte"; import { getProfileCounts } from "$lib/components/profile/counts.svelte"; import { createOptimisticCount, createOptimisticRelation, seededValue } from "$lib/optimistic.svelte"; import { formatCount } from "$lib/format"; import Bones from "$lib/components/ui/Bones.svelte"; import { CROSSFADE_DURATION, CROSSFADE_EASING } from "$lib/motion/spring"; import { prefersReducedMotion } from "svelte/motion"; import bonesLoadingprimitivesCount from "$lib/bones/bones-loadingprimitives--count.bones.json"; import type { StarChange } from "$lib/components/profile/types"; import type { RepoCounts } from "$lib/components/repo/types";
interface Props { subject: StarSubject; initialCount?: number | RepoCounts | Promise<number> | Promise<RepoCounts>; initialRkey?: string | null | Promise<string | null>; insetShadow?: boolean; onCommit?: (change: StarChange) => void; class?: string; } let { subject, initialCount, initialRkey, insetShadow = true, onCommit, class: className = "" }: Props = $props();
const subjectKey = $derived(subject.kind === "repo" ? subject.did : subject.uri);
const auth = getAuth(); const profileCounts = getProfileCounts(); const signedIn = $derived(Boolean(auth.currentDid)); const initialRkeySync = ( val: string | null | Promise<string | null> | undefined ): string | null | undefined => (typeof val === "string" || val === null ? val : undefined);
const initialCountSync = ( val: number | RepoCounts | Promise<number> | Promise<RepoCounts> | undefined ): number | undefined => { if (typeof val === "number") return val; if (typeof val === "object" && val !== null && !("then" in (val as unknown as object))) { return (val as RepoCounts).stars; } return undefined; };
const rkeySeed = seededValue(() => initialRkey, initialRkeySync, null); const countSeed = seededValue(() => initialCount, initialCountSync, 0); const loadedRkey = $derived(rkeySeed.value); const loadedCount = $derived(countSeed.value);
const relation = createOptimisticRelation({ key: () => `${auth.currentDid ?? ""}:${subjectKey}`, loadedRkey: () => loadedRkey }); const starCount = createOptimisticCount({ key: () => subjectKey, loaded: () => loadedCount }); const starred = $derived(relation.active); const failed = $derived(relation.failed || starCount.failed); $effect(() => { if (!failed) return; const timer = setTimeout(() => { relation.resetFailure(); starCount.resetFailure(); }, 2000); return () => clearTimeout(timer); }); const loginHref = $derived( resolve(loginWithReturn(page.url.pathname + page.url.search) as "/login") ); const starsHref = $derived( subject.kind === "repo" && subject.ownerHandle && subject.name ? resolve(`/${subject.ownerHandle}/${subject.name}/stars` as "/") : undefined ); const fade = $derived(prefersReducedMotion.current ? 0 : CROSSFADE_DURATION); const face = $derived(failed ? "err" : starred ? "on" : "off");
const toggle = async () => { const agent = auth.agent; if (!agent || !relation.known || relation.loading || !subjectKey) return; const wasStarred = relation.active; const existingRkey = relation.rkey; const delta = wasStarred ? -1 : 1; const undo = wasStarred ? 1 : -1; relation.begin(!wasStarred); starCount.resetFailure(); starCount.adjust(delta); if (subject.kind === "repo") { profileCounts?.adjust(agent.sub, "stars", delta); } const bobbinUrl = page.data.publicConfig?.bobbinUrl; const ctx = bobbinUrl ? createBobbinClient({ serviceUrl: bobbinUrl }) : null; try { if (wasStarred && existingRkey) { const { uri } = await deleteStar(agent, existingRkey); relation.deleted(); if (ctx) { awaitDeletedRecord(ctx, uri).catch((cause) => console.error("failed to wait for unstar index", cause) ); } if (subject.kind === "repo") { onCommit?.({ viewerDid: agent.sub, repoDid: subject.did, starred: false, rkey: null, delta }); } } else { const written = await createStar(agent, subject); const rkey = written.rkey; relation.created(rkey); if (ctx) { // todo: needs a design pass for surfacing index wait failures awaitIndexedOrSaved(ctx, written).catch((cause) => console.error("failed to wait for star index", cause) ); } if (subject.kind === "repo") { onCommit?.({ viewerDid: agent.sub, repoDid: subject.did, starred: true, rkey, delta }); } } } catch { relation.fail(); starCount.adjust(undo); starCount.fail(); if (subject.kind === "repo") { profileCounts?.adjust(agent.sub, "stars", undo); } } };</script>
<div class={["flex flex-col items-start", className]}> <Tooltip content="Something went wrong. Try again." disabled={!failed}> <ButtonGroup class="w-full"> <Button variant={failed ? "danger" : "default"} size="small" {insetShadow} href={signedIn ? undefined : loginHref} disabled={signedIn && (!relation.known || !subjectKey)} aria-busy={relation.loading} onclick={signedIn ? toggle : undefined} class="min-w-22 flex-1" style="--swap: {fade}ms; --swap-ease: {CROSSFADE_EASING}" > <span class="faces grid size-4 shrink-0" data-face={face} aria-hidden="true"> <Star class="face off size-4" /> <Star class="face on size-4 [&_path]:fill-current" /> <TriangleAlert class="face err size-4" /> </span> <span class="labels" data-on={starred} aria-hidden="true"> <span class="label off">Star</span> <span class="label on">Starred</span> </span> <span class="sr-only">{starred ? "Starred" : "Star"}</span> </Button> <Button href={starsHref} variant="ghost" size="small" title="Starred by" class={starsHref ? undefined : "pointer-events-none"} tabindex={starsHref ? undefined : -1} > {#if loadedCount === undefined} <Bones capture={bonesLoadingprimitivesCount} class="inline-block w-8 align-middle" /> {:else} {formatCount(starCount.value)} {/if} </Button> </ButtonGroup> </Tooltip></div>
<style> .faces :global(.face) { grid-column: 1; grid-row: 1; opacity: 0; scale: 0.92; filter: blur(1px); transition: opacity var(--swap) var(--swap-ease), scale var(--swap) var(--swap-ease), filter var(--swap) var(--swap-ease); }
.faces[data-face="off"] :global(.off), .faces[data-face="on"] :global(.on), .faces[data-face="err"] :global(.err) { opacity: 1; scale: 1; filter: blur(0); }
/* only the visible word is in flow, so the box hugs it and the gap next to the icon stays honest; the button's min-width holds the widest arrangement so nothing beside it moves. keep that min-width in step if the labels ever change. */ .labels { position: relative; }
.label { display: block; white-space: nowrap; opacity: 0; translate: 0 3px; filter: blur(1px); transition: opacity var(--swap) var(--swap-ease), translate var(--swap) var(--swap-ease), filter var(--swap) var(--swap-ease); }
.labels[data-on="false"] .on, .labels[data-on="true"] .off { position: absolute; top: 0; left: 0; }
.labels[data-on="false"] .off, .labels[data-on="true"] .on { opacity: 1; translate: 0 0; filter: blur(0); }</style>