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