your personal website on atproto - mirror
blento.app
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>