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>