/ src / components / opensource / PRDropdown.svelte
PRDropdown.svelte
  1  <script lang="ts">
  2  import { type AnimationPlaybackControlsWithThen, animate } from "motion";
  3  import { untrack } from "svelte";
  4  import ArrowUpRight from "~icons/ph/arrow-up-right-duotone";
  5  import CaretDownIcon from "~icons/ph/caret-down";
  6  import GitPullRequestIcon from "~icons/ph/git-pull-request-duotone";
  7  import StarIcon from "~icons/ph/star-fill";
  8  import type { GitHubPullRequest } from "../../types/github-pr.ts";
  9  
 10  interface Props {
 11  	repository: {
 12  		name: string;
 13  		full_name: string;
 14  		url: string;
 15  		stargazerCount: number;
 16  		owner: string;
 17  	};
 18  	prs: GitHubPullRequest[];
 19  	defaultOpen?: boolean;
 20  }
 21  
 22  const { repository, prs, defaultOpen = false }: Props = $props();
 23  
 24  function abbreviateNumber(num: number): string {
 25  	if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
 26  	if (num >= 1_000) return `${Math.round(num / 1_000)}k`;
 27  	return num.toString();
 28  }
 29  
 30  // svelte-ignore state_referenced_locally: intentional
 31  let isOpen = $state(defaultOpen);
 32  let detailsEl: HTMLDivElement;
 33  let animationRef: AnimationPlaybackControlsWithThen | null = null;
 34  
 35  const totalChanges = $derived(
 36  	prs.reduce((sum, pr) => sum + pr.additions + pr.deletions, 0),
 37  );
 38  
 39  function toggleOpen() {
 40  	isOpen = !isOpen;
 41  
 42  	const prefersReduced = window.matchMedia(
 43  		"(prefers-reduced-motion: reduce)",
 44  	).matches;
 45  
 46  	if (prefersReduced) {
 47  		if (detailsEl) {
 48  			detailsEl.style.height = isOpen ? "auto" : "0px";
 49  			detailsEl.style.opacity = isOpen ? "1" : "0";
 50  		}
 51  		return;
 52  	}
 53  
 54  	if (animationRef) {
 55  		animationRef.stop();
 56  	}
 57  
 58  	if (isOpen) {
 59  		const targetHeight = detailsEl.scrollHeight;
 60  		detailsEl.style.height = "0px";
 61  		detailsEl.style.opacity = "0";
 62  
 63  		animationRef = animate(
 64  			detailsEl,
 65  			{ height: `${targetHeight}px`, opacity: 1 },
 66  			{ duration: 0.35, ease: [0.25, 0.46, 0.45, 0.94] },
 67  		);
 68  
 69  		animationRef.then(() => {
 70  			if (detailsEl) detailsEl.style.height = "auto";
 71  		});
 72  	} else {
 73  		const currentHeight = detailsEl.scrollHeight;
 74  		detailsEl.style.height = `${currentHeight}px`;
 75  
 76  		animationRef = animate(
 77  			detailsEl,
 78  			{ height: "0px", opacity: 0 },
 79  			{ duration: 0.25, ease: [0.25, 0.46, 0.45, 0.94] },
 80  		);
 81  	}
 82  }
 83  
 84  $effect(() => {
 85  	if (detailsEl) {
 86  		const initiallyOpen = untrack(() => isOpen);
 87  		detailsEl.style.height = initiallyOpen ? "auto" : "0px";
 88  		detailsEl.style.opacity = initiallyOpen ? "1" : "0";
 89  	}
 90  });
 91  </script>
 92  
 93  <div
 94  	class="bg-blur-xl bg-white/50 focus:outline-none rounded-xl border-[0.5px] border-pink-200/50"
 95  	data-open={isOpen}
 96  >
 97  	<button
 98  		type="button"
 99  		class="list-none w-full px-4 py-3 cursor-pointer focus:outline-none rounded-lg text-left"
100  		onclick={toggleOpen}
101  		aria-expanded={isOpen}
102  	>
103  		<div
104  			class="flex flex-row sm:items-center justify-between focus:outline-none"
105  		>
106  			<div class="flex items-center gap-2">
107  				<div
108  					class="text-pink-600 transition-transform duration-200"
109  					class:rotate-180={isOpen}
110  				>
111  					<CaretDownIcon class="inline-block size-3" />
112  				</div>
113  
114  				<h3 class="text-left font-mono text-sm text-pink-800">
115  					<a
116  						class="text-pink-800 inline-flex items-center hover:text-pink-500 transition-colors"
117  						href="https://github.com/{repository.full_name}"
118  						target="_blank"
119  						rel="noopener noreferrer"
120  						onclick={(e) => e.stopPropagation()}
121  					>
122  						<span class="hidden sm:inline">
123  							{repository.full_name}
124  						</span>
125  						<span class="inline sm:hidden">
126  							{repository.full_name.split("/").at(-1)}
127  						</span>
128  						<ArrowUpRight class="inline-block size-3" />
129  					</a>
130  				</h3>
131  			</div>
132  
133  			<div class="flex-col items-end">
134  				<div class="flex items-center justify-end gap-2">
135  					<div
136  						class="flex items-center justify-end gap-1 font-mono text-xs text-pink-950/70"
137  					>
138  						<GitPullRequestIcon class="size-4 text-teal-500" />
139  						<span class="font-bold">{prs.length}</span>
140  					</div>
141  					<div
142  						class="flex items-center justify-end gap-1 font-mono text-xs text-pink-950/70"
143  					>
144  						<StarIcon class="size-3 text-yellow-400" />
145  						<span class="font-bold">
146  							{abbreviateNumber(repository.stargazerCount)}
147  						</span>
148  					</div>
149  				</div>
150  				<p class="font-mono text-xs text-pink-950/50 mt-1 text-right">
151  					±{totalChanges.toLocaleString()} changes
152  				</p>
153  			</div>
154  		</div>
155  	</button>
156  
157  	<div bind:this={detailsEl} class="overflow-hidden">
158  		<div class="border-t border-pink-200/50">
159  			<div id="pr-details-{repository.name}" class="p-3">
160  				<div class="space-y-2">
161  					{#each prs as pr (pr.id)}
162  						<div
163  							class="not-last:border-b border-pink-200/50 not-last:pb-2"
164  						>
165  							<div class="flex items-start justify-between gap-3">
166  								<div class="flex-1 min-w-0">
167  									<a
168  										href={pr.url}
169  										target="_blank"
170  										rel="noopener noreferrer"
171  										class="font-body text-sm font-medium text-pink-950 hover:text-pink-700 transition-colors line-clamp-2"
172  									>
173  										{pr.title}
174  									</a>
175  									<p
176  										class="font-mono text-xs text-pink-950/50 mt-1"
177  									>
178  										#{pr.number} • merged {new Date(
179  											pr.merged_at || "",
180  										).toLocaleDateString()}
181  									</p>
182  								</div>
183  								<div
184  									class="flex flex-col items-end gap-1 text-xs font-mono text-pink-950/70"
185  								>
186  									<div class="flex items-center gap-1">
187  										<span class="text-green-600"
188  											>+{pr.additions}</span
189  										>
190  										<span class="text-red-600"
191  											>-{pr.deletions}</span
192  										>
193  									</div>
194  									<div class="text-pink-950/50">
195  										{pr.changed_files} file{pr.changed_files !==
196  										1
197  											? "s"
198  											: ""}
199  									</div>
200  								</div>
201  							</div>
202  						</div>
203  					{/each}
204  				</div>
205  			</div>
206  		</div>
207  	</div>
208  </div>