Something went wrong. Try again.
Monorepo for Tangled forked from tangled.org/core
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321<script lang="ts"> import { onDestroy, tick } from "svelte"; import GripVertical from "$icon/grip-vertical"; import Plus from "$icon/plus"; import X from "$icon/x"; import { createBobbinClient } from "#lib/api/client.ts"; import type { Did, Handle } from "@atcute/lexicons/syntax"; import { addPinned, asPinnedKey, movePinned, PINNED_LIMIT, pinKeyOf, removePinned, type PinnedKey } from "#lib/api/pinning.ts"; import { getAuth } from "#lib/auth.svelte.ts"; import { fetchPinnedByKey, fetchReposPage } from "#lib/components/profile/pages.ts"; import { dropIndex, shiftFor, type Slot } from "#lib/components/profile/pinnedReorder.ts"; import type { RepoCardData } from "#lib/components/profile/types.ts"; import RepoCard from "#lib/components/repo/RepoCard.svelte"; import Button from "#lib/components/ui/Button.svelte"; import Card from "#lib/components/ui/Card.svelte"; import Combobox from "#lib/components/ui/Combobox.svelte"; import ErrorAlert from "#lib/components/ui/Error.svelte";
const SEARCH_DELAY = 150; const SEARCH_LIMIT = 20; const PLACEHOLDER: RepoCardData = { name: "placeholder", repoDid: "", ownerHandle: "", createdAt: "" };
interface Props { keys: PinnedKey[]; did: Did; handle: Handle; cards?: Record<string, RepoCardData>; showOwner?: boolean; }
let { keys = $bindable(), did, handle, cards = $bindable({}), showOwner = false }: Props = $props();
const auth = getAuth(); const ctx = $derived(createBobbinClient({ serviceUrl: auth.bobbinUrl }));
let loadError = $state<string>(); // not state: it only stops a key being fetched twice // eslint-disable-next-line svelte/prefer-svelte-reactivity const requested = new Set<string>();
// pins loaded from the record need their cards fetched, picked ones already have theirs $effect(() => { const missing = keys.filter((key) => !(key in cards) && !requested.has(key)); if (missing.length === 0) return; for (const key of missing) requested.add(key); fetchPinnedByKey(ctx, { keys: missing, did, handle, viewerDid: auth.currentDid ?? undefined }) .then((found) => { for (const [key, card] of found) cards[key] = card; }) .catch((cause) => { loadError = cause instanceof Error ? cause.message : String(cause); }); });
let results = $state<RepoCardData[]>([]); let searching = $state(false); let picked = $state(""); let timer: ReturnType<typeof setTimeout> | undefined; let latest = 0;
const full = $derived(keys.length >= PINNED_LIMIT); const options = $derived( results .map((repo) => ({ repo, key: pinKeyOf(repo) })) .filter(({ key }) => !keys.includes(key)) .map(({ repo, key }) => ({ value: key, label: repo.name, hint: repo.knot })) );
const search = (query: string) => { if (timer !== undefined) clearTimeout(timer); const id = ++latest; searching = true; timer = setTimeout( async () => { try { const page = await fetchReposPage(ctx, { did, handle, viewerDid: auth.currentDid ?? undefined, q: query || undefined, limit: SEARCH_LIMIT }); if (id === latest) results = page.items; } catch { if (id === latest) results = []; } if (id === latest) searching = false; }, query ? SEARCH_DELAY : 0 ); };
const pin = (key: string) => { const repo = results.find((candidate) => pinKeyOf(candidate) === key); if (repo) cards[key] = repo; keys = addPinned(keys, asPinnedKey(key)); picked = ""; };
let stopDrag: (() => void) | undefined;
onDestroy(() => { if (timer !== undefined) clearTimeout(timer); stopDrag?.(); });
let items: HTMLElement[] = []; let grips: Record<string, HTMLElement | undefined> = {}; let status = $state(""); let drag = $state<{ from: number; to: number; x: number; y: number; slots: Slot[] }>();
const labelOf = (key: string) => cards[key]?.name ?? key;
const reorder = async (from: number, to: number) => { if (to < 0 || to >= keys.length || from === to) return; const key = keys[from]; keys = movePinned(keys, from, to); status = `${labelOf(key)} moved to position ${to + 1} of ${keys.length}`; await tick(); grips[key]?.focus(); };
const steps: Record<string, number> = { ArrowUp: -1, ArrowLeft: -1, ArrowDown: 1, ArrowRight: 1 };
const onGripKeydown = (event: KeyboardEvent, index: number) => { const step = steps[event.key]; if (step === undefined) return; event.preventDefault(); void reorder(index, index + step); };
const onGripPointerdown = (event: PointerEvent, from: number) => { if (event.button !== 0 || keys.length < 2) return; event.preventDefault(); (event.currentTarget as HTMLElement).focus(); stopDrag?.(); const slots = items.slice(0, keys.length).map((item) => { const { left, top, width, height } = item.getBoundingClientRect(); return { left, top, width, height }; }); const startX = event.clientX; const startY = event.clientY;
const move = (moveEvent: PointerEvent) => { drag = { from, to: dropIndex(slots, moveEvent.clientX, moveEvent.clientY), x: moveEvent.clientX - startX, y: moveEvent.clientY - startY, slots }; }; const drop = () => end(true); const abort = () => end(false); const end = (commit: boolean) => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", drop); window.removeEventListener("pointercancel", abort); stopDrag = undefined; const landed = drag; drag = undefined; if (commit && landed) void reorder(landed.from, landed.to); }; stopDrag = abort; window.addEventListener("pointermove", move); window.addEventListener("pointerup", drop); window.addEventListener("pointercancel", abort); };
const transformFor = (index: number) => { if (!drag) return undefined; const { x, y } = index === drag.from ? drag : shiftFor(index, drag.from, drag.to, drag.slots); return x === 0 && y === 0 ? undefined : `translate(${x}px, ${y}px)`; };</script>
{#snippet placeholderAction()} <div class="-mt-2xs -mr-2xs size-7 shrink-0"></div>{/snippet}
{#snippet tile({ props }: { props: Record<string, unknown> })} <div class="grid h-full"> <div class="invisible col-start-1 row-start-1" inert aria-hidden="true"> <RepoCard repo={PLACEHOLDER} starButton={false} showOwner={false} uniformHeight action={placeholderAction} class="h-full" /> </div> <button type="button" data-testid="trigger" class="col-start-1 row-start-1 flex size-full cursor-pointer items-center justify-center gap-xs rounded-md border border-dashed border-border-default text-foreground-muted hover:bg-background-subtle hover:text-foreground-default focus-visible:outline-2" {...props} > <Plus class="size-3.5" aria-hidden="true" /> Pin a repository </button> </div>{/snippet}
<div class="@container flex w-full flex-col gap-xs" data-testid="pinnedReposEditor"> <ErrorAlert label={loadError} /> <ul class="grid grid-cols-1 gap-xs @min-[42rem]:grid-cols-2" aria-label="Pinned repositories"> {#if !full} <li> <Combobox testid="pinPicker" {options} bind:value={picked} onSelect={pin} onSearch={search} loading={searching} trigger={tile} label="Pin a repository" searchPlaceholder="Search your repositories…" emptyLabel={searching ? "Searching…" : "No repositories found"} class="h-full" matchTriggerWidth /> </li> {/if} {#each keys as key, index (key)} {@const card = cards[key]} <li bind:this={items[index]} data-testid="pinnedRepo" data-key={key} class={[ "relative", drag?.from === index ? "z-10 shadow-regular" : drag && "transition-transform" ]} style:transform={transformFor(index)} > {#snippet actions()} <div class="-mt-2xs -mr-2xs flex shrink-0 items-center"> <button type="button" data-testid="reorder" bind:this={grips[key]} class="flex size-7 cursor-grab touch-none items-center justify-center rounded-sm text-foreground-muted hover:bg-background-subtle hover:text-foreground-default focus-visible:outline-2 active:cursor-grabbing" aria-label="Reorder {labelOf(key)}. Press an arrow key to move it." onpointerdown={(event) => onGripPointerdown(event, index)} onkeydown={(event) => onGripKeydown(event, index)} > <GripVertical class="size-3.5" aria-hidden="true" /> </button> <Button data-testid="unpin" variant="ghost" size="small" icon={X} aria-label="Unpin {labelOf(key)}" onclick={() => (keys = removePinned(keys, key))} /> </div> {/snippet} {#if card} <RepoCard repo={card} starButton={false} {showOwner} uniformHeight action={actions} class="h-full" /> {:else} <Card class="h-full"> <div class="flex items-start gap-sm"> <span class="mt-2xs min-w-0 flex-1 truncate text-foreground-muted" >{key}</span > {@render actions()} </div> </Card> {/if} </li> {/each} </ul> {#if keys.length > 0} <p class="sr-only"> Drag a handle to reorder, or focus it and use the arrow keys. {keys.length} of {PINNED_LIMIT} pinned. </p> {/if} <span class="sr-only" role="status" aria-live="polite">{status}</span></div>