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