your personal website on atproto - mirror blento.app

allow editing profile in place

Florian e5f48660 0466dbed

+206 -27
+2 -2
src/lib/cards/BaseCard/BaseEditingCard.svelte
··· 8 import { AllCardDefinitions, CardDefinitionsByType, getColor } from '..'; 9 import { COLUMNS } from '$lib'; 10 import { getCanEdit, getIsMobile } from '$lib/website/context'; 11 - import PlainTextEditor from '../utils/PlainTextEditor.svelte'; 12 13 let colorsChoices = [ 14 { class: 'text-base-500', label: 'base' }, ··· 189 <PlainTextEditor 190 class="text-base-900 dark:text-base-50 w-fit text-base font-semibold" 191 key="label" 192 - bind:item 193 placeholder="Label" 194 /> 195 </div>
··· 8 import { AllCardDefinitions, CardDefinitionsByType, getColor } from '..'; 9 import { COLUMNS } from '$lib'; 10 import { getCanEdit, getIsMobile } from '$lib/website/context'; 11 + import PlainTextEditor from '$lib/components/PlainTextEditor.svelte'; 12 13 let colorsChoices = [ 14 { class: 'text-base-500', label: 'base' }, ··· 189 <PlainTextEditor 190 class="text-base-900 dark:text-base-50 w-fit text-base font-semibold" 191 key="label" 192 + bind:contentDict={item.cardData} 193 placeholder="Label" 194 /> 195 </div>
+2 -2
src/lib/cards/LinkCard/EditingLinkCard.svelte
··· 3 import { getImage } from '$lib/helper'; 4 import { getDidContext, getIsMobile } from '$lib/website/context'; 5 import type { ContentComponentProps } from '../types'; 6 - import PlainTextEditor from '../utils/PlainTextEditor.svelte'; 7 8 let { item = $bindable() }: ContentComponentProps = $props(); 9 ··· 104 <PlainTextEditor 105 class="text-base-900 dark:text-base-50 line-clamp-2 text-lg font-bold" 106 key="title" 107 - bind:item 108 placeholder="Title here" 109 /> 110 {:else}
··· 3 import { getImage } from '$lib/helper'; 4 import { getDidContext, getIsMobile } from '$lib/website/context'; 5 import type { ContentComponentProps } from '../types'; 6 + import PlainTextEditor from '$lib/components/PlainTextEditor.svelte'; 7 8 let { item = $bindable() }: ContentComponentProps = $props(); 9 ··· 104 <PlainTextEditor 105 class="text-base-900 dark:text-base-50 line-clamp-2 text-lg font-bold" 106 key="title" 107 + bind:contentDict={item.cardData} 108 placeholder="Title here" 109 /> 110 {:else}
+2 -2
src/lib/cards/SectionCard/EditingSectionCard.svelte
··· 2 import type { Item } from '$lib/types'; 3 import { textAlignClasses, textSizeClasses, verticalAlignClasses } from '.'; 4 import type { ContentComponentProps } from '../types'; 5 - import PlainTextEditor from '../utils/PlainTextEditor.svelte'; 6 7 let { item = $bindable<Item>() }: ContentComponentProps = $props(); 8 </script> ··· 15 textSizeClasses[(item.cardData.textSize ?? 1) as number] 16 ]} 17 > 18 - <PlainTextEditor bind:item key="text" class="line-clamp-1 w-full" /> 19 </div>
··· 2 import type { Item } from '$lib/types'; 3 import { textAlignClasses, textSizeClasses, verticalAlignClasses } from '.'; 4 import type { ContentComponentProps } from '../types'; 5 + import PlainTextEditor from '$lib/components/PlainTextEditor.svelte'; 6 7 let { item = $bindable<Item>() }: ContentComponentProps = $props(); 8 </script> ··· 15 textSizeClasses[(item.cardData.textSize ?? 1) as number] 16 ]} 17 > 18 + <PlainTextEditor bind:contentDict={item.cardData} key="text" class="line-clamp-1 w-full" /> 19 </div>
+2 -2
src/lib/cards/TextCard/EditingTextCard.svelte
··· 3 import type { Editor } from '@tiptap/core'; 4 import { textAlignClasses, textSizeClasses, verticalAlignClasses } from '.'; 5 import type { ContentComponentProps } from '../types'; 6 - import MarkdownTextEditor from '../utils/MarkdownTextEditor.svelte'; 7 import { cn } from '@foxui/core'; 8 9 let { item = $bindable<Item>() }: ContentComponentProps = $props(); ··· 26 editor?.commands.focus('end'); 27 }} 28 > 29 - <MarkdownTextEditor bind:item bind:editor /> 30 </div>
··· 3 import type { Editor } from '@tiptap/core'; 4 import { textAlignClasses, textSizeClasses, verticalAlignClasses } from '.'; 5 import type { ContentComponentProps } from '../types'; 6 + import MarkdownTextEditor from '$lib/components/MarkdownTextEditor.svelte'; 7 import { cn } from '@foxui/core'; 8 9 let { item = $bindable<Item>() }: ContentComponentProps = $props(); ··· 26 editor?.commands.focus('end'); 27 }} 28 > 29 + <MarkdownTextEditor bind:contentDict={item.cardData} key="text" bind:editor /> 30 </div>
+1 -1
src/lib/cards/YoutubeVideoCard/YoutubeCard.svelte
··· 1 <script lang="ts"> 2 - import { videoPlayer } from '../utils/YoutubeVideoPlayer.svelte'; 3 import type { ContentComponentProps } from '../types'; 4 5 let { item }: ContentComponentProps = $props();
··· 1 <script lang="ts"> 2 + import { videoPlayer } from '$lib/components/YoutubeVideoPlayer.svelte'; 3 import type { ContentComponentProps } from '../types'; 4 5 let { item }: ContentComponentProps = $props();
+11 -7
src/lib/cards/utils/MarkdownTextEditor.svelte src/lib/components/MarkdownTextEditor.svelte
··· 15 16 let { 17 editor = $bindable(), 18 - item = $bindable(), 19 placeholder = '', 20 - defaultContent = '' 21 }: { 22 editor: Editor | null; 23 - item: Item; 24 placeholder?: string; 25 defaultContent?: string; 26 } = $props(); 27 28 const update = async () => { ··· 36 }); 37 const markdown = turndownService.turndown(html); 38 39 - item.cardData.text = markdown; 40 }; 41 42 onMount(async () => { ··· 45 let json: Content = ''; 46 47 try { 48 - let html = await marked.parse(item.cardData.text ?? (defaultContent as string)); 49 50 // parse to json 51 json = generateJSON(html, [ ··· 100 101 editorProps: { 102 attributes: { 103 - class: 'outline-none w-full' 104 }, 105 handleDOMEvents: { drop: () => false } 106 } ··· 114 }); 115 </script> 116 117 - <div class="w-full cursor-text" bind:this={element}></div> 118 119 <style> 120 :global(.tiptap p.is-editor-empty:first-child::before) {
··· 15 16 let { 17 editor = $bindable(), 18 + contentDict = $bindable(), 19 + key = 'text', 20 placeholder = '', 21 + defaultContent = '', 22 + class: className 23 }: { 24 editor: Editor | null; 25 + contentDict: Record<string, any>; 26 + key: string; 27 placeholder?: string; 28 defaultContent?: string; 29 + class?: string; 30 } = $props(); 31 32 const update = async () => { ··· 40 }); 41 const markdown = turndownService.turndown(html); 42 43 + contentDict[key] = markdown; 44 }; 45 46 onMount(async () => { ··· 49 let json: Content = ''; 50 51 try { 52 + let html = await marked.parse(contentDict[key] ?? (defaultContent as string)); 53 54 // parse to json 55 json = generateJSON(html, [ ··· 104 105 editorProps: { 106 attributes: { 107 + class: 'outline-none w-full text-base-600 dark:text-base-400 prose dark:prose-invert prose-a:text-accent-500 prose-a:no-underline' 108 }, 109 handleDOMEvents: { drop: () => false } 110 } ··· 118 }); 119 </script> 120 121 + <div class={["w-full cursor-text", className]} bind:this={element}></div> 122 123 <style> 124 :global(.tiptap p.is-editor-empty:first-child::before) {
+11 -4
src/lib/cards/utils/PlainTextEditor.svelte src/lib/components/PlainTextEditor.svelte
··· 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; ··· 27 const update = async () => { 28 if (!editor) return; 29 30 - item.cardData[key] = editor.getText(); 31 }; 32 33 onMount(async () => { ··· 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 });
··· 11 let editor: Editor | null = $state(null); 12 13 let { 14 + contentDict = $bindable(), 15 key, 16 class: className, 17 placeholder = '', 18 defaultContent = '' 19 }: { 20 + contentDict: Record<string, any>; 21 key: string; 22 class?: string; 23 placeholder?: string; ··· 27 const update = async () => { 28 if (!editor) return; 29 30 + contentDict[key] = editor.getText(); 31 }; 32 33 onMount(async () => { ··· 53 update(); 54 }, 55 56 + content: contentDict[key] ?? defaultContent, 57 58 editorProps: { 59 attributes: { 60 class: 'outline-none pointer-events-auto' 61 + }, 62 + handleKeyDown: (_view, event) => { 63 + // Prevent newlines by blocking Enter key 64 + if (event.key === 'Enter') { 65 + return true; 66 + } 67 + return false; 68 } 69 } 70 });
src/lib/cards/utils/YoutubeVideoPlayer.svelte src/lib/components/YoutubeVideoPlayer.svelte
src/lib/cards/utils/extensions/RichTextLink.ts src/lib/components/extensions/RichTextLink.ts
+6 -2
src/lib/helper.ts
··· 576 } 577 } 578 579 - export function getImage(objectWithImage: Record<string, any>, did: string, key: string = 'image') { 580 - if (!objectWithImage[key]) return; 581 582 if (objectWithImage[key].objectUrl) return objectWithImage[key].objectUrl; 583
··· 576 } 577 } 578 579 + export function getImage( 580 + objectWithImage: Record<string, any> | undefined, 581 + did: string, 582 + key: string = 'image' 583 + ) { 584 + if (!objectWithImage?.[key]) return; 585 586 if (objectWithImage[key].objectUrl) return objectWithImage[key].objectUrl; 587
+158
src/lib/website/EditableProfile.svelte
···
··· 1 + <script lang="ts"> 2 + import type { WebsiteData } from '$lib/types'; 3 + import { getDescription, getName, getImage, compressImage } from '$lib/helper'; 4 + import PlainTextEditor from '$lib/components/PlainTextEditor.svelte'; 5 + import MarkdownTextEditor from '$lib/components/MarkdownTextEditor.svelte'; 6 + import type { Editor } from '@tiptap/core'; 7 + 8 + let { data = $bindable() }: { data: WebsiteData } = $props(); 9 + 10 + let fileInput: HTMLInputElement; 11 + let isHoveringAvatar = $state(false); 12 + let descriptionEditor: Editor | null = $state(null); 13 + 14 + // Initialize publication if needed 15 + $effect(() => { 16 + if (!data.publication) { 17 + data.publication = { 18 + name: getName(data), 19 + description: getDescription(data) 20 + }; 21 + } else { 22 + if (data.publication.name === undefined) { 23 + data.publication.name = getName(data); 24 + } 25 + if (data.publication.description === undefined) { 26 + data.publication.description = getDescription(data); 27 + } 28 + } 29 + }); 30 + 31 + async function handleAvatarChange(event: Event) { 32 + const target = event.target as HTMLInputElement; 33 + const file = target.files?.[0]; 34 + if (!file) return; 35 + 36 + try { 37 + const compressedBlob = await compressImage(file); 38 + const objectUrl = URL.createObjectURL(compressedBlob); 39 + 40 + data.publication ??= {}; 41 + data.publication.icon = { 42 + blob: compressedBlob, 43 + objectUrl 44 + } as any; 45 + 46 + data = { ...data }; 47 + } catch (error) { 48 + console.error('Failed to process image:', error); 49 + } 50 + } 51 + 52 + function getAvatarUrl(): string | undefined { 53 + const customIcon = getImage(data.publication ?? {}, data.did, 'icon'); 54 + if (customIcon) return customIcon; 55 + return data.profile.avatar; 56 + } 57 + 58 + function handleFileInputClick() { 59 + fileInput.click(); 60 + } 61 + </script> 62 + 63 + <div 64 + class="mx-auto flex max-w-lg flex-col justify-between px-8 @5xl/wrapper:fixed @5xl/wrapper:h-screen @5xl/wrapper:w-1/4 @5xl/wrapper:max-w-none @5xl/wrapper:px-12" 65 + > 66 + <div class="flex flex-col gap-4 pt-16 pb-8 @5xl/wrapper:h-screen @5xl/wrapper:pt-24"> 67 + <!-- Avatar with edit capability --> 68 + <button 69 + type="button" 70 + class="group relative size-32 cursor-pointer overflow-hidden rounded-full @5xl/wrapper:size-44" 71 + onmouseenter={() => (isHoveringAvatar = true)} 72 + onmouseleave={() => (isHoveringAvatar = false)} 73 + onclick={handleFileInputClick} 74 + > 75 + {#if getAvatarUrl()} 76 + <img 77 + class="border-base-400 dark:border-base-800 size-full rounded-full border object-cover" 78 + src={getAvatarUrl()} 79 + alt="" 80 + /> 81 + {:else} 82 + <div class="bg-base-300 dark:bg-base-700 size-full rounded-full"></div> 83 + {/if} 84 + 85 + <!-- Hover overlay --> 86 + <div 87 + class={[ 88 + 'absolute inset-0 flex items-center justify-center rounded-full bg-black/50 transition-opacity duration-200', 89 + isHoveringAvatar ? 'opacity-100' : 'opacity-0' 90 + ]} 91 + > 92 + <div class="text-center text-sm text-white"> 93 + <svg 94 + xmlns="http://www.w3.org/2000/svg" 95 + fill="none" 96 + viewBox="0 0 24 24" 97 + stroke-width="1.5" 98 + stroke="currentColor" 99 + class="mx-auto mb-1 size-6" 100 + > 101 + <path 102 + stroke-linecap="round" 103 + stroke-linejoin="round" 104 + d="M6.827 6.175A2.31 2.31 0 0 1 5.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 0 0-1.134-.175 2.31 2.31 0 0 1-1.64-1.055l-.822-1.316a2.192 2.192 0 0 0-1.736-1.039 48.774 48.774 0 0 0-5.232 0 2.192 2.192 0 0 0-1.736 1.039l-.821 1.316Z" 105 + /> 106 + <path 107 + stroke-linecap="round" 108 + stroke-linejoin="round" 109 + d="M16.5 12.75a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0ZM18.75 10.5h.008v.008h-.008V10.5Z" 110 + /> 111 + </svg> 112 + <span>Click to change</span> 113 + </div> 114 + </div> 115 + </button> 116 + 117 + <input 118 + bind:this={fileInput} 119 + type="file" 120 + accept="image/*" 121 + class="hidden" 122 + onchange={handleAvatarChange} 123 + /> 124 + 125 + <!-- Editable Name --> 126 + {#if data.publication} 127 + <div class="text-4xl font-bold wrap-anywhere"> 128 + <PlainTextEditor bind:contentDict={data.publication} key="name" placeholder="Your name" /> 129 + </div> 130 + {/if} 131 + 132 + <!-- Editable Description --> 133 + <div class="scrollbar -mx-4 grow overflow-x-hidden overflow-y-scroll px-4"> 134 + {#if data.publication} 135 + 136 + 137 + <MarkdownTextEditor 138 + bind:editor={descriptionEditor} 139 + bind:contentDict={data.publication} 140 + key="description" 141 + placeholder="Add a description... (supports markdown)" 142 + class="" 143 + /> 144 + {/if} 145 + </div> 146 + 147 + <div class="h-10.5 w-1 @5xl/wrapper:hidden"></div> 148 + 149 + <div class="hidden text-xs font-light @5xl/wrapper:block"> 150 + made with <a 151 + href="https://blento.app" 152 + target="_blank" 153 + class="hover:text-accent-600 dark:hover:text-accent-400 font-medium transition-colors duration-200" 154 + >blento</a 155 + > 156 + </div> 157 + </div> 158 + </div>
+8 -2
src/lib/website/EditableWebsite.svelte
··· 2 import { Button, toast, Toaster, Sidebar } from '@foxui/core'; 3 import { COLUMNS, margin, mobileMargin } from '$lib'; 4 import { 5 clamp, 6 compactItems, 7 createEmptyCard, ··· 14 setPositionOfNewItem, 15 validateLink 16 } from '../helper'; 17 - import Profile from './Profile.svelte'; 18 import type { Item, WebsiteData } from '../types'; 19 import { innerWidth } from 'svelte/reactivity/window'; 20 import EditingCard from '../cards/Card/EditingCard.svelte'; ··· 132 isSaving = true; 133 134 try { 135 await savePage(data, items, publication); 136 137 publication = JSON.stringify(data.publication); ··· 559 ]} 560 > 561 {#if !getHideProfileSection(data)} 562 - <Profile {data} /> 563 {/if} 564 565 <div
··· 2 import { Button, toast, Toaster, Sidebar } from '@foxui/core'; 3 import { COLUMNS, margin, mobileMargin } from '$lib'; 4 import { 5 + checkAndUploadImage, 6 clamp, 7 compactItems, 8 createEmptyCard, ··· 15 setPositionOfNewItem, 16 validateLink 17 } from '../helper'; 18 + import EditableProfile from './EditableProfile.svelte'; 19 import type { Item, WebsiteData } from '../types'; 20 import { innerWidth } from 'svelte/reactivity/window'; 21 import EditingCard from '../cards/Card/EditingCard.svelte'; ··· 133 isSaving = true; 134 135 try { 136 + // Upload profile icon if changed 137 + if (data.publication?.icon) { 138 + await checkAndUploadImage(data.publication, 'icon'); 139 + } 140 + 141 await savePage(data, items, publication); 142 143 publication = JSON.stringify(data.publication); ··· 565 ]} 566 > 567 {#if !getHideProfileSection(data)} 568 + <EditableProfile bind:data /> 569 {/if} 570 571 <div
+2 -2
src/lib/website/Profile.svelte
··· 5 import { BlueskyLogin } from '@foxui/social'; 6 import { env } from '$env/dynamic/public'; 7 import type { WebsiteData } from '$lib/types'; 8 - import { getDescription, getName } from '$lib/helper'; 9 import { page } from '$app/state'; 10 import type { ActorIdentifier } from '@atcute/lexicons'; 11 ··· 30 {#if data.profile.avatar} 31 <img 32 class="border-base-400 dark:border-base-800 size-32 rounded-full border @5xl/wrapper:size-44" 33 - src={data.profile.avatar} 34 alt="" 35 /> 36 {:else}
··· 5 import { BlueskyLogin } from '@foxui/social'; 6 import { env } from '$env/dynamic/public'; 7 import type { WebsiteData } from '$lib/types'; 8 + import { getDescription, getImage, getName } from '$lib/helper'; 9 import { page } from '$app/state'; 10 import type { ActorIdentifier } from '@atcute/lexicons'; 11 ··· 30 {#if data.profile.avatar} 31 <img 32 class="border-base-400 dark:border-base-800 size-32 rounded-full border @5xl/wrapper:size-44" 33 + src={getImage(data.publication, data.did, 'icon') || data.profile.avatar} 34 alt="" 35 /> 36 {:else}
+1 -1
src/routes/+layout.svelte
··· 4 import { ThemeToggle } from '@foxui/core'; 5 import { onMount } from 'svelte'; 6 import { initClient } from '$lib/atproto'; 7 - import YoutubeVideoPlayer, { videoPlayer } from '$lib/cards/utils/YoutubeVideoPlayer.svelte'; 8 9 let { children } = $props(); 10
··· 4 import { ThemeToggle } from '@foxui/core'; 5 import { onMount } from 'svelte'; 6 import { initClient } from '$lib/atproto'; 7 + import YoutubeVideoPlayer, { videoPlayer } from '$lib/components/YoutubeVideoPlayer.svelte'; 8 9 let { children } = $props(); 10