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