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
13 showingMobileView = $bindable(),
14 isSaving = $bindable(),
15 hasUnsavedChanges,
16
17 save,
18
19 handleImageInputChange,
20 handleVideoInputChange,
21
22 newCard,
23 addLink,
24 linkValue = $bindable(''),
25
26 showCardCommand,
27 selectedCard = null,
28 isMobile = false,
29 isCoarse = false,
30 ondeselect,
31 ondelete,
32 onsetsize
33 }: {
34 data: WebsiteData;
35
36 showingMobileView: boolean;
37
38 isSaving: boolean;
39 hasUnsavedChanges: boolean;
40
41 save: () => Promise<void>;
42
43 handleImageInputChange: (evt: Event) => void;
44 handleVideoInputChange: (evt: Event) => void;
45
46 newCard: (type?: string, cardData?: any) => void;
47 addLink: (url: string) => void;
48 linkValue: string;
49
50 showCardCommand: () => void;
51 selectedCard?: Item | null;
52 isMobile?: boolean;
53 isCoarse?: boolean;
54 ondeselect?: () => void;
55 ondelete?: () => void;
56 onsetsize?: (w: number, h: number) => void;
57 } = $props();
58
59 let linkPopoverOpen = $state(false);
60 let imageInputRef: HTMLInputElement | undefined = $state();
61 let videoInputRef: HTMLInputElement | undefined = $state();
62
63 function getShareUrl() {
64 const base = typeof window !== 'undefined' ? window.location.origin : '';
65 const pagePath =
66 data.page && data.page !== 'blento.self' ? `/${data.page.replace('blento.', '')}` : '';
67 return `${base}/${data.handle}${pagePath}`;
68 }
69
70 async function copyShareLink() {
71 const url = getShareUrl();
72 await navigator.clipboard.writeText(url);
73 toast.success('Link copied to clipboard!');
74 }
75
76 let colorsChoices = [
77 { class: 'text-base-500', label: 'base' },
78 { class: 'text-accent-500', label: 'accent' },
79 { class: 'text-base-300 dark:text-base-700', label: 'transparent' },
80 { class: 'text-red-500', label: 'red' },
81 { class: 'text-orange-500', label: 'orange' },
82 { class: 'text-amber-500', label: 'amber' },
83 { class: 'text-yellow-500', label: 'yellow' },
84 { class: 'text-lime-500', label: 'lime' },
85 { class: 'text-green-500', label: 'green' },
86 { class: 'text-emerald-500', label: 'emerald' },
87 { class: 'text-teal-500', label: 'teal' },
88 { class: 'text-cyan-500', label: 'cyan' },
89 { class: 'text-sky-500', label: 'sky' },
90 { class: 'text-blue-500', label: 'blue' },
91 { class: 'text-indigo-500', label: 'indigo' },
92 { class: 'text-violet-500', label: 'violet' },
93 { class: 'text-purple-500', label: 'purple' },
94 { class: 'text-fuchsia-500', label: 'fuchsia' },
95 { class: 'text-pink-500', label: 'pink' },
96 { class: 'text-rose-500', label: 'rose' }
97 ];
98
99 let selectedColor = $derived(
100 selectedCard
101 ? colorsChoices.find((c) => (selectedCard!.color ?? 'base') === c.label)
102 : undefined
103 );
104
105 let cardDef = $derived(
106 selectedCard ? (CardDefinitionsByType[selectedCard.cardType] ?? null) : null
107 );
108
109 let colorPopoverOpen = $state(false);
110 let sizePopoverOpen = $state(false);
111 let settingsPopoverOpen = $state(false);
112
113 const minW = $derived(cardDef?.minW ?? 2);
114 const minH = $derived(cardDef?.minH ?? 2);
115 const maxW = $derived(cardDef?.maxW ?? COLUMNS);
116 const maxH = $derived(cardDef?.maxH ?? (isMobile ? 12 : 6));
117
118 function canSetSize(w: number, h: number) {
119 if (!cardDef) return false;
120 if (isMobile) {
121 return w >= minW && w * 2 <= maxW && h >= minH && h * 2 <= maxH;
122 }
123 return w >= minW && w <= maxW && h >= minH && h <= maxH;
124 }
125
126 const showMobileEditControls = $derived(isCoarse && selectedCard);
127</script>
128
129<input
130 type="file"
131 accept="image/*"
132 onchange={handleImageInputChange}
133 class="hidden"
134 id="image-input"
135 multiple
136 bind:this={imageInputRef}
137/>
138
139<input
140 type="file"
141 accept="video/*"
142 onchange={handleVideoInputChange}
143 class="hidden"
144 id="video-input"
145 multiple
146 bind:this={videoInputRef}
147/>
148
149{#if dev || (user.isLoggedIn && user.profile?.did === data.did)}
150 <Navbar
151 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"
152 >
153 {#if showMobileEditControls}
154 <!-- Mobile edit controls: left = color, size, settings; right = delete, deselect -->
155 <div class="flex items-center gap-1">
156 {#if cardDef?.allowSetColor !== false}
157 <Popover bind:open={colorPopoverOpen}>
158 {#snippet child({ props })}
159 <button
160 {...props}
161 class={[
162 'cursor-pointer rounded-xl p-2',
163 !selectedCard?.color ||
164 selectedCard.color === 'base' ||
165 selectedCard.color === 'transparent'
166 ? 'text-base-800 dark:text-base-200'
167 : 'text-accent-500'
168 ]}
169 >
170 <svg
171 xmlns="http://www.w3.org/2000/svg"
172 viewBox="0 0 24 24"
173 fill="currentColor"
174 class="size-5"
175 >
176 <path
177 fill-rule="evenodd"
178 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"
179 clip-rule="evenodd"
180 />
181 </svg>
182 </button>
183 {/snippet}
184 <ColorSelect
185 selected={selectedColor}
186 colors={colorsChoices}
187 onselected={(color, previous) => {
188 if (typeof previous === 'string' || typeof color === 'string') {
189 return;
190 }
191 if (selectedCard) {
192 selectedCard.color = color.label;
193 }
194 }}
195 class="w-64"
196 />
197 </Popover>
198 {/if}
199
200 <Popover bind:open={sizePopoverOpen}>
201 {#snippet child({ props })}
202 <button {...props} class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2">
203 <svg
204 xmlns="http://www.w3.org/2000/svg"
205 fill="none"
206 viewBox="0 0 24 24"
207 stroke-width="1.5"
208 stroke="currentColor"
209 class="size-5"
210 >
211 <path
212 stroke-linecap="round"
213 stroke-linejoin="round"
214 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"
215 />
216 </svg>
217 </button>
218 {/snippet}
219 <div class="flex items-center gap-1">
220 {#if canSetSize(2, 2)}
221 <button
222 onclick={() => onsetsize?.(4, 4)}
223 class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2"
224 >
225 <div class="border-base-900 dark:border-base-50 size-3 rounded-sm border-2"></div>
226 <span class="sr-only">set size to 1x1</span>
227 </button>
228 {/if}
229 {#if canSetSize(4, 2)}
230 <button
231 onclick={() => onsetsize?.(8, 4)}
232 class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2"
233 >
234 <div class="border-base-900 dark:border-base-50 h-3 w-5 rounded-sm border-2"></div>
235 <span class="sr-only">set size to 2x1</span>
236 </button>
237 {/if}
238 {#if canSetSize(2, 4)}
239 <button
240 onclick={() => onsetsize?.(4, 8)}
241 class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2"
242 >
243 <div class="border-base-900 dark:border-base-50 h-5 w-3 rounded-sm border-2"></div>
244 <span class="sr-only">set size to 1x2</span>
245 </button>
246 {/if}
247 {#if canSetSize(4, 4)}
248 <button
249 onclick={() => onsetsize?.(8, 8)}
250 class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2"
251 >
252 <div class="border-base-900 dark:border-base-50 h-5 w-5 rounded-sm border-2"></div>
253 <span class="sr-only">set size to 2x2</span>
254 </button>
255 {/if}
256 </div>
257 </Popover>
258
259 {#if cardDef?.settingsComponent && selectedCard}
260 <Popover bind:open={settingsPopoverOpen} class="bg-base-50 dark:bg-base-900">
261 {#snippet child({ props })}
262 <button {...props} class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2">
263 <svg
264 xmlns="http://www.w3.org/2000/svg"
265 fill="none"
266 viewBox="0 0 24 24"
267 stroke-width="2"
268 stroke="currentColor"
269 class="size-5"
270 >
271 <path
272 stroke-linecap="round"
273 stroke-linejoin="round"
274 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"
275 />
276 <path
277 stroke-linecap="round"
278 stroke-linejoin="round"
279 d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
280 />
281 </svg>
282 </button>
283 {/snippet}
284 <cardDef.settingsComponent
285 bind:item={selectedCard}
286 onclose={() => {
287 settingsPopoverOpen = false;
288 }}
289 />
290 </Popover>
291 {/if}
292 </div>
293 <div class="flex items-center gap-1">
294 <Button
295 size="iconLg"
296 variant="ghost"
297 class="text-rose-500 backdrop-blur-none"
298 onclick={() => ondelete?.()}
299 >
300 <svg
301 xmlns="http://www.w3.org/2000/svg"
302 fill="none"
303 viewBox="0 0 24 24"
304 stroke-width="1.5"
305 stroke="currentColor"
306 class="size-5"
307 >
308 <path
309 stroke-linecap="round"
310 stroke-linejoin="round"
311 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"
312 />
313 </svg>
314 </Button>
315 <Button
316 size="iconLg"
317 variant="ghost"
318 class="backdrop-blur-none"
319 onclick={() => ondeselect?.()}
320 >
321 <svg
322 xmlns="http://www.w3.org/2000/svg"
323 fill="none"
324 viewBox="0 0 24 24"
325 stroke-width="2"
326 stroke="currentColor"
327 class="size-5"
328 >
329 <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
330 </svg>
331 </Button>
332 </div>
333 {:else}
334 <div class="flex items-center gap-2">
335 <Button size="iconLg" variant="ghost" class="backdrop-blur-none" onclick={showCardCommand}>
336 <svg
337 xmlns="http://www.w3.org/2000/svg"
338 fill="none"
339 viewBox="0 0 24 24"
340 stroke-width="1.5"
341 stroke="currentColor"
342 >
343 <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
344 </svg>
345 </Button>
346 </div>
347 {/if}
348 <div class={['flex items-center gap-2', showMobileEditControls ? 'hidden' : '']}>
349 <Toggle
350 class="hidden bg-transparent backdrop-blur-none lg:block dark:bg-transparent"
351 bind:pressed={showingMobileView}
352 >
353 <svg
354 xmlns="http://www.w3.org/2000/svg"
355 fill="none"
356 viewBox="0 0 24 24"
357 stroke-width="1.5"
358 stroke="currentColor"
359 class="size-6"
360 >
361 <path
362 stroke-linecap="round"
363 stroke-linejoin="round"
364 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"
365 />
366 </svg>
367 </Toggle>
368 {#if hasUnsavedChanges}
369 <Button
370 disabled={isSaving}
371 onclick={async () => {
372 save();
373 }}>{isSaving ? 'Saving...' : 'Save'}</Button
374 >
375 {:else}
376 <Button onclick={copyShareLink}>
377 <svg
378 xmlns="http://www.w3.org/2000/svg"
379 fill="none"
380 viewBox="0 0 24 24"
381 stroke-width="1.5"
382 stroke="currentColor"
383 class="size-5"
384 >
385 <path
386 stroke-linecap="round"
387 stroke-linejoin="round"
388 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"
389 />
390 </svg>
391 Share
392 </Button>
393 {/if}
394 </div>
395 </Navbar>
396{/if}