your personal website on atproto - mirror
blento.app
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>