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, cn, Label, Popover } from '@foxui/core';
7 import { ColorSelect } from '@foxui/colors';
8 import { AllCardDefinitions, CardDefinitionsByType, getColor } from '..';
9 import { COLUMNS } from '$lib';
10 import {
11 getCanEdit,
12 getIsCoarse,
13 getIsMobile,
14 getSelectedCardId,
15 getSelectCard
16 } from '$lib/website/context';
17 import PlainTextEditor from '$lib/components/PlainTextEditor.svelte';
18 import { fixAllCollisions, fixCollisions } from '$lib/helper';
19
20 let colorsChoices = [
21 { class: 'text-base-500', label: 'base' },
22 { class: 'text-accent-500', label: 'accent' },
23 { class: 'text-base-300 dark:text-base-700', label: 'transparent' },
24 { class: 'text-red-500', label: 'red' },
25 { class: 'text-orange-500', label: 'orange' },
26 { class: 'text-amber-500', label: 'amber' },
27 { class: 'text-yellow-500', label: 'yellow' },
28 { class: 'text-lime-500', label: 'lime' },
29 { class: 'text-green-500', label: 'green' },
30 { class: 'text-emerald-500', label: 'emerald' },
31 { class: 'text-teal-500', label: 'teal' },
32 { class: 'text-cyan-500', label: 'cyan' },
33 { class: 'text-sky-500', label: 'sky' },
34 { class: 'text-blue-500', label: 'blue' },
35 { class: 'text-indigo-500', label: 'indigo' },
36 { class: 'text-violet-500', label: 'violet' },
37 { class: 'text-purple-500', label: 'purple' },
38 { class: 'text-fuchsia-500', label: 'fuchsia' },
39 { class: 'text-pink-500', label: 'pink' },
40 { class: 'text-rose-500', label: 'rose' }
41 ];
42
43 export type BaseEditingCardProps = {
44 item: Item;
45 ondelete: () => void;
46 onsetsize: (newW: number, newH: number) => void;
47 } & WithElementRef<HTMLAttributes<HTMLDivElement>>;
48
49 let {
50 item = $bindable(),
51 children,
52 ref = $bindable(null),
53 onsetsize,
54 ondelete,
55 ...rest
56 }: BaseEditingCardProps = $props();
57
58 let selectedColor = $derived(colorsChoices.find((c) => getColor(item) === c.label));
59
60 let canEdit = getCanEdit();
61 let isMobile = getIsMobile();
62 let isCoarse = getIsCoarse();
63
64 let selectedCardId = getSelectedCardId();
65 let selectCard = getSelectCard();
66 let isSelected = $derived(selectedCardId?.() === item.id);
67 let isDimmed = $derived(isCoarse?.() && selectedCardId?.() != null && !isSelected);
68
69 let colorPopoverOpen = $state(false);
70
71 const cardDef = $derived(CardDefinitionsByType[item.cardType] ?? {});
72
73 const minW = $derived(cardDef.minW ?? (isMobile() ? 2 : 2));
74 const minH = $derived(cardDef.minH ?? (isMobile() ? 2 : 2));
75
76 const maxW = $derived(cardDef.maxW ?? COLUMNS);
77 const maxH = $derived(cardDef.maxH ?? (isMobile() ? 12 : 6));
78
79 // Resize handle state
80 let isResizing = $state(false);
81 let resizeStartX = $state(0);
82 let resizeStartY = $state(0);
83 let resizeStartW = $state(0);
84 let resizeStartH = $state(0);
85
86 function handleResizeStart(e: PointerEvent) {
87 e.preventDefault();
88 e.stopPropagation();
89 isResizing = true;
90 resizeStartX = e.clientX;
91 resizeStartY = e.clientY;
92 // For mobile view, sizes are doubled so we need to account for that
93 resizeStartW = isMobile() ? (item.mobileW ?? item.w) : item.w;
94 resizeStartH = isMobile() ? (item.mobileH ?? item.h) : item.h;
95
96 document.addEventListener('pointermove', handleResizeMove);
97 document.addEventListener('pointerup', handleResizeEnd);
98 }
99
100 function handleResizeMove(e: PointerEvent) {
101 if (!isResizing || !ref) return;
102
103 // Get the container width to calculate cell size
104 const container = ref.closest('.\\@container\\/grid') as HTMLElement;
105 if (!container) return;
106
107 const containerRect = container.getBoundingClientRect();
108 const cellSize = containerRect.width / COLUMNS;
109
110 // Calculate delta in grid units (each visual unit is 2 grid units)
111 const deltaX = e.clientX - resizeStartX;
112 const deltaY = e.clientY - resizeStartY;
113
114 // Convert pixel delta to grid units (2 grid units = 1 visual cell)
115 const gridDeltaW = Math.round(deltaX / cellSize);
116 const gridDeltaH = Math.round(deltaY / cellSize);
117
118 let newW = resizeStartW + gridDeltaW;
119 let newH = resizeStartH + gridDeltaH;
120
121 if (isMobile()) {
122 newW = Math.round(newW / 4) * 4;
123 } else {
124 newW = Math.round(newW / 2) * 2;
125 }
126 let mult = isMobile() ? 2 : 1;
127
128 // Clamp to min/max
129 newW = Math.max(minW * mult, Math.min(maxW, newW));
130 newH = Math.max(minH * mult, Math.min(maxH, newH));
131
132 // Only call onsetsize if size changed
133 const currentW = isMobile() ? (item.mobileW ?? item.w) : item.w;
134 const currentH = isMobile() ? (item.mobileH ?? item.h) : item.h;
135
136 if (newW !== currentW || newH !== currentH) {
137 onsetsize?.(newW, newH);
138 }
139 }
140
141 function handleResizeEnd() {
142 isResizing = false;
143 document.removeEventListener('pointermove', handleResizeMove);
144 document.removeEventListener('pointerup', handleResizeEnd);
145 }
146
147 function canSetSize(w: number, h: number) {
148 if (!cardDef) return false;
149
150 if (isMobile()) {
151 return w >= minW && w * 2 <= maxW && h >= minH && h * 2 <= maxH;
152 }
153
154 return w >= minW && w <= maxW && h >= minH && h <= maxH;
155 }
156
157 function setSize(w: number, h: number) {
158 if (isMobile()) {
159 w *= 2;
160 h *= 2;
161 }
162 onsetsize?.(w, h);
163 }
164
165 let settingsPopoverOpen = $state(false);
166 let changePopoverOpen = $state(false);
167
168 const changeOptions = $derived(AllCardDefinitions.filter((def) => def.canChange?.(item)));
169
170 function applyChange(def: (typeof AllCardDefinitions)[number]) {
171 const updated = def.change ? def.change(item) : item;
172 if (updated && updated !== item) {
173 item = updated;
174 }
175 item.cardType = def.type;
176 changePopoverOpen = false;
177 }
178
179 function getChangeLabel(def: (typeof AllCardDefinitions)[number]) {
180 return def.name;
181 }
182</script>
183
184<BaseCard
185 {item}
186 isEditing={true}
187 bind:ref
188 showOutline={isResizing || (isCoarse?.() && isSelected)}
189 locked={item.cardData?.locked}
190 class={[
191 'scale-100 starting:scale-0 starting:opacity-0',
192 isCoarse?.() && isSelected ? 'ring-accent-500 z-10 ring-2 ring-offset-2' : '',
193 isDimmed ? 'opacity-70' : 'opacity-100'
194 ]}
195 {...rest}
196>
197 {#if isCoarse?.() ? !isSelected : !item.cardData?.locked}
198 <!-- svelte-ignore a11y_click_events_have_key_events -->
199 <div
200 role="button"
201 tabindex="-1"
202 class={['absolute inset-0', isCoarse?.() ? 'z-20 cursor-pointer' : 'cursor-grab']}
203 onclick={(e) => {
204 if (isCoarse?.()) {
205 e.stopPropagation();
206 selectCard?.(item.id);
207 }
208 }}
209 ></div>
210 {/if}
211 {@render children?.()}
212
213 {#if cardDef.canHaveLabel}
214 <div
215 class={cn(
216 'bg-base-200/50 dark:bg-base-900/50 absolute top-2 left-2 z-100 w-fit max-w-[calc(100%-1rem)] rounded-xl p-1 px-2 backdrop-blur-md',
217 !item.cardData.label && 'hidden lg:group-hover/card:block'
218 )}
219 >
220 <PlainTextEditor
221 class="text-base-900 dark:text-base-50 w-fit text-base font-semibold"
222 key="label"
223 bind:contentDict={item.cardData}
224 placeholder="Label"
225 />
226 </div>
227 {/if}
228
229 {#snippet controls()}
230 <!-- 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" -->
231 {#if canEdit()}
232 {#if changeOptions.length > 1}
233 <div
234 class={[
235 'absolute -top-3 -right-3 hidden lg:group-focus-within:inline-flex lg:group-hover/card:inline-flex',
236 changePopoverOpen ? 'inline-flex' : ''
237 ]}
238 >
239 <Popover bind:open={changePopoverOpen} class="bg-base-50 dark:bg-base-900">
240 {#snippet child({ props })}
241 <Button size="icon" variant="secondary" {...props}>
242 <svg
243 xmlns="http://www.w3.org/2000/svg"
244 fill="none"
245 viewBox="0 0 24 24"
246 stroke-width="1.5"
247 stroke="currentColor"
248 class="size-6"
249 >
250 <path
251 stroke-linecap="round"
252 stroke-linejoin="round"
253 d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5"
254 />
255 </svg>
256
257 <span class="sr-only">Change card type</span>
258 </Button>
259 {/snippet}
260
261 <div class="flex min-w-36 flex-col gap-1">
262 <Label class="mb-2">Card type</Label>
263 {#each changeOptions as changeDef, i (i)}
264 <Button
265 class="justify-start"
266 variant={changeDef.type === item.cardType ? 'primary' : 'ghost'}
267 onclick={() => applyChange(changeDef)}
268 >
269 {getChangeLabel(changeDef)}
270 </Button>
271 {/each}
272 </div>
273 </Popover>
274 </div>
275 {/if}
276
277 <Button
278 size="icon"
279 variant="rose"
280 onclick={() => {
281 ondelete();
282 }}
283 class="absolute -top-3 -left-3 hidden lg:group-focus-within:inline-flex lg:group-hover/card:inline-flex"
284 >
285 <svg
286 xmlns="http://www.w3.org/2000/svg"
287 fill="none"
288 viewBox="0 0 24 24"
289 stroke-width="1.5"
290 stroke="currentColor"
291 >
292 <path
293 stroke-linecap="round"
294 stroke-linejoin="round"
295 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"
296 />
297 </svg>
298
299 <span class="sr-only">Delete card</span>
300 </Button>
301
302 <div
303 class={[
304 'absolute -bottom-7 w-full items-center justify-center text-xs lg:group-focus-within:inline-flex lg:group-hover/card:inline-flex',
305 colorPopoverOpen || settingsPopoverOpen ? 'inline-flex' : 'hidden'
306 ]}
307 >
308 <div
309 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"
310 >
311 {#if cardDef.allowSetColor !== false}
312 <Popover bind:open={colorPopoverOpen}>
313 {#snippet child({ props })}
314 <button
315 {...props}
316 class={[
317 'm-2 size-4 cursor-pointer rounded-full',
318 !item.color || item.color === 'base' || item.color === 'transparent'
319 ? 'text-base-800 dark:text-base-200'
320 : 'text-accent-500'
321 ]}
322 >
323 <svg
324 xmlns="http://www.w3.org/2000/svg"
325 viewBox="0 0 24 24"
326 fill="currentColor"
327 class="size-4"
328 >
329 <path
330 fill-rule="evenodd"
331 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"
332 clip-rule="evenodd"
333 />
334 </svg>
335 </button>
336 {/snippet}
337 <ColorSelect
338 selected={selectedColor}
339 colors={colorsChoices}
340 onselected={(color, previous) => {
341 if (typeof previous === 'string' || typeof color === 'string') {
342 return;
343 }
344
345 item.color = color.label;
346 }}
347 class="w-64"
348 />
349 </Popover>
350 {/if}
351
352 {#if canSetSize(2, 2)}
353 <button
354 onclick={() => {
355 setSize(2, 2);
356 }}
357 class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2"
358 >
359 <div class="border-base-900 dark:border-base-50 size-3 rounded-sm border-2"></div>
360
361 <span class="sr-only">set size to 1x1</span>
362 </button>
363 {/if}
364
365 {#if canSetSize(4, 2)}
366 <button
367 onclick={() => {
368 setSize(4, 2);
369 }}
370 class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2"
371 >
372 <div class="border-base-900 dark:border-base-50 h-3 w-5 rounded-sm border-2"></div>
373 <span class="sr-only">set size to 2x1</span>
374 </button>
375 {/if}
376 {#if canSetSize(2, 4)}
377 <button
378 onclick={() => {
379 setSize(2, 4);
380 }}
381 class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2"
382 >
383 <div class="border-base-900 dark:border-base-50 h-5 w-3 rounded-sm border-2"></div>
384
385 <span class="sr-only">set size to 1x2</span>
386 </button>
387 {/if}
388 {#if canSetSize(4, 4)}
389 <button
390 onclick={() => {
391 setSize(4, 4);
392 }}
393 class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2"
394 >
395 <div class="border-base-900 dark:border-base-50 h-5 w-5 rounded-sm border-2"></div>
396
397 <span class="sr-only">set size to 2x2</span>
398 </button>
399 {/if}
400
401 {#if cardDef.settingsComponent}
402 <Popover bind:open={settingsPopoverOpen} class="bg-base-50 dark:bg-base-900">
403 {#snippet child({ props })}
404 <button {...props} class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2">
405 <svg
406 xmlns="http://www.w3.org/2000/svg"
407 fill="none"
408 viewBox="0 0 24 24"
409 stroke-width="2"
410 stroke="currentColor"
411 class="size-5"
412 >
413 <path
414 stroke-linecap="round"
415 stroke-linejoin="round"
416 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"
417 />
418 <path
419 stroke-linecap="round"
420 stroke-linejoin="round"
421 d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
422 />
423 </svg>
424 </button>
425 {/snippet}
426 <cardDef.settingsComponent
427 bind:item
428 onclose={() => {
429 settingsPopoverOpen = false;
430 }}
431 />
432 </Popover>
433 {/if}
434 </div>
435 </div>
436
437 {#if cardDef.canResize !== false}
438 <!-- Resize handle at bottom right corner -->
439 <div
440 onpointerdown={handleResizeStart}
441 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 lg:group-hover/card:block"
442 >
443 <svg
444 xmlns="http://www.w3.org/2000/svg"
445 viewBox="0 0 24 24"
446 fill="none"
447 stroke="currentColor"
448 stroke-width="2"
449 stroke-linecap="round"
450 stroke-linejoin="round"
451 class=" dark:text-base-400 text-base-600 size-4"
452 >
453 <circle cx="12" cy="5" r="1" /><circle cx="19" cy="5" r="1" /><circle
454 cx="5"
455 cy="5"
456 r="1"
457 />
458 <circle cx="12" cy="12" r="1" /><circle cx="19" cy="12" r="1" /><circle
459 cx="5"
460 cy="12"
461 r="1"
462 />
463 <circle cx="12" cy="19" r="1" /><circle cx="19" cy="19" r="1" /><circle
464 cx="5"
465 cy="19"
466 r="1"
467 />
468 </svg>
469 <span class="sr-only">Resize card</span>
470 </div>
471 {/if}
472 {/if}
473 {/snippet}
474</BaseCard>