your personal website on atproto - mirror
blento.app
1<script lang="ts">
2 import type { WithElementRef } from 'bits-ui';
3 import type { HTMLAttributes } from 'svelte/elements';
4 import BaseCard from './BaseCard.svelte';
5 import type { Item } from '$lib/types';
6 import { Button, Label, Popover } from '@foxui/core';
7 import { ColorSelect } from '@foxui/colors';
8 import { AllCardDefinitions, CardDefinitionsByType, getColor } from '..';
9 import { COLUMNS } from '$lib';
10 import { getCanEdit, getIsMobile } from '$lib/website/context';
11
12 let colorsChoices = [
13 { class: 'text-base-500', label: 'base' },
14 { class: 'text-accent-500', label: 'accent' },
15 { class: 'text-base-300 dark:text-base-700', label: 'transparent' },
16 { class: 'text-red-500', label: 'red' },
17 { class: 'text-orange-500', label: 'orange' },
18 { class: 'text-amber-500', label: 'amber' },
19 { class: 'text-yellow-500', label: 'yellow' },
20 { class: 'text-lime-500', label: 'lime' },
21 { class: 'text-green-500', label: 'green' },
22 { class: 'text-emerald-500', label: 'emerald' },
23 { class: 'text-teal-500', label: 'teal' },
24 { class: 'text-cyan-500', label: 'cyan' },
25 { class: 'text-sky-500', label: 'sky' },
26 { class: 'text-blue-500', label: 'blue' },
27 { class: 'text-indigo-500', label: 'indigo' },
28 { class: 'text-violet-500', label: 'violet' },
29 { class: 'text-purple-500', label: 'purple' },
30 { class: 'text-fuchsia-500', label: 'fuchsia' },
31 { class: 'text-pink-500', label: 'pink' },
32 { class: 'text-rose-500', label: 'rose' }
33 ];
34
35 export type BaseEditingCardProps = {
36 item: Item;
37 ondelete: () => void;
38 onsetsize: (newW: number, newH: number) => void;
39 } & WithElementRef<HTMLAttributes<HTMLDivElement>>;
40
41 let {
42 item = $bindable(),
43 children,
44 ref = $bindable(null),
45 onsetsize,
46 ondelete,
47 ...rest
48 }: BaseEditingCardProps = $props();
49
50 let selectedColor = $derived(colorsChoices.find((c) => getColor(item) === c.label));
51
52 let canEdit = getCanEdit();
53 let isMobile = getIsMobile();
54
55 let colorPopoverOpen = $state(false);
56
57 const cardDef = $derived(CardDefinitionsByType[item.cardType]);
58
59 const minW = $derived(cardDef.minW ?? (isMobile() ? 2 : 2));
60 const minH = $derived(cardDef.minH ?? (isMobile() ? 2 : 2));
61
62 const maxW = $derived(cardDef.maxW ?? COLUMNS);
63 const maxH = $derived(cardDef.maxH ?? (isMobile() ? 12 : 6));
64
65 // Resize handle state
66 let isResizing = $state(false);
67 let resizeStartX = $state(0);
68 let resizeStartY = $state(0);
69 let resizeStartW = $state(0);
70 let resizeStartH = $state(0);
71
72 function handleResizeStart(e: PointerEvent) {
73 e.preventDefault();
74 e.stopPropagation();
75 isResizing = true;
76 resizeStartX = e.clientX;
77 resizeStartY = e.clientY;
78 // For mobile view, sizes are doubled so we need to account for that
79 resizeStartW = isMobile() ? (item.mobileW ?? item.w) : item.w;
80 resizeStartH = isMobile() ? (item.mobileH ?? item.h) : item.h;
81
82 document.addEventListener('pointermove', handleResizeMove);
83 document.addEventListener('pointerup', handleResizeEnd);
84 }
85
86 function handleResizeMove(e: PointerEvent) {
87 if (!isResizing || !ref) return;
88
89 // Get the container width to calculate cell size
90 const container = ref.closest('.\\@container\\/grid') as HTMLElement;
91 if (!container) return;
92
93 const containerRect = container.getBoundingClientRect();
94 const cellSize = containerRect.width / COLUMNS;
95
96 // Calculate delta in grid units (each visual unit is 2 grid units)
97 const deltaX = e.clientX - resizeStartX;
98 const deltaY = e.clientY - resizeStartY;
99
100 // Convert pixel delta to grid units (2 grid units = 1 visual cell)
101 const gridDeltaW = Math.round(deltaX / cellSize);
102 const gridDeltaH = Math.round(deltaY / cellSize);
103
104 let newW = resizeStartW + gridDeltaW;
105 let newH = resizeStartH + gridDeltaH;
106
107 if (isMobile()) {
108 newW = Math.round(newW / 4) * 4;
109 } else {
110 newW = Math.round(newW / 2) * 2;
111 }
112 let mult = isMobile() ? 2 : 1;
113
114 // Clamp to min/max
115 newW = Math.max(minW * mult, Math.min(maxW, newW));
116 newH = Math.max(minH * mult, Math.min(maxH, newH));
117
118 // Only call onsetsize if size changed
119 const currentW = isMobile() ? (item.mobileW ?? item.w) : item.w;
120 const currentH = isMobile() ? (item.mobileH ?? item.h) : item.h;
121
122 if (newW !== currentW || newH !== currentH) {
123 onsetsize?.(newW, newH);
124 }
125 }
126
127 function handleResizeEnd() {
128 isResizing = false;
129 document.removeEventListener('pointermove', handleResizeMove);
130 document.removeEventListener('pointerup', handleResizeEnd);
131 }
132
133 function canSetSize(w: number, h: number) {
134 if (!cardDef) return false;
135
136 if (isMobile()) {
137 return w >= minW && w * 2 <= maxW && h >= minH && h * 2 <= maxH;
138 }
139
140 return w >= minW && w <= maxW && h >= minH && h <= maxH;
141 }
142
143 function setSize(w: number, h: number) {
144 if (isMobile()) {
145 w *= 2;
146 h *= 2;
147 }
148 onsetsize?.(w, h);
149 }
150
151 let settingsPopoverOpen = $state(false);
152 let changePopoverOpen = $state(false);
153
154 const changeOptions = $derived(
155 AllCardDefinitions.filter((def) => def.canChange?.(item))
156 );
157
158 function applyChange(def: (typeof AllCardDefinitions)[number]) {
159 const updated = def.change ? def.change(item) : item;
160 if (updated && updated !== item) {
161 item = updated;
162 }
163 item.cardType = def.type;
164 changePopoverOpen = false;
165 }
166
167 function getChangeLabel(def: (typeof AllCardDefinitions)[number]) {
168 return def.name;
169 }
170</script>
171
172<BaseCard
173 {item}
174 isEditing={true}
175 bind:ref
176 showOutline={isResizing}
177 class="scale-100 opacity-100 starting:scale-0 starting:opacity-0"
178 {...rest}
179>
180 <div class="absolute inset-0 cursor-grab"></div>
181 {@render children?.()}
182
183 {#snippet controls()}
184 <!-- class="bg-base-100 border-base-200 dark:bg-base-800 dark:border-base-700 absolute -top-3 -left-3 hidden cursor-pointer items-center justify-center rounded-full border p-2 shadow-lg group-focus-within:inline-flex group-hover/card:inline-flex" -->
185 {#if canEdit()}
186 {#if changeOptions.length > 1}
187 <div
188 class={[
189 'absolute -top-3 -right-3 hidden group-focus-within:inline-flex group-hover/card:inline-flex',
190 changePopoverOpen ? 'inline-flex' : ''
191 ]}
192 >
193 <Popover bind:open={changePopoverOpen} class="bg-base-50 dark:bg-base-900">
194 {#snippet child({ props })}
195 <Button size="icon" variant="secondary" {...props}>
196 <svg
197 xmlns="http://www.w3.org/2000/svg"
198 fill="none"
199 viewBox="0 0 24 24"
200 stroke-width="1.5"
201 stroke="currentColor"
202 class="size-6"
203 >
204 <path
205 stroke-linecap="round"
206 stroke-linejoin="round"
207 d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5"
208 />
209 </svg>
210
211 <span class="sr-only">Change card type</span>
212 </Button>
213 {/snippet}
214
215 <div class="flex min-w-36 flex-col gap-1">
216 <Label class="mb-2">Card type</Label>
217 {#each changeOptions as changeDef}
218 <Button
219 class="justify-start"
220 variant={changeDef.type === item.cardType ? 'primary' : 'ghost'}
221 onclick={() => applyChange(changeDef)}
222 >
223 {getChangeLabel(changeDef)}
224 </Button>
225 {/each}
226 </div>
227 </Popover>
228 </div>
229 {/if}
230
231 <Button
232 size="icon"
233 variant="rose"
234 onclick={() => {
235 ondelete();
236 }}
237 class="absolute -top-3 -left-3 hidden group-focus-within:inline-flex group-hover/card:inline-flex"
238 >
239 <svg
240 xmlns="http://www.w3.org/2000/svg"
241 fill="none"
242 viewBox="0 0 24 24"
243 stroke-width="1.5"
244 stroke="currentColor"
245 >
246 <path
247 stroke-linecap="round"
248 stroke-linejoin="round"
249 d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
250 />
251 </svg>
252
253 <span class="sr-only">Delete card</span>
254 </Button>
255
256 <div
257 class={[
258 'absolute -bottom-7 w-full items-center justify-center text-xs group-focus-within:inline-flex group-hover/card:inline-flex',
259 colorPopoverOpen || settingsPopoverOpen ? 'inline-flex' : 'hidden'
260 ]}
261 >
262 <div
263 class="bg-base-100 border-base-200 dark:bg-base-800 dark:border-base-700 z-100 inline-flex items-center gap-0.5 rounded-2xl border p-1 px-2 shadow-lg"
264 >
265 {#if cardDef.allowSetColor !== false}
266 <Popover bind:open={colorPopoverOpen}>
267 {#snippet child({ props })}
268 <button
269 {...props}
270 class={[
271 'm-2 size-4 cursor-pointer rounded-full',
272 !item.color || item.color === 'base' || item.color === 'transparent'
273 ? 'text-base-800 dark:text-base-200'
274 : 'text-accent-500'
275 ]}
276 >
277 <svg
278 xmlns="http://www.w3.org/2000/svg"
279 viewBox="0 0 24 24"
280 fill="currentColor"
281 class="size-4"
282 >
283 <path
284 fill-rule="evenodd"
285 d="M20.599 1.5c-.376 0-.743.111-1.055.32l-5.08 3.385a18.747 18.747 0 0 0-3.471 2.987 10.04 10.04 0 0 1 4.815 4.815 18.748 18.748 0 0 0 2.987-3.472l3.386-5.079A1.902 1.902 0 0 0 20.599 1.5Zm-8.3 14.025a18.76 18.76 0 0 0 1.896-1.207 8.026 8.026 0 0 0-4.513-4.513A18.75 18.75 0 0 0 8.475 11.7l-.278.5a5.26 5.26 0 0 1 3.601 3.602l.502-.278ZM6.75 13.5A3.75 3.75 0 0 0 3 17.25a1.5 1.5 0 0 1-1.601 1.497.75.75 0 0 0-.7 1.123 5.25 5.25 0 0 0 9.8-2.62 3.75 3.75 0 0 0-3.75-3.75Z"
286 clip-rule="evenodd"
287 />
288 </svg>
289 </button>
290 {/snippet}
291 <ColorSelect
292 selected={selectedColor}
293 colors={colorsChoices}
294 onselected={(color, previous) => {
295 if (typeof previous === 'string' || typeof color === 'string') {
296 return;
297 }
298
299 item.color = color.label;
300 }}
301 class="w-64"
302 />
303 </Popover>
304 {/if}
305
306 {#if canSetSize(2, 2)}
307 <button
308 onclick={() => {
309 setSize(2, 2);
310 }}
311 class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2"
312 >
313 <div class="border-base-900 dark:border-base-50 size-3 rounded-sm border-2"></div>
314
315 <span class="sr-only">set size to 1x1</span>
316 </button>
317 {/if}
318
319 {#if canSetSize(4, 2)}
320 <button
321 onclick={() => {
322 setSize(4, 2);
323 }}
324 class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2"
325 >
326 <div class="border-base-900 dark:border-base-50 h-3 w-5 rounded-sm border-2"></div>
327 <span class="sr-only">set size to 2x1</span>
328 </button>
329 {/if}
330 {#if canSetSize(2, 4)}
331 <button
332 onclick={() => {
333 setSize(2, 4);
334 }}
335 class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2"
336 >
337 <div class="border-base-900 dark:border-base-50 h-5 w-3 rounded-sm border-2"></div>
338
339 <span class="sr-only">set size to 1x2</span>
340 </button>
341 {/if}
342 {#if canSetSize(4, 4)}
343 <button
344 onclick={() => {
345 setSize(4, 4);
346 }}
347 class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2"
348 >
349 <div class="border-base-900 dark:border-base-50 h-5 w-5 rounded-sm border-2"></div>
350
351 <span class="sr-only">set size to 2x2</span>
352 </button>
353 {/if}
354
355 {#if cardDef.settingsComponent}
356 <Popover bind:open={settingsPopoverOpen} class="bg-base-50 dark:bg-base-900">
357 {#snippet child({ props })}
358 <button {...props} class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2">
359 <svg
360 xmlns="http://www.w3.org/2000/svg"
361 fill="none"
362 viewBox="0 0 24 24"
363 stroke-width="2"
364 stroke="currentColor"
365 class="size-5"
366 >
367 <path
368 stroke-linecap="round"
369 stroke-linejoin="round"
370 d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"
371 />
372 <path
373 stroke-linecap="round"
374 stroke-linejoin="round"
375 d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
376 />
377 </svg>
378 </button>
379 {/snippet}
380 <cardDef.settingsComponent
381 bind:item
382 onclose={() => {
383 settingsPopoverOpen = false;
384 }}
385 />
386 </Popover>
387 {/if}
388 </div>
389 </div>
390
391 {#if cardDef.canResize !== false}
392 <!-- Resize handle at bottom right corner -->
393 <!-- svelte-ignore a11y_no_static_element_interactions -->
394
395 <div
396 onpointerdown={handleResizeStart}
397 class="bg-base-300/70 dark:bg-base-900/70 pointer-events-auto absolute right-0.5 bottom-0.5 hidden cursor-se-resize rounded-md rounded-br-3xl p-1 group-hover/card:block"
398 >
399 <svg
400 xmlns="http://www.w3.org/2000/svg"
401 viewBox="0 0 24 24"
402 fill="none"
403 stroke="currentColor"
404 stroke-width="2"
405 stroke-linecap="round"
406 stroke-linejoin="round"
407 class=" dark:text-base-400 text-base-600 size-4"
408 >
409 <circle cx="12" cy="5" r="1" /><circle cx="19" cy="5" r="1" /><circle
410 cx="5"
411 cy="5"
412 r="1"
413 />
414 <circle cx="12" cy="12" r="1" /><circle cx="19" cy="12" r="1" /><circle
415 cx="5"
416 cy="12"
417 r="1"
418 />
419 <circle cx="12" cy="19" r="1" /><circle cx="19" cy="19" r="1" /><circle
420 cx="5"
421 cy="19"
422 r="1"
423 />
424 </svg>
425 <span class="sr-only">Resize card</span>
426 </div>
427 {/if}
428 {/if}
429 {/snippet}
430</BaseCard>