your personal website on atproto - mirror blento.app

prepare public launch

Florian 255ad136 213a7b23

+357 -128
+21 -2
README.md
··· 1 # blento 2 3 - WORK IN PROGRESS, not ready for use yet. 4 5 - your personal website in a bento style layout, using your PDS as a backend.
··· 1 # blento 2 3 + WORK IN PROGRESS, not ready for use yet, but you can test it out at: https://blento.app 4 + 5 + your personal website in a bento style layout, using your bluesky PDS as a backend. 6 + 7 + made with svelte, tailwind. 8 + 9 + 10 + ## Selfhosting 11 + 12 + - fork this repo 13 + - create a cloudflare worker application and connect it to your fork 14 + - change the vars in `wranger.jsonc` 15 + 16 + ```json 17 + "vars": { 18 + "PUBLIC_HANDLE": "your-bluesky-handle", 19 + "PUBLIC_IS_SELFHOSTED": "true", 20 + "PUBLIC_DOMAIN": "https://your-cloudflare-worker-or-custom-domain.com" 21 + } 22 + ``` 23 24 + DONE :)
+5 -3
src/lib/EditableWebsite.svelte
··· 5 import { BlueskyLogin } from '@foxui/social'; 6 7 import { margin, mobileMargin } from '$lib'; 8 - import { cardsEqual, clamp, fixCollisions, setIsMobile, setPositionOfNewItem } from './helper'; 9 import Profile from './Profile.svelte'; 10 import type { Item } from './types'; 11 import { deleteRecord, putRecord } from './oauth/atproto'; ··· 54 let isMobile = $derived(showingMobileView || (innerWidth.current ?? 1000) < 1024); 55 56 setIsMobile(() => isMobile); 57 58 // svelte-ignore state_referenced_locally 59 setDidContext(did); ··· 137 item = await cardDef?.upload(item); 138 } 139 140 - promises.push(putRecord({ collection: 'com.example.bento', rkey: item.id, record: item })); 141 } 142 } 143 ··· 147 if (!item) { 148 console.log('deleting item', originalItem); 149 promises.push( 150 - deleteRecord({ collection: 'com.example.bento', rkey: originalItem.id, did }) 151 ); 152 } 153 }
··· 5 import { BlueskyLogin } from '@foxui/social'; 6 7 import { margin, mobileMargin } from '$lib'; 8 + import { cardsEqual, clamp, fixCollisions, setCanEdit, setIsMobile, setPositionOfNewItem } from './helper'; 9 import Profile from './Profile.svelte'; 10 import type { Item } from './types'; 11 import { deleteRecord, putRecord } from './oauth/atproto'; ··· 54 let isMobile = $derived(showingMobileView || (innerWidth.current ?? 1000) < 1024); 55 56 setIsMobile(() => isMobile); 57 + 58 + setCanEdit(() => client.isLoggedIn && client.profile?.did === did) 59 60 // svelte-ignore state_referenced_locally 61 setDidContext(did); ··· 139 item = await cardDef?.upload(item); 140 } 141 142 + promises.push(putRecord({ collection: 'app.blento.card', rkey: item.id, record: item })); 143 } 144 } 145 ··· 149 if (!item) { 150 console.log('deleting item', originalItem); 151 promises.push( 152 + deleteRecord({ collection: 'app.blento.card', rkey: originalItem.id, did }) 153 ); 154 } 155 }
+47 -4
src/lib/Profile.svelte
··· 2 import Head from './Head.svelte'; 3 4 import { marked } from 'marked'; 5 - import { client } from './oauth'; 6 import { Button } from '@foxui/core'; 7 let { 8 handle, 9 did, ··· 22 23 <!-- lg:fixed lg:h-screen lg:w-1/4 lg:max-w-none lg:px-12 lg:pt-24 xl:w-1/3 --> 24 <div 25 - class="mx-auto flex max-w-2xl px-10 pt-16 pb-8 @5xl/wrapper:fixed @5xl/wrapper:h-screen @5xl/wrapper:w-1/4 @5xl/wrapper:max-w-none @5xl/wrapper:px-12 @5xl/wrapper:pt-24 @7xl/wrapper:w-1/3" 26 > 27 <div class="flex flex-col gap-4"> 28 <Head ··· 34 src={'https://cdn.bsky.app/img/avatar/plain/' + did + '/' + profileData?.avatar.ref.$link} 35 alt="" 36 /> 37 - <div class="line-clamp-2 text-4xl font-bold wrap-anywhere">{(profileData?.displayName || handle)}</div> 38 39 <div 40 class="text-base-600 dark:text-base-400 prose dark:prose-invert prose-a:text-accent-500 prose-a:no-underline line-clamp-3" ··· 44 45 {#if showEditButton && client.isLoggedIn && client.profile?.did === did} 46 <div> 47 - <Button href="./edit" class="mt-2"> 48 <svg 49 xmlns="http://www.w3.org/2000/svg" 50 fill="none" ··· 63 > 64 </div> 65 {/if} 66 </div> 67 </div>
··· 2 import Head from './Head.svelte'; 3 4 import { marked } from 'marked'; 5 + import { client, login } from './oauth'; 6 import { Button } from '@foxui/core'; 7 + import { BlueskyLogin } from '@foxui/social'; 8 + import { env } from '$env/dynamic/public'; 9 let { 10 handle, 11 did, ··· 24 25 <!-- lg:fixed lg:h-screen lg:w-1/4 lg:max-w-none lg:px-12 lg:pt-24 xl:w-1/3 --> 26 <div 27 + class="mx-auto flex max-w-2xl flex-col justify-between px-8 pt-16 pb-8 @5xl/wrapper:fixed @5xl/wrapper:h-screen @5xl/wrapper:w-1/4 @5xl/wrapper:max-w-none @5xl/wrapper:px-12 @5xl/wrapper:pt-24 @7xl/wrapper:w-1/3" 28 > 29 <div class="flex flex-col gap-4"> 30 <Head ··· 36 src={'https://cdn.bsky.app/img/avatar/plain/' + did + '/' + profileData?.avatar.ref.$link} 37 alt="" 38 /> 39 + <div class="line-clamp-2 text-4xl font-bold wrap-anywhere"> 40 + {profileData?.displayName || handle} 41 + </div> 42 43 <div 44 class="text-base-600 dark:text-base-400 prose dark:prose-invert prose-a:text-accent-500 prose-a:no-underline line-clamp-3" ··· 48 49 {#if showEditButton && client.isLoggedIn && client.profile?.did === did} 50 <div> 51 + <Button href="{env.PUBLIC_IS_SELFHOSTED && client.profile?.handle}/edit" class="mt-2"> 52 <svg 53 xmlns="http://www.w3.org/2000/svg" 54 fill="none" ··· 67 > 68 </div> 69 {/if} 70 + 71 + {#if !env.PUBLIC_IS_SELFHOSTED && handle === 'blento.app' && client.profile?.handle !== handle} 72 + {#if !client.isInitializing && !client.isLoggedIn} 73 + <BlueskyLogin 74 + login={async (handle) => { 75 + await login(handle); 76 + return true; 77 + }} 78 + /> 79 + {:else if client.isLoggedIn} 80 + <div> 81 + <Button href={client.profile?.handle} class="mt-2"> 82 + <svg 83 + xmlns="http://www.w3.org/2000/svg" 84 + fill="none" 85 + viewBox="0 0 24 24" 86 + stroke-width="1.5" 87 + stroke="currentColor" 88 + > 89 + <path 90 + stroke-linecap="round" 91 + stroke-linejoin="round" 92 + d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25" 93 + /> 94 + </svg> 95 + 96 + Open Your Blento</Button 97 + > 98 + </div> 99 + {/if} 100 + {/if} 101 + </div> 102 + <div class="hidden text-xs font-light @5xl/wrapper:block"> 103 + made with <a 104 + href="https://blento.app" 105 + target="_blank" 106 + class="hover:text-accent-600 dark:hover:text-accent-400 font-medium transition-colors duration-200" 107 + >blento</a 108 + > 109 </div> 110 </div>
+10 -1
src/lib/Website.svelte
··· 15 16 // svelte-ignore state_referenced_locally 17 setDidContext(did); 18 - 19 let maxHeight = $derived( 20 items.reduce( 21 (max, item) => Math.max(max, isMobile ? item.mobileY + item.mobileH : item.y + item.h), ··· 40 {/each} 41 <div style="height: {(maxHeight / 4) * 100}cqw;"></div> 42 </div> 43 </div> 44 </div>
··· 15 16 // svelte-ignore state_referenced_locally 17 setDidContext(did); 18 + 19 let maxHeight = $derived( 20 items.reduce( 21 (max, item) => Math.max(max, isMobile ? item.mobileY + item.mobileH : item.y + item.h), ··· 40 {/each} 41 <div style="height: {(maxHeight / 4) * 100}cqw;"></div> 42 </div> 43 + </div> 44 + 45 + <div class="block text-xs font-light @5xl/wrapper:hidden mx-auto text-center pb-8"> 46 + made with <a 47 + href="https://blento.app" 48 + target="_blank" 49 + class="hover:text-accent-600 dark:hover:text-accent-400 font-medium transition-colors duration-200" 50 + >blento</a 51 + > 52 </div> 53 </div>
+92 -87
src/lib/cards/BaseCard/BaseEditingCard.svelte
··· 4 import Card from './BaseCard.svelte'; 5 import type { Item } from '$lib/types'; 6 import { Button } from '@foxui/core'; 7 8 export type BaseEditingCardProps = { 9 item: Item; ··· 21 ondelete, 22 ...rest 23 }: BaseEditingCardProps = $props(); 24 </script> 25 26 <Card {item} {...rest} isEditing={true} bind:ref> ··· 28 29 {#snippet controls()} 30 <!-- 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:inline-flex" --> 31 - <Button 32 - size="icon" 33 - variant="rose" 34 - onclick={() => { 35 - ondelete(); 36 - }} 37 - class="absolute -top-3 -left-3 hidden group-focus-within:inline-flex group-hover:inline-flex" 38 - > 39 - <svg 40 - xmlns="http://www.w3.org/2000/svg" 41 - fill="none" 42 - viewBox="0 0 24 24" 43 - stroke-width="1.5" 44 - stroke="currentColor" 45 > 46 - <path 47 - stroke-linecap="round" 48 - stroke-linejoin="round" 49 - 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" 50 - /> 51 - </svg> 52 53 - <span class="sr-only">Delete card</span> 54 - </Button> 55 56 - <div 57 - class="absolute -bottom-7 z-50 hidden w-full items-center justify-center text-xs group-focus-within:inline-flex group-hover:inline-flex" 58 - > 59 <div 60 - class="bg-base-100 border-base-200 dark:bg-base-800 dark:border-base-700 inline-flex gap-0.5 rounded-2xl border p-1 px-2 shadow-lg" 61 > 62 - <button 63 - onclick={() => { 64 - onsetsize?.(1, 1); 65 - }} 66 - class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 67 > 68 - <div class="border-base-900 dark:border-base-50 size-3 rounded-sm border-2"></div> 69 70 - <span class="sr-only">set size to 1x1</span> 71 - </button> 72 73 - <button 74 - onclick={() => { 75 - onsetsize?.(2, 1); 76 - }} 77 - class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 78 - > 79 - <div class="border-base-900 dark:border-base-50 h-3 w-5 rounded-sm border-2"></div> 80 - <span class="sr-only">set size to 2x1</span> 81 - </button> 82 - <button 83 - onclick={() => { 84 - onsetsize?.(1, 2); 85 - }} 86 - class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 87 - > 88 - <div class="border-base-900 dark:border-base-50 h-5 w-3 rounded-sm border-2"></div> 89 90 - <span class="sr-only">set size to 1x2</span> 91 - </button> 92 - <button 93 - onclick={() => { 94 - onsetsize?.(2, 2); 95 - }} 96 - class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 97 - > 98 - <div class="border-base-900 dark:border-base-50 h-5 w-5 rounded-sm border-2"></div> 99 - 100 - <span class="sr-only">set size to 2x2</span> 101 - </button> 102 - 103 - {#if onshowsettings} 104 <button 105 onclick={() => { 106 - onshowsettings(); 107 }} 108 class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 109 > 110 - <svg 111 - xmlns="http://www.w3.org/2000/svg" 112 - fill="none" 113 - viewBox="0 0 24 24" 114 - stroke-width="2" 115 - stroke="currentColor" 116 - class="size-5" 117 > 118 - <path 119 - stroke-linecap="round" 120 - stroke-linejoin="round" 121 - 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" 122 - /> 123 - <path 124 - stroke-linecap="round" 125 - stroke-linejoin="round" 126 - d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 127 - /> 128 - </svg> 129 130 - <span class="sr-only">open card settings</span> 131 - </button> 132 - {/if} 133 </div> 134 - </div> 135 {/snippet} 136 </Card>
··· 4 import Card from './BaseCard.svelte'; 5 import type { Item } from '$lib/types'; 6 import { Button } from '@foxui/core'; 7 + import { getCanEdit } from '$lib/helper'; 8 9 export type BaseEditingCardProps = { 10 item: Item; ··· 22 ondelete, 23 ...rest 24 }: BaseEditingCardProps = $props(); 25 + 26 + let canEdit = getCanEdit(); 27 </script> 28 29 <Card {item} {...rest} isEditing={true} bind:ref> ··· 31 32 {#snippet controls()} 33 <!-- 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:inline-flex" --> 34 + {#if canEdit()} 35 + <Button 36 + size="icon" 37 + variant="rose" 38 + onclick={() => { 39 + ondelete(); 40 + }} 41 + class="absolute -top-3 -left-3 hidden group-focus-within:inline-flex group-hover:inline-flex" 42 > 43 + <svg 44 + xmlns="http://www.w3.org/2000/svg" 45 + fill="none" 46 + viewBox="0 0 24 24" 47 + stroke-width="1.5" 48 + stroke="currentColor" 49 + > 50 + <path 51 + stroke-linecap="round" 52 + stroke-linejoin="round" 53 + 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" 54 + /> 55 + </svg> 56 57 + <span class="sr-only">Delete card</span> 58 + </Button> 59 60 <div 61 + class="absolute -bottom-7 z-50 hidden w-full items-center justify-center text-xs group-focus-within:inline-flex group-hover:inline-flex" 62 > 63 + <div 64 + class="bg-base-100 border-base-200 dark:bg-base-800 dark:border-base-700 inline-flex gap-0.5 rounded-2xl border p-1 px-2 shadow-lg" 65 > 66 + <button 67 + onclick={() => { 68 + onsetsize?.(1, 1); 69 + }} 70 + class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 71 + > 72 + <div class="border-base-900 dark:border-base-50 size-3 rounded-sm border-2"></div> 73 74 + <span class="sr-only">set size to 1x1</span> 75 + </button> 76 77 + <button 78 + onclick={() => { 79 + onsetsize?.(2, 1); 80 + }} 81 + class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 82 + > 83 + <div class="border-base-900 dark:border-base-50 h-3 w-5 rounded-sm border-2"></div> 84 + <span class="sr-only">set size to 2x1</span> 85 + </button> 86 + <button 87 + onclick={() => { 88 + onsetsize?.(1, 2); 89 + }} 90 + class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 91 + > 92 + <div class="border-base-900 dark:border-base-50 h-5 w-3 rounded-sm border-2"></div> 93 94 + <span class="sr-only">set size to 1x2</span> 95 + </button> 96 <button 97 onclick={() => { 98 + onsetsize?.(2, 2); 99 }} 100 class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 101 > 102 + <div class="border-base-900 dark:border-base-50 h-5 w-5 rounded-sm border-2"></div> 103 + 104 + <span class="sr-only">set size to 2x2</span> 105 + </button> 106 + 107 + {#if onshowsettings} 108 + <button 109 + onclick={() => { 110 + onshowsettings(); 111 + }} 112 + class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 113 > 114 + <svg 115 + xmlns="http://www.w3.org/2000/svg" 116 + fill="none" 117 + viewBox="0 0 24 24" 118 + stroke-width="2" 119 + stroke="currentColor" 120 + class="size-5" 121 + > 122 + <path 123 + stroke-linecap="round" 124 + stroke-linejoin="round" 125 + 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" 126 + /> 127 + <path 128 + stroke-linecap="round" 129 + stroke-linejoin="round" 130 + d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 131 + /> 132 + </svg> 133 134 + <span class="sr-only">open card settings</span> 135 + </button> 136 + {/if} 137 + </div> 138 </div> 139 + {/if} 140 {/snippet} 141 </Card>
+5 -2
src/lib/cards/ImageCard/CreateImageCardModal.svelte
··· 1 <script lang="ts"> 2 - import { Button, Modal, Subheading } from '@foxui/core'; 3 import type { CreationModalComponentProps } from '../types'; 4 5 let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props(); ··· 134 onclick={() => { 135 inputRef?.click(); 136 }} 137 - class="dark:bg-accent-600/5 hover:bg-accent-400/10 dark:hover:bg-accent-600/10 border-accent-400 bg-accent-400/5 dark:border-accent-800 flex h-44 w-full cursor-pointer items-center justify-center gap-2 rounded-2xl border border-dashed p-4 transition-colors duration-200" 138 > 139 {#if !item.cardData.objectUrl} 140 <svg ··· 168 bind:this={inputRef} 169 /> 170 </div> 171 172 <div class="mt-4 flex justify-end gap-2"> 173 <Button
··· 1 <script lang="ts"> 2 + import { Button, Input, Label, Modal, Subheading } from '@foxui/core'; 3 import type { CreationModalComponentProps } from '../types'; 4 5 let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props(); ··· 134 onclick={() => { 135 inputRef?.click(); 136 }} 137 + class="dark:bg-accent-600/5 hover:bg-accent-400/10 dark:hover:bg-accent-600/10 border-accent-400 bg-accent-400/5 dark:border-accent-800 flex h-32 w-full cursor-pointer items-center justify-center gap-2 rounded-2xl border border-dashed p-2 transition-colors duration-200" 138 > 139 {#if !item.cardData.objectUrl} 140 <svg ··· 168 bind:this={inputRef} 169 /> 170 </div> 171 + <Label class="mt-4">Link (optional):</Label> 172 + <Input bind:value={item.cardData.href} /> 173 + 174 175 <div class="mt-4 flex justify-end gap-2"> 176 <Button
+21 -1
src/lib/cards/ImageCard/EditingImageCard.svelte
··· 22 <img 23 class={[ 24 'absolute inset-0 h-full w-full object-cover opacity-100 transition-transform duration-300 ease-in-out', 25 - item.cardData.href ? 'group-hover:scale-105' : '' 26 ]} 27 src={getSrc()} 28 alt="" ··· 38 <span class="sr-only"> 39 {item.cardData.hrefText ?? 'Learn more'} 40 </span> 41 </a> 42 {/if} 43 </BaseEditingCard>
··· 22 <img 23 class={[ 24 'absolute inset-0 h-full w-full object-cover opacity-100 transition-transform duration-300 ease-in-out', 25 + item.cardData.href ? 'group-hover:scale-102' : '' 26 ]} 27 src={getSrc()} 28 alt="" ··· 38 <span class="sr-only"> 39 {item.cardData.hrefText ?? 'Learn more'} 40 </span> 41 + 42 + 43 + <div 44 + class="bg-base-800/30 border-base-900/30 absolute top-2 right-2 rounded-full border p-1 text-white opacity-50 backdrop-blur-lg group-focus-within:opacity-100 group-hover:opacity-100" 45 + > 46 + <svg 47 + xmlns="http://www.w3.org/2000/svg" 48 + fill="none" 49 + viewBox="0 0 24 24" 50 + stroke-width="2.5" 51 + stroke="currentColor" 52 + class="size-4" 53 + > 54 + <path 55 + stroke-linecap="round" 56 + stroke-linejoin="round" 57 + d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25" 58 + /> 59 + </svg> 60 + </div> 61 </a> 62 {/if} 63 </BaseEditingCard>
+1 -1
src/lib/cards/ImageCard/ImageCard.svelte
··· 22 <img 23 class={[ 24 'absolute inset-0 h-full w-full object-cover opacity-100 transition-transform duration-300 ease-in-out', 25 - item.cardData.href ? 'group-hover:scale-105' : '' 26 ]} 27 src={getSrc()} 28 alt=""
··· 22 <img 23 class={[ 24 'absolute inset-0 h-full w-full object-cover opacity-100 transition-transform duration-300 ease-in-out', 25 + item.cardData.href ? 'group-hover:scale-102' : '' 26 ]} 27 src={getSrc()} 28 alt=""
+21 -6
src/lib/cards/LinkCard/CreateLinkCardModal.svelte
··· 1 <script lang="ts"> 2 - import { Button, Input, Modal, Subheading } from '@foxui/core'; 3 import type { CreationModalComponentProps } from '../types'; 4 5 let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props(); 6 7 let isFetchingMetadata = $state(false); 8 9 async function fetchMetadata() { 10 - item.cardData.domain = new URL(item.cardData.href).hostname; 11 - isFetchingMetadata = true; 12 13 try { 14 const response = await fetch('/api/links?link=' + encodeURIComponent(item.cardData.href)); ··· 18 item.cardData.title = data.title || ''; 19 item.cardData.image = data.images?.[0] || ''; 20 item.cardData.favicon = data.favicons?.[0] || undefined; 21 } 22 } catch (error) { 23 - console.error('Error fetching metadata:', error); 24 } finally { 25 isFetchingMetadata = false; 26 } 27 } 28 </script> 29 ··· 31 <Subheading>Enter a link</Subheading> 32 <Input bind:value={item.cardData.href} /> 33 34 <div class="mt-4 flex justify-end gap-2"> 35 <Button onclick={oncancel} variant="ghost">Cancel</Button> 36 <Button 37 disabled={isFetchingMetadata} 38 onclick={async () => { 39 - await fetchMetadata(); 40 - oncreate(); 41 }}>{isFetchingMetadata ? 'Creating...' : 'Create'}</Button 42 > 43 </div>
··· 1 <script lang="ts"> 2 + import { Alert, Button, Input, Modal, Subheading } from '@foxui/core'; 3 import type { CreationModalComponentProps } from '../types'; 4 5 let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props(); 6 7 let isFetchingMetadata = $state(false); 8 9 + let errorMessage = $state(''); 10 + 11 async function fetchMetadata() { 12 + errorMessage = ''; 13 + try { 14 + item.cardData.domain = new URL(item.cardData.href).hostname; 15 + } catch (error) { 16 + errorMessage = 'Invalid URL!'; 17 + return false; 18 + } 19 + isFetchingMetadata = true; 20 21 try { 22 const response = await fetch('/api/links?link=' + encodeURIComponent(item.cardData.href)); ··· 26 item.cardData.title = data.title || ''; 27 item.cardData.image = data.images?.[0] || ''; 28 item.cardData.favicon = data.favicons?.[0] || undefined; 29 + } else { 30 + throw new Error(); 31 } 32 } catch (error) { 33 + errorMessage = "Couldn't fetch metadata for this link!"; 34 + return false; 35 } finally { 36 isFetchingMetadata = false; 37 } 38 + return true; 39 } 40 </script> 41 ··· 43 <Subheading>Enter a link</Subheading> 44 <Input bind:value={item.cardData.href} /> 45 46 + {#if errorMessage} 47 + <Alert type="error" title="Failed to create link card"><span>{errorMessage}</span></Alert> 48 + {/if} 49 + 50 <div class="mt-4 flex justify-end gap-2"> 51 <Button onclick={oncancel} variant="ghost">Cancel</Button> 52 <Button 53 disabled={isFetchingMetadata} 54 onclick={async () => { 55 + if (await fetchMetadata()) oncreate(); 56 }}>{isFetchingMetadata ? 'Creating...' : 'Create'}</Button 57 > 58 </div>
+13 -3
src/lib/cards/LinkCard/EditingLinkCard.svelte
··· 2 import { getIsMobile } from '$lib/helper'; 3 import BaseEditingCard, { type BaseEditingCardProps } from '../BaseCard/BaseEditingCard.svelte'; 4 import { innerWidth } from 'svelte/reactivity/window'; 5 6 let { item = $bindable(), ...rest }: BaseEditingCardProps = $props(); 7 ··· 14 {#if item.cardData.favicon} 15 <img class="mb-2 size-8 rounded-lg object-cover" src={item.cardData.favicon} alt="" /> 16 {/if} 17 - <div class="text-base-900 dark:text-base-50 text-lg font-semibold">{item.cardData.title}</div> 18 <!-- <div class="text-base-800 dark:text-base-100 mt-2 text-xs">{item.cardData.description}</div> --> 19 <div class="text-accent-600 dark:text-accent-400 mt-2 text-xs font-light"> 20 {item.cardData.domain} ··· 34 alt="" 35 /> 36 {/key} --> 37 - {#if item.cardData.href} 38 <a 39 href={item.cardData.href} 40 class="absolute inset-0 h-full w-full" ··· 45 {item.cardData.hrefText ?? 'Learn more'} 46 </span> 47 </a> 48 - {/if} 49 </div> 50 </BaseEditingCard>
··· 2 import { getIsMobile } from '$lib/helper'; 3 import BaseEditingCard, { type BaseEditingCardProps } from '../BaseCard/BaseEditingCard.svelte'; 4 import { innerWidth } from 'svelte/reactivity/window'; 5 + import PlainTextEditor from '../utils/PlainTextEditor.svelte'; 6 7 let { item = $bindable(), ...rest }: BaseEditingCardProps = $props(); 8 ··· 15 {#if item.cardData.favicon} 16 <img class="mb-2 size-8 rounded-lg object-cover" src={item.cardData.favicon} alt="" /> 17 {/if} 18 + 19 + <div 20 + class="hover:bg-base-200/70 dark:hover:bg-base-800/70 -m-1 rounded-md p-1 transition-colors duration-200" 21 + > 22 + <PlainTextEditor 23 + class="text-base-900 dark:text-base-50 text-lg font-semibold" 24 + key="title" 25 + bind:item 26 + /> 27 + </div> 28 <!-- <div class="text-base-800 dark:text-base-100 mt-2 text-xs">{item.cardData.description}</div> --> 29 <div class="text-accent-600 dark:text-accent-400 mt-2 text-xs font-light"> 30 {item.cardData.domain} ··· 44 alt="" 45 /> 46 {/key} --> 47 + <!-- {#if item.cardData.href} 48 <a 49 href={item.cardData.href} 50 class="absolute inset-0 h-full w-full" ··· 55 {item.cardData.hrefText ?? 'Learn more'} 56 </span> 57 </a> 58 + {/if} --> 59 </div> 60 </BaseEditingCard>
+1 -1
src/lib/cards/TextCard/EditingTextCard.svelte
··· 1 <script lang="ts"> 2 import type { Item } from '$lib/types'; 3 import BaseEditingCard, { type BaseEditingCardProps } from '../BaseCard/BaseEditingCard.svelte'; 4 - import MarkdownTextEditor from './MarkdownTextEditor.svelte'; 5 6 let { item = $bindable<Item>(), ...rest }: BaseEditingCardProps = $props(); 7 </script>
··· 1 <script lang="ts"> 2 import type { Item } from '$lib/types'; 3 import BaseEditingCard, { type BaseEditingCardProps } from '../BaseCard/BaseEditingCard.svelte'; 4 + import MarkdownTextEditor from '../utils/MarkdownTextEditor.svelte'; 5 6 let { item = $bindable<Item>(), ...rest }: BaseEditingCardProps = $props(); 7 </script>
-2
src/lib/cards/TextCard/MarkdownTextEditor.svelte src/lib/cards/utils/MarkdownTextEditor.svelte
··· 16 17 let loaded = $state(false); 18 19 - let edited = $state(false); 20 - 21 let { 22 item = $bindable(), 23 placeholder = '',
··· 16 17 let loaded = $state(false); 18 19 let { 20 item = $bindable(), 21 placeholder = '',
src/lib/cards/TextCard/extensions/RichTextLink.ts src/lib/cards/utils/extensions/RichTextLink.ts
+86
src/lib/cards/utils/PlainTextEditor.svelte
···
··· 1 + <script lang="ts"> 2 + import { onDestroy, onMount } from 'svelte'; 3 + import { Editor, type Extensions } from '@tiptap/core'; 4 + import Placeholder from '@tiptap/extension-placeholder'; 5 + import Paragraph from '@tiptap/extension-paragraph'; 6 + import Document from '@tiptap/extension-document'; 7 + import Text from '@tiptap/extension-text'; 8 + import type { Item } from '$lib/types'; 9 + 10 + let element: HTMLElement | undefined = $state(); 11 + let editor: Editor | null = $state(null); 12 + 13 + let { 14 + item = $bindable(), 15 + key, 16 + class: className, 17 + placeholder = '', 18 + defaultContent = '' 19 + }: { 20 + item: Item; 21 + key: string; 22 + class?: string; 23 + placeholder?: string; 24 + defaultContent?: string; 25 + } = $props(); 26 + 27 + const update = async () => { 28 + if (!editor) return; 29 + 30 + item.cardData[key] = editor.getText(); 31 + }; 32 + 33 + onMount(async () => { 34 + if (!element || editor) return; 35 + 36 + let extensions: Extensions = [Document.configure(), Paragraph.configure(), Text.configure()]; 37 + 38 + if (placeholder) { 39 + extensions.push( 40 + Placeholder.configure({ 41 + placeholder: placeholder 42 + }) 43 + ); 44 + } 45 + 46 + editor = new Editor({ 47 + element: element, 48 + extensions: extensions, 49 + onTransaction: () => { 50 + editor = editor; 51 + }, 52 + onUpdate: () => { 53 + update(); 54 + }, 55 + 56 + content: item.cardData[key] ?? defaultContent, 57 + 58 + editorProps: { 59 + attributes: { 60 + class: 'outline-none pointer-events-auto' 61 + } 62 + } 63 + }); 64 + }); 65 + 66 + onDestroy(() => { 67 + if (editor) { 68 + editor.destroy(); 69 + } 70 + }); 71 + </script> 72 + 73 + <span class={className} bind:this={element}></span> 74 + 75 + <style> 76 + :global(.tiptap p.is-editor-empty:first-child::before) { 77 + color: var(--color-base-800); 78 + content: attr(data-placeholder); 79 + float: left; 80 + height: 0; 81 + pointer-events: none; 82 + } 83 + :global(.dark .tiptap p.is-editor-empty:first-child::before) { 84 + color: var(--color-base-200); 85 + } 86 + </style>
+2
src/lib/helper.ts
··· 144 } 145 146 export const [getIsMobile, setIsMobile] = createContext<() => boolean>();
··· 144 } 145 146 export const [getIsMobile, setIsMobile] = createContext<() => boolean>(); 147 + 148 + export const [getCanEdit, setCanEdit] = createContext<() => boolean>();
+3 -1
src/lib/oauth/const.ts
··· 1 import { base } from '$app/paths'; 2 3 - export const SITE_URL = 'https://blento.flobit-dev.workers.dev'; 4 5 export const metadata = { 6 client_id: `${SITE_URL}${base}/client-metadata.json`,
··· 1 import { base } from '$app/paths'; 2 3 + import { env } from '$env/dynamic/public'; 4 + 5 + export const SITE_URL = env.PUBLIC_DOMAIN; 6 7 export const metadata = { 8 client_id: `${SITE_URL}${base}/client-metadata.json`,
+1 -1
src/lib/website/context.ts
··· 14 15 export const [getDataContext, setDataContext] = createContext<DownloadedData>(); 16 17 - export const [isEditing, setIsEditing] = createContext<boolean>();
··· 14 15 export const [getDataContext, setDataContext] = createContext<DownloadedData>(); 16 17 + export const [isEditing, setIsEditing] = createContext<boolean>();
+1 -1
src/lib/website/data.ts
··· 4 export const data = { 5 'app.bsky.actor.profile': ['self'], 6 7 - 'com.example.bento': 'all' 8 } as const;
··· 4 export const data = { 5 'app.bsky.actor.profile': ['self'], 6 7 + 'app.blento.card': 'all' 8 } as const;
+3 -3
src/routes/+page.server.ts
··· 1 import { loadData } from '$lib/website/utils'; 2 - import { env } from '$env/dynamic/private'; 3 4 export async function load() { 5 - const data = await loadData(env.MAIN_HANDLE); 6 - return { ...data, handle: env.MAIN_HANDLE }; 7 }
··· 1 import { loadData } from '$lib/website/utils'; 2 + import { env } from '$env/dynamic/public'; 3 4 export async function load() { 5 + const data = await loadData(env.PUBLIC_HANDLE); 6 + return { ...data, handle: env.PUBLIC_HANDLE }; 7 }
+1 -1
src/routes/+page.svelte
··· 11 {data} 12 handle={data.handle} 13 did={data.did} 14 - items={Object.values(data.data['com.example.bento']).map((i) => i.value) as Item[]} 15 />
··· 11 {data} 12 handle={data.handle} 13 did={data.did} 14 + items={Object.values(data.data['app.blento.card']).map((i) => i.value) as Item[]} 15 />
+1 -1
src/routes/[handle]/+layout.server.ts
··· 3 import { error } from '@sveltejs/kit'; 4 5 export async function load({ params }) { 6 - if (env.ONLY_ALLOW_MAIN_HANDLE) error(404); 7 return await loadData(params.handle); 8 }
··· 3 import { error } from '@sveltejs/kit'; 4 5 export async function load({ params }) { 6 + if (env.PUBLIC_IS_SELFHOSTED) error(404); 7 return await loadData(params.handle); 8 }
+1 -1
src/routes/[handle]/+page.svelte
··· 11 {data} 12 handle={page.params.handle} 13 did={data.did} 14 - items={Object.values(data.data['com.example.bento']).map((i) => i.value) as Item[]} 15 />
··· 11 {data} 12 handle={page.params.handle} 13 did={data.did} 14 + items={Object.values(data.data['app.blento.card']).map((i) => i.value) as Item[]} 15 />
+1 -1
src/routes/[handle]/edit/+page.svelte
··· 10 handle={page.params.handle} 11 did={data.did} 12 {data} 13 - items={Object.values(data.data['com.example.bento']).map((i) => i.value) as Item[]} 14 />
··· 10 handle={page.params.handle} 11 did={data.did} 12 {data} 13 + items={Object.values(data.data['app.blento.card']).map((i) => i.value) as Item[]} 14 />
+3 -3
src/routes/edit/+page.server.ts
··· 1 import { loadData } from '$lib/website/utils'; 2 - import { env } from '$env/dynamic/private'; 3 4 export async function load() { 5 - const data = await loadData(env.MAIN_HANDLE); 6 - return { ...data, handle: env.MAIN_HANDLE }; 7 }
··· 1 import { loadData } from '$lib/website/utils'; 2 + import { env } from '$env/dynamic/public'; 3 4 export async function load() { 5 + const data = await loadData(env.PUBLIC_HANDLE); 6 + return { ...data, handle: env.PUBLIC_HANDLE }; 7 }
+1 -1
src/routes/edit/+page.svelte
··· 10 handle={data.handle} 11 did={data.did} 12 {data} 13 - items={Object.values(data.data['com.example.bento']).map((i) => i.value) as Item[]} 14 />
··· 10 handle={data.handle} 11 did={data.did} 12 {data} 13 + items={Object.values(data.data['app.blento.card']).map((i) => i.value) as Item[]} 14 />
+11
todo.md
···
··· 1 + # todo 2 + 3 + - video card 4 + - github card 5 + - instagram card 6 + - edit already created cards (e.g. change link) 7 + - link card: save favicon and og image to pds 8 + 9 + 10 + - allow setting custom base and accent color 11 + - allow changing avatar and description to be different than bluesky
+5 -1
wrangler.jsonc
··· 32 * Note: Use secrets to store sensitive data. 33 * https://developers.cloudflare.com/workers/configuration/secrets/ 34 */ 35 - "vars": { "MAIN_HANDLE": "flo-bit.dev" } 36 /** 37 * Service Bindings (communicate between multiple Workers) 38 * https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings
··· 32 * Note: Use secrets to store sensitive data. 33 * https://developers.cloudflare.com/workers/configuration/secrets/ 34 */ 35 + "vars": { 36 + "PUBLIC_HANDLE": "blento.app", 37 + "PUBLIC_IS_SELFHOSTED": "", 38 + "PUBLIC_DOMAIN": "https://blento.app" 39 + } 40 /** 41 * Service Bindings (communicate between multiple Workers) 42 * https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings