your personal website on atproto - mirror blento.app
at lastfm 180 lines 5.1 kB view raw
1<script lang="ts"> 2 import { Tooltip } from 'bits-ui'; 3 4 export type ImageGridItem = { 5 imageUrl: string | null; 6 link: string; 7 label: string; 8 }; 9 10 let { 11 items, 12 layout = 'grid', 13 shape = 'square', 14 tooltip = false 15 }: { 16 items: ImageGridItem[]; 17 layout?: 'grid' | 'cinema'; 18 shape?: 'square' | 'circle'; 19 tooltip?: boolean; 20 } = $props(); 21 22 let containerWidth = $state(0); 23 let containerHeight = $state(0); 24 25 let totalItems = $derived(items.length); 26 27 const GAP = 6; 28 const MIN_SIZE = 16; 29 const MAX_SIZE = 120; 30 31 function cinemaCapacity(size: number, availW: number, availH: number): number { 32 const colsWide = Math.floor((availW + GAP) / (size + GAP)); 33 if (colsWide < 1) return 0; 34 const colsNarrow = Math.max(1, colsWide - 1); 35 const maxRows = Math.floor((availH + GAP) / (size + GAP)); 36 let capacity = 0; 37 for (let r = 0; r < maxRows; r++) { 38 capacity += r % 2 === 0 ? colsNarrow : colsWide; 39 } 40 return capacity; 41 } 42 43 function gridCapacity(size: number, availW: number, availH: number): number { 44 const cols = Math.floor((availW + GAP) / (size + GAP)); 45 const rows = Math.floor((availH + GAP) / (size + GAP)); 46 return cols * rows; 47 } 48 49 let computedSize = $derived.by(() => { 50 if (!containerWidth || !containerHeight || totalItems === 0) return 40; 51 52 let lo = MIN_SIZE; 53 let hi = MAX_SIZE; 54 const capacityFn = layout === 'cinema' ? cinemaCapacity : gridCapacity; 55 56 while (lo <= hi) { 57 const mid = Math.floor((lo + hi) / 2); 58 const availW = containerWidth - (layout === 'cinema' ? mid / 2 : 0); 59 const availH = containerHeight - (layout === 'cinema' ? mid / 2 : 0); 60 if (availW <= 0 || availH <= 0) { 61 hi = mid - 1; 62 continue; 63 } 64 if (capacityFn(mid, availW, availH) >= totalItems) { 65 lo = mid + 1; 66 } else { 67 hi = mid - 1; 68 } 69 } 70 71 return Math.max(MIN_SIZE, hi); 72 }); 73 74 let padding = $derived(layout === 'cinema' ? computedSize / 4 : 0); 75 76 let rows = $derived.by(() => { 77 const availW = containerWidth - (layout === 'cinema' ? computedSize / 4 : 0); 78 if (availW <= 0) return [] as ImageGridItem[][]; 79 80 const colsWide = Math.floor((availW + GAP) / (computedSize + GAP)); 81 const colsNarrow = layout === 'cinema' ? Math.max(1, colsWide - 1) : colsWide; 82 83 const rowSizes: number[] = []; 84 let remaining = items.length; 85 let rowNum = 0; 86 while (remaining > 0) { 87 const cols = layout === 'cinema' && rowNum % 2 === 0 ? colsNarrow : colsWide; 88 rowSizes.push(Math.min(cols, remaining)); 89 remaining -= cols; 90 rowNum++; 91 } 92 rowSizes.reverse(); 93 94 const result: ImageGridItem[][] = []; 95 let idx = 0; 96 for (const size of rowSizes) { 97 result.push(items.slice(idx, idx + size)); 98 idx += size; 99 } 100 return result; 101 }); 102 103 let textSize = $derived( 104 computedSize < 24 ? 'text-[10px]' : computedSize < 40 ? 'text-xs' : 'text-sm' 105 ); 106 107 let shapeClass = $derived(shape === 'circle' ? 'rounded-full' : 'rounded-lg'); 108</script> 109 110{#snippet gridItem(item: ImageGridItem)} 111 {#if item.imageUrl} 112 <img 113 src={item.imageUrl} 114 alt={item.label} 115 class="{shapeClass} object-cover" 116 style="width: {computedSize}px; height: {computedSize}px;" 117 /> 118 {:else} 119 <div 120 class="bg-base-200 dark:bg-base-700 accent:bg-accent-400 flex items-center justify-center {shapeClass}" 121 style="width: {computedSize}px; height: {computedSize}px;" 122 > 123 <span class="text-base-500 dark:text-base-400 accent:text-accent-100 {textSize} font-medium"> 124 {item.label.charAt(0).toUpperCase()} 125 </span> 126 </div> 127 {/if} 128{/snippet} 129 130<div 131 class="flex h-full w-full items-center justify-center overflow-hidden px-2" 132 bind:clientWidth={containerWidth} 133 bind:clientHeight={containerHeight} 134> 135 {#if totalItems > 0} 136 <div style="padding: {padding}px;"> 137 <div class="flex flex-col items-center" style="gap: {GAP}px;"> 138 {#each rows as row, rowIdx (rowIdx)} 139 <div class="flex justify-center" style="gap: {GAP}px;"> 140 {#each row as item (item.link)} 141 {#if tooltip} 142 <Tooltip.Root> 143 <Tooltip.Trigger> 144 <a 145 href={item.link} 146 target="_blank" 147 rel="noopener noreferrer" 148 class="accent:ring-accent-500 block {shapeClass} ring-2 ring-white transition-transform hover:scale-110 dark:ring-neutral-900" 149 > 150 {@render gridItem(item)} 151 </a> 152 </Tooltip.Trigger> 153 <Tooltip.Portal> 154 <Tooltip.Content 155 side="top" 156 sideOffset={4} 157 class="bg-base-900 dark:bg-base-800 text-base-100 z-50 rounded-lg px-3 py-1.5 text-xs font-medium shadow-md" 158 > 159 {item.label} 160 </Tooltip.Content> 161 </Tooltip.Portal> 162 </Tooltip.Root> 163 {:else} 164 <a 165 href={item.link} 166 target="_blank" 167 rel="noopener noreferrer" 168 class="accent:ring-accent-500 block {shapeClass} ring-2 ring-white transition-transform hover:scale-110 dark:ring-neutral-900" 169 title={item.label} 170 > 171 {@render gridItem(item)} 172 </a> 173 {/if} 174 {/each} 175 </div> 176 {/each} 177 </div> 178 </div> 179 {/if} 180</div>