your personal website on atproto - mirror
blento.app
1<script lang="ts">
2 import { dev } from '$app/environment';
3 import { user } from '$lib/atproto';
4 import { COLUMNS } from '$lib';
5 import type { Item, WebsiteData } from '$lib/types';
6 import { CardDefinitionsByType } from '$lib/cards';
7 import { Button, Input, Navbar, Popover, Toggle, toast } from '@foxui/core';
8 import { ColorSelect } from '@foxui/colors';
9
10 let {
11 data,
12 linkValue = $bindable(),
13 newCard,
14 addLink,
15
16 showingMobileView = $bindable(),
17 isSaving = $bindable(),
18 hasUnsavedChanges,
19
20 save,
21
22 handleImageInputChange,
23 handleVideoInputChange,
24
25 showCardCommand,
26 selectedCard = null,
27 isMobile = false,
28 isCoarse = false,
29 ondeselect,
30 ondelete,
31 onsetsize
32 }: {
33 data: WebsiteData;
34 linkValue: string;
35 newCard: (type: string) => void;
36 addLink: (url: string) => void;
37
38 showingMobileView: boolean;
39
40 isSaving: boolean;
41 hasUnsavedChanges: boolean;
42
43 save: () => Promise<void>;
44
45 handleImageInputChange: (evt: Event) => void;
46 handleVideoInputChange: (evt: Event) => void;
47
48 showCardCommand: () => void;
49 selectedCard?: Item | null;
50 isMobile?: boolean;
51 isCoarse?: boolean;
52 ondeselect?: () => void;
53 ondelete?: () => void;
54 onsetsize?: (w: number, h: number) => void;
55 } = $props();
56
57 let linkPopoverOpen = $state(false);
58
59 let imageInputRef: HTMLInputElement | undefined = $state();
60 let videoInputRef: HTMLInputElement | undefined = $state();
61
62 function getShareUrl() {
63 const base = typeof window !== 'undefined' ? window.location.origin : '';
64 const pagePath =
65 data.page && data.page !== 'blento.self' ? `/${data.page.replace('blento.', '')}` : '';
66 return `${base}/${data.handle}${pagePath}`;
67 }
68
69 async function copyShareLink() {
70 const url = getShareUrl();
71 await navigator.clipboard.writeText(url);
72 toast.success('Link copied to clipboard!');
73 }
74
75 let colorsChoices = [
76 { class: 'text-base-500', label: 'base' },
77 { class: 'text-accent-500', label: 'accent' },
78 { class: 'text-base-300 dark:text-base-700', label: 'transparent' },
79 { class: 'text-red-500', label: 'red' },
80 { class: 'text-orange-500', label: 'orange' },
81 { class: 'text-amber-500', label: 'amber' },
82 { class: 'text-yellow-500', label: 'yellow' },
83 { class: 'text-lime-500', label: 'lime' },
84 { class: 'text-green-500', label: 'green' },
85 { class: 'text-emerald-500', label: 'emerald' },
86 { class: 'text-teal-500', label: 'teal' },
87 { class: 'text-cyan-500', label: 'cyan' },
88 { class: 'text-sky-500', label: 'sky' },
89 { class: 'text-blue-500', label: 'blue' },
90 { class: 'text-indigo-500', label: 'indigo' },
91 { class: 'text-violet-500', label: 'violet' },
92 { class: 'text-purple-500', label: 'purple' },
93 { class: 'text-fuchsia-500', label: 'fuchsia' },
94 { class: 'text-pink-500', label: 'pink' },
95 { class: 'text-rose-500', label: 'rose' }
96 ];
97
98 let selectedColor = $derived(
99 selectedCard
100 ? colorsChoices.find((c) => (selectedCard!.color ?? 'base') === c.label)
101 : undefined
102 );
103
104 let cardDef = $derived(
105 selectedCard ? (CardDefinitionsByType[selectedCard.cardType] ?? null) : null
106 );
107
108 let colorPopoverOpen = $state(false);
109 let sizePopoverOpen = $state(false);
110 let settingsPopoverOpen = $state(false);
111
112 const minW = $derived(cardDef?.minW ?? 2);
113 const minH = $derived(cardDef?.minH ?? 2);
114 const maxW = $derived(cardDef?.maxW ?? COLUMNS);
115 const maxH = $derived(cardDef?.maxH ?? (isMobile ? 12 : 6));
116
117 function canSetSize(w: number, h: number) {
118 if (!cardDef) return false;
119 if (isMobile) {
120 return w >= minW && w * 2 <= maxW && h >= minH && h * 2 <= maxH;
121 }
122 return w >= minW && w <= maxW && h >= minH && h <= maxH;
123 }
124
125 const showMobileEditControls = $derived(isCoarse && selectedCard);
126</script>
127
128<input
129 type="file"
130 accept="image/*"
131 onchange={handleImageInputChange}
132 class="hidden"
133 id="image-input"
134 multiple
135 bind:this={imageInputRef}
136/>
137
138<input
139 type="file"
140 accept="video/*"
141 onchange={handleVideoInputChange}
142 class="hidden"
143 multiple
144 bind:this={videoInputRef}
145/>
146
147{#if dev || (user.isLoggedIn && user.profile?.did === data.did)}
148 <Navbar
149 class="dark:bg-base-900 bg-base-100 top-auto bottom-2 mx-4 mt-3 max-w-3xl rounded-full px-4 md:mx-auto"
150 >
151 {#if showMobileEditControls}
152 <!-- Mobile edit controls: left = color, size, settings; right = delete, deselect -->
153 <div class="flex items-center gap-1">
154 {#if cardDef?.allowSetColor !== false}
155 <Popover bind:open={colorPopoverOpen}>
156 {#snippet child({ props })}
157 <button
158 {...props}
159 class={[
160 'cursor-pointer rounded-xl p-2',
161 !selectedCard?.color ||
162 selectedCard.color === 'base' ||
163 selectedCard.color === 'transparent'
164 ? 'text-base-800 dark:text-base-200'
165 : 'text-accent-500'
166 ]}
167 >
168 <svg
169 xmlns="http://www.w3.org/2000/svg"
170 viewBox="0 0 24 24"
171 fill="currentColor"
172 class="size-5"
173 >
174 <path
175 fill-rule="evenodd"
176 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"
177 clip-rule="evenodd"
178 />
179 </svg>
180 </button>
181 {/snippet}
182 <ColorSelect
183 selected={selectedColor}
184 colors={colorsChoices}
185 onselected={(color, previous) => {
186 if (typeof previous === 'string' || typeof color === 'string') {
187 return;
188 }
189 if (selectedCard) {
190 selectedCard.color = color.label;
191 }
192 }}
193 class="w-64"
194 />
195 </Popover>
196 {/if}
197
198 <Popover bind:open={sizePopoverOpen}>
199 {#snippet child({ props })}
200 <button {...props} class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2">
201 <svg
202 xmlns="http://www.w3.org/2000/svg"
203 fill="none"
204 viewBox="0 0 24 24"
205 stroke-width="1.5"
206 stroke="currentColor"
207 class="size-5"
208 >
209 <path
210 stroke-linecap="round"
211 stroke-linejoin="round"
212 d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15"
213 />
214 </svg>
215 </button>
216 {/snippet}
217 <div class="flex items-center gap-1">
218 {#if canSetSize(2, 2)}
219 <button
220 onclick={() => onsetsize?.(4, 4)}
221 class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2"
222 >
223 <div class="border-base-900 dark:border-base-50 size-3 rounded-sm border-2"></div>
224 <span class="sr-only">set size to 1x1</span>
225 </button>
226 {/if}
227 {#if canSetSize(4, 2)}
228 <button
229 onclick={() => onsetsize?.(8, 4)}
230 class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2"
231 >
232 <div class="border-base-900 dark:border-base-50 h-3 w-5 rounded-sm border-2"></div>
233 <span class="sr-only">set size to 2x1</span>
234 </button>
235 {/if}
236 {#if canSetSize(2, 4)}
237 <button
238 onclick={() => onsetsize?.(4, 8)}
239 class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2"
240 >
241 <div class="border-base-900 dark:border-base-50 h-5 w-3 rounded-sm border-2"></div>
242 <span class="sr-only">set size to 1x2</span>
243 </button>
244 {/if}
245 {#if canSetSize(4, 4)}
246 <button
247 onclick={() => onsetsize?.(8, 8)}
248 class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2"
249 >
250 <div class="border-base-900 dark:border-base-50 h-5 w-5 rounded-sm border-2"></div>
251 <span class="sr-only">set size to 2x2</span>
252 </button>
253 {/if}
254 </div>
255 </Popover>
256
257 {#if cardDef?.settingsComponent && selectedCard}
258 <Popover bind:open={settingsPopoverOpen} class="bg-base-50 dark:bg-base-900">
259 {#snippet child({ props })}
260 <button {...props} class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2">
261 <svg
262 xmlns="http://www.w3.org/2000/svg"
263 fill="none"
264 viewBox="0 0 24 24"
265 stroke-width="2"
266 stroke="currentColor"
267 class="size-5"
268 >
269 <path
270 stroke-linecap="round"
271 stroke-linejoin="round"
272 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"
273 />
274 <path
275 stroke-linecap="round"
276 stroke-linejoin="round"
277 d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
278 />
279 </svg>
280 </button>
281 {/snippet}
282 <cardDef.settingsComponent
283 bind:item={selectedCard}
284 onclose={() => {
285 settingsPopoverOpen = false;
286 }}
287 />
288 </Popover>
289 {/if}
290 </div>
291 <div class="flex items-center gap-1">
292 <Button
293 size="iconLg"
294 variant="ghost"
295 class="text-rose-500 backdrop-blur-none"
296 onclick={() => ondelete?.()}
297 >
298 <svg
299 xmlns="http://www.w3.org/2000/svg"
300 fill="none"
301 viewBox="0 0 24 24"
302 stroke-width="1.5"
303 stroke="currentColor"
304 class="size-5"
305 >
306 <path
307 stroke-linecap="round"
308 stroke-linejoin="round"
309 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"
310 />
311 </svg>
312 </Button>
313 <Button
314 size="iconLg"
315 variant="ghost"
316 class="backdrop-blur-none"
317 onclick={() => ondeselect?.()}
318 >
319 <svg
320 xmlns="http://www.w3.org/2000/svg"
321 fill="none"
322 viewBox="0 0 24 24"
323 stroke-width="2"
324 stroke="currentColor"
325 class="size-5"
326 >
327 <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
328 </svg>
329 </Button>
330 </div>
331 {:else}
332 <!-- Normal add-card controls -->
333 <div class="flex items-center gap-2">
334 <Button
335 size="iconLg"
336 variant="ghost"
337 class="backdrop-blur-none"
338 onclick={() => {
339 newCard('section');
340 }}
341 >
342 <svg
343 xmlns="http://www.w3.org/2000/svg"
344 viewBox="0 0 24 24"
345 fill="none"
346 stroke="currentColor"
347 stroke-width="2"
348 stroke-linecap="round"
349 stroke-linejoin="round"
350 ><path d="M6 12h12" /><path d="M6 20V4" /><path d="M18 20V4" /></svg
351 >
352 </Button>
353
354 <Button
355 size="iconLg"
356 variant="ghost"
357 class="backdrop-blur-none"
358 onclick={() => {
359 newCard('text');
360 }}
361 >
362 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
363 ><path
364 fill="none"
365 stroke="currentColor"
366 stroke-linecap="round"
367 stroke-linejoin="round"
368 stroke-width="2"
369 d="m15 16l2.536-7.328a1.02 1.02 1 0 1 1.928 0L22 16m-6.303-2h5.606M2 16l4.039-9.69a.5.5 0 0 1 .923 0L11 16m-7.696-3h6.392"
370 /></svg
371 >
372 </Button>
373
374 <Popover sideOffset={16} bind:open={linkPopoverOpen} class="bg-base-100 dark:bg-base-900">
375 {#snippet child({ props })}
376 <Button
377 size="iconLg"
378 variant="ghost"
379 class="backdrop-blur-none"
380 onclick={() => {
381 newCard('link');
382 }}
383 {...props}
384 >
385 <svg
386 xmlns="http://www.w3.org/2000/svg"
387 fill="none"
388 viewBox="-2 -2 28 28"
389 stroke-width="2"
390 stroke="currentColor"
391 >
392 <path
393 stroke-linecap="round"
394 stroke-linejoin="round"
395 d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244"
396 />
397 </svg>
398 </Button>
399 {/snippet}
400 <Input
401 spellcheck={false}
402 type="url"
403 bind:value={linkValue}
404 onkeydown={(event) => {
405 if (event.code === 'Enter') {
406 addLink(linkValue);
407 event.preventDefault();
408 }
409 }}
410 placeholder="Enter link"
411 />
412 <Button onclick={() => addLink(linkValue)} size="icon"
413 ><svg
414 xmlns="http://www.w3.org/2000/svg"
415 fill="none"
416 viewBox="0 0 24 24"
417 stroke-width="2"
418 stroke="currentColor"
419 class="size-6"
420 >
421 <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
422 </svg>
423 </Button>
424 </Popover>
425
426 <Button
427 size="iconLg"
428 variant="ghost"
429 class="backdrop-blur-none"
430 onclick={() => {
431 imageInputRef?.click();
432 }}
433 >
434 <svg
435 xmlns="http://www.w3.org/2000/svg"
436 fill="none"
437 viewBox="0 0 24 24"
438 stroke-width="2"
439 stroke="currentColor"
440 >
441 <path
442 stroke-linecap="round"
443 stroke-linejoin="round"
444 d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z"
445 />
446 </svg>
447 </Button>
448
449 <Button size="iconLg" variant="ghost" class="backdrop-blur-none" onclick={showCardCommand}>
450 <svg
451 xmlns="http://www.w3.org/2000/svg"
452 fill="none"
453 viewBox="0 0 24 24"
454 stroke-width="1.5"
455 stroke="currentColor"
456 >
457 <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
458 </svg>
459 </Button>
460 </div>
461 {/if}
462 <div class={['flex items-center gap-2', showMobileEditControls ? 'hidden' : '']}>
463 <Toggle
464 class="hidden bg-transparent backdrop-blur-none lg:block dark:bg-transparent"
465 bind:pressed={showingMobileView}
466 >
467 <svg
468 xmlns="http://www.w3.org/2000/svg"
469 fill="none"
470 viewBox="0 0 24 24"
471 stroke-width="1.5"
472 stroke="currentColor"
473 class="size-6"
474 >
475 <path
476 stroke-linecap="round"
477 stroke-linejoin="round"
478 d="M10.5 1.5H8.25A2.25 2.25 0 0 0 6 3.75v16.5a2.25 2.25 0 0 0 2.25 2.25h7.5A2.25 2.25 0 0 0 18 20.25V3.75a2.25 2.25 0 0 0-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3"
479 />
480 </svg>
481 </Toggle>
482 {#if hasUnsavedChanges}
483 <Button
484 disabled={isSaving}
485 onclick={async () => {
486 save();
487 }}>{isSaving ? 'Saving...' : 'Save'}</Button
488 >
489 {:else}
490 <Button onclick={copyShareLink}>
491 <svg
492 xmlns="http://www.w3.org/2000/svg"
493 fill="none"
494 viewBox="0 0 24 24"
495 stroke-width="1.5"
496 stroke="currentColor"
497 class="size-5"
498 >
499 <path
500 stroke-linecap="round"
501 stroke-linejoin="round"
502 d="M7.217 10.907a2.25 2.25 0 100 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186l9.566-5.314m-9.566 7.5l9.566 5.314m0 0a2.25 2.25 0 103.935 2.186 2.25 2.25 0 00-3.935-2.186zm0-12.814a2.25 2.25 0 103.933-2.185 2.25 2.25 0 00-3.933 2.185z"
503 />
504 </svg>
505 Share
506 </Button>
507 {/if}
508 </div>
509 </Navbar>
510{/if}