223 lines
6.1 kB
1
import { get } from '@dumbjs/pick/get'
2
import { formatDistanceToNow } from 'date-fns/formatDistanceToNow'
3
import { useMeta, useTitle } from 'adex/head'
4
5
import { computed, signal } from '@preact/signals'
6
import { Image } from '../components/Image'
7
8
/**
9
* @type {ReturnType<typeof microsearch>}
10
*/
11
let searcher
12
13
const sites$ = signal([])
14
15
const MIN_CARD_WIDTH = 280
16
17
async function getData() {
18
const response = await fetch('/api/data').then(d => d.json())
19
searcher = microsearch(response, ['title', 'link'])
20
return response
21
}
22
23
if (typeof window != 'undefined') {
24
;(async () => {
25
const data = await getData()
26
sites$.value = data.sort((x, y) => x.title.localeCompare(y.title))
27
})()
28
}
29
30
const recents = computed(() =>
31
sites$.value
32
.toSorted(
33
(x, y) => new Date(y.addedOn).getTime() - new Date(x.addedOn).getTime()
34
)
35
.slice(0, 4)
36
)
37
38
const containerWidth = signal(900)
39
const offset = { value: 8 }
40
const columns = signal(3)
41
42
const bentoPositions = computed(() => {
43
const sites = sites$.value
44
const containerW = containerWidth.value
45
const cols = columns.value
46
const offsetVal = offset.value
47
const count = sites.length
48
const cardWidth = containerW / cols
49
const withPositions = new Array(count)
50
51
for (let i = 0; i < count; i++) {
52
const d = sites[i]
53
const originalHeight = d.dimensions.height ?? 543
54
const originalWidth = d.dimensions.width ?? 1039
55
const ratio = originalHeight / originalWidth
56
const heightByContainer = cardWidth * ratio
57
58
let left = 0
59
let top = 0
60
61
// Calculate horizontal position:
62
if (i % cols !== 0) {
63
const prev = withPositions[i - 1]
64
left = prev.left + prev.width + offsetVal / 2
65
} else {
66
left = 0
67
}
68
69
// Calculate vertical position if not in the first row:
70
if (i >= cols) {
71
const prevByCol = withPositions[i - cols]
72
top = prevByCol.top + prevByCol.height + offsetVal
73
}
74
75
withPositions[i] = {
76
top,
77
left,
78
height: heightByContainer,
79
width: cardWidth,
80
}
81
}
82
return withPositions
83
})
84
85
function useDefaultHead() {
86
useTitle('minweb.site | Minimal Websites Gallery')
87
useMeta({
88
name: 'viewport',
89
content: 'width=device-width, initial-scale=1.0',
90
})
91
}
92
93
export default () => {
94
useDefaultHead()
95
96
return (
97
<div class="p-2 mx-auto sm:p-5 md:p-10 max-w-screen" ref={onGridMount()}>
98
<div class="flex justify-end w-full">
99
<ul class="flex gap-2 items-center mx-2 font-sans text-xs">
100
<li>
101
<a
102
class="text-zinc-600 hover:underline hover:underline-offset-4 hover:text-black"
103
href="https://github.com/barelyhuman/minweb-public-data?tab=readme-ov-file#add-another-site"
104
>
105
Add your site?
106
</a>
107
</li>
108
</ul>
109
110
<h1 class="font-sans text-sm text-zinc-400">minweb.site</h1>
111
</div>
112
<div class="my-24">
113
<h2 class="font-semibold">Recent</h2>
114
<ul class="flex flex-col gap-4 mt-8 w-full">
115
{recents.value.map(d => {
116
return (
117
<li class="w-full text-zinc-500">
118
<a
119
href={d.link}
120
class="relative w-full transition duration-300 link"
121
>
122
{d.title}
123
<span class="font-sans italic absolute top-0 text-[8px] text-emerald-400 -right-99 min-w-44">
124
{formatDistanceToNow(new Date(d.addedOn), {
125
addSuffix: true,
126
})}
127
</span>
128
</a>
129
</li>
130
)
131
})}
132
</ul>
133
</div>
134
<div class="my-24">
135
<h2 class="font-semibold">Gallery</h2>
136
<div class="relative gap-2 mt-4 w-full">
137
{sites$.value.map((d, ind) => {
138
const pos = Object.fromEntries(
139
Object.entries(bentoPositions.value[ind]).map(d => [
140
d[0],
141
d[1] + 'px',
142
])
143
)
144
return (
145
<div
146
class="inline-flex absolute justify-center items-center hover:cursor-pointer text-zinc-500"
147
style={{
148
...pos,
149
}}
150
>
151
<a href={d.link} class="transition-all transition hover:px-1">
152
<Image
153
src={d.imageURL}
154
className="rounded-md"
155
classNameOnLoad="border-2 border-black"
156
/>
157
</a>
158
</div>
159
)
160
})}
161
</div>
162
</div>
163
</div>
164
)
165
}
166
167
const listenToContainerBoundaries = node => {
168
const computedStyle = getComputedStyle(node)
169
const widthWithoutPadding =
170
node.getBoundingClientRect().width -
171
(parseFloat(computedStyle.paddingLeft) +
172
parseFloat(computedStyle.paddingRight))
173
174
const negationWidthForOffset = offset.value
175
let colsWithOffset = Math.floor(
176
(widthWithoutPadding - negationWidthForOffset) / MIN_CARD_WIDTH
177
)
178
179
if (colsWithOffset <= 1) {
180
colsWithOffset = 1
181
}
182
183
containerWidth.value = widthWithoutPadding - negationWidthForOffset
184
columns.value = colsWithOffset
185
}
186
187
function onGridMount() {
188
const debouncedListenToContainerBoundaries = debounce(
189
listenToContainerBoundaries,
190
350
191
)
192
return node => {
193
if (!node) return
194
window.addEventListener('resize', () => {
195
debouncedListenToContainerBoundaries(node)
196
})
197
debouncedListenToContainerBoundaries(node)
198
}
199
}
200
201
function microsearch(collection, paths) {
202
const index = collection.map(d => paths.map(p => get(d, p)))
203
return term => {
204
return index
205
.map((d, index) => {
206
return [d, index]
207
})
208
.filter(val =>
209
val[0].find(t => t.toLowerCase().includes(term.toLowerCase()))
210
)
211
.map(matches => collection[matches[1]])
212
}
213
}
214
215
function debounce(fn, delay) {
216
let handler
217
return (...args) => {
218
if (handler) clearTimeout(handler)
219
handler = setTimeout(() => {
220
fn(...args)
221
}, delay)
222
}
223
}
224