your personal website on atproto - mirror blento.app
at small-fixes 229 lines 6.7 kB view raw
1<script lang="ts"> 2 import type { WebsiteData } from '$lib/types'; 3 import { getImage, compressImage, getProfilePosition } from '$lib/helper'; 4 import PlainTextEditor from '$lib/components/PlainTextEditor.svelte'; 5 import MarkdownTextEditor from '$lib/components/MarkdownTextEditor.svelte'; 6 import { Button } from '@foxui/core'; 7 import { getIsMobile } from './context'; 8 import type { Editor } from '@tiptap/core'; 9 import MadeWithBlento from './MadeWithBlento.svelte'; 10 11 let { 12 data = $bindable(), 13 hideBlento = false 14 }: { data: WebsiteData; hideBlento?: boolean } = $props(); 15 16 let profilePosition = $derived(getProfilePosition(data)); 17 18 function toggleProfilePosition() { 19 data.publication.preferences ??= {}; 20 data.publication.preferences.profilePosition = profilePosition === 'side' ? 'top' : 'side'; 21 data = { ...data }; 22 } 23 24 let fileInput: HTMLInputElement; 25 let isHoveringAvatar = $state(false); 26 27 async function handleAvatarChange(event: Event) { 28 const target = event.target as HTMLInputElement; 29 const file = target.files?.[0]; 30 if (!file) return; 31 32 try { 33 const compressedBlob = await compressImage(file); 34 const objectUrl = URL.createObjectURL(compressedBlob); 35 36 data.publication.icon = { 37 blob: compressedBlob, 38 objectUrl 39 } as any; 40 41 data = { ...data }; 42 } catch (error) { 43 console.error('Failed to process image:', error); 44 } 45 } 46 47 function getAvatarUrl(): string | undefined { 48 const customIcon = getImage(data.publication, data.did, 'icon'); 49 if (customIcon) return customIcon; 50 return data.profile.avatar; 51 } 52 53 function handleFileInputClick() { 54 fileInput.click(); 55 } 56 57 let isMobile = getIsMobile(); 58</script> 59 60<div 61 class={[ 62 'relative mx-auto flex max-w-lg flex-col justify-between px-8', 63 profilePosition === 'side' 64 ? '@5xl/wrapper:fixed @5xl/wrapper:h-screen @5xl/wrapper:w-1/4 @5xl/wrapper:max-w-none @5xl/wrapper:px-12' 65 : '@5xl/wrapper:max-w-4xl @5xl/wrapper:px-12' 66 ]} 67> 68 <div 69 class={[ 70 'absolute left-2 z-20 flex gap-2', 71 profilePosition === 'side' ? 'top-2 left-14' : 'top-2' 72 ]} 73 > 74 <Button 75 size="icon" 76 onclick={() => { 77 data.publication.preferences ??= {}; 78 data.publication.preferences.hideProfileSection = true; 79 data = { ...data }; 80 }} 81 variant="ghost" 82 > 83 <svg 84 xmlns="http://www.w3.org/2000/svg" 85 fill="none" 86 viewBox="0 0 24 24" 87 stroke-width="1.5" 88 stroke="currentColor" 89 class="size-6" 90 > 91 <path 92 stroke-linecap="round" 93 stroke-linejoin="round" 94 d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" 95 /> 96 </svg> 97 </Button> 98 99 <!-- Position toggle button (desktop only) --> 100 {#if !isMobile()} 101 <Button size="icon" type="button" onclick={toggleProfilePosition} variant="ghost"> 102 {#if profilePosition === 'side'} 103 <svg 104 xmlns="http://www.w3.org/2000/svg" 105 fill="none" 106 viewBox="0 0 24 24" 107 stroke-width="1.5" 108 stroke="currentColor" 109 class="size-6" 110 > 111 <path 112 stroke-linecap="round" 113 stroke-linejoin="round" 114 d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25" 115 /> 116 </svg> 117 {:else} 118 <svg 119 xmlns="http://www.w3.org/2000/svg" 120 fill="none" 121 viewBox="0 0 24 24" 122 stroke-width="1.5" 123 stroke="currentColor" 124 class="size-6" 125 > 126 <path 127 stroke-linecap="round" 128 stroke-linejoin="round" 129 d="m19.5 4.5-15 15m0 0h11.25m-11.25 0V8.25" 130 /> 131 </svg> 132 {/if} 133 </Button> 134 {/if} 135 </div> 136 137 <div 138 class={[ 139 'flex flex-col gap-4 pt-16 pb-8', 140 profilePosition === 'side' && '@5xl/wrapper:h-screen @5xl/wrapper:pt-24' 141 ]} 142 > 143 <!-- Avatar with edit capability --> 144 <button 145 type="button" 146 class={[ 147 'group relative size-32 shrink-0 cursor-pointer overflow-hidden rounded-full', 148 profilePosition === 'side' && '@5xl/wrapper:size-44' 149 ]} 150 onmouseenter={() => (isHoveringAvatar = true)} 151 onmouseleave={() => (isHoveringAvatar = false)} 152 onclick={handleFileInputClick} 153 > 154 {#if getAvatarUrl()} 155 <img 156 class="border-base-400 dark:border-base-800 size-full shrink-0 rounded-full border object-cover" 157 src={getAvatarUrl()} 158 alt="" 159 /> 160 {:else} 161 <div class="bg-base-300 dark:bg-base-700 size-full rounded-full"></div> 162 {/if} 163 164 <!-- Hover overlay --> 165 <div 166 class={[ 167 'absolute inset-0 flex items-center justify-center rounded-full bg-black/50 transition-opacity duration-200', 168 isHoveringAvatar ? 'opacity-100' : 'opacity-0' 169 ]} 170 > 171 <div class="text-center text-sm text-white"> 172 <svg 173 xmlns="http://www.w3.org/2000/svg" 174 fill="none" 175 viewBox="0 0 24 24" 176 stroke-width="1.5" 177 stroke="currentColor" 178 class="mx-auto mb-1 size-6" 179 > 180 <path 181 stroke-linecap="round" 182 stroke-linejoin="round" 183 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" 184 /> 185 <path 186 stroke-linecap="round" 187 stroke-linejoin="round" 188 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" 189 /> 190 </svg> 191 <span class="font-medium">Click to change</span> 192 </div> 193 </div> 194 </button> 195 196 <input 197 bind:this={fileInput} 198 type="file" 199 accept="image/*" 200 class="hidden" 201 onchange={handleAvatarChange} 202 /> 203 204 <!-- Editable Name --> 205 {#if data.publication} 206 <div class="text-4xl font-bold wrap-anywhere"> 207 <PlainTextEditor bind:contentDict={data.publication} key="name" placeholder="Your name" /> 208 </div> 209 {/if} 210 211 <!-- Editable Description --> 212 <div class="scrollbar -mx-4 grow overflow-x-hidden overflow-y-scroll px-4"> 213 {#if data.publication} 214 <MarkdownTextEditor 215 bind:contentDict={data.publication} 216 key="description" 217 placeholder="Something about me..." 218 class="text-base-600 dark:text-base-400 prose dark:prose-invert prose-a:text-accent-500 prose-a:no-underline" 219 /> 220 {/if} 221 </div> 222 223 <div class={['h-10.5 w-1', profilePosition === 'side' && '@5xl/wrapper:hidden']}></div> 224 225 {#if !hideBlento} 226 <MadeWithBlento class="hidden {profilePosition === 'side' && '@5xl/wrapper:block'}" /> 227 {/if} 228 </div> 229</div>