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