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 class={['absolute left-2 z-20 flex gap-2', profilePosition === 'side' ? 'top-12' : 'top-4']}>
66 <Button
67 size="sm"
68 onclick={() => {
69 data.publication.preferences ??= {};
70 data.publication.preferences.hideProfileSection = true;
71 data = { ...data };
72 }}
73 variant="ghost"
74 >
75 hide profile
76 </Button>
77
78 <!-- Position toggle button (desktop only) -->
79 {#if !isMobile()}
80 <Button size="sm" type="button" onclick={toggleProfilePosition} variant="ghost">
81 {profilePosition === 'side' ? 'Move to top' : 'Move to side'}
82 </Button>
83 {/if}
84 </div>
85
86 <div
87 class={[
88 'flex flex-col gap-4 pt-16 pb-8',
89 profilePosition === 'side' && '@5xl/wrapper:h-screen @5xl/wrapper:pt-24'
90 ]}
91 >
92 <!-- Avatar with edit capability -->
93 <button
94 type="button"
95 class={[
96 'group relative size-32 cursor-pointer overflow-hidden rounded-full',
97 profilePosition === 'side' && '@5xl/wrapper:size-44'
98 ]}
99 onmouseenter={() => (isHoveringAvatar = true)}
100 onmouseleave={() => (isHoveringAvatar = false)}
101 onclick={handleFileInputClick}
102 >
103 {#if getAvatarUrl()}
104 <img
105 class="border-base-400 dark:border-base-800 size-full rounded-full border object-cover"
106 src={getAvatarUrl()}
107 alt=""
108 />
109 {:else}
110 <div class="bg-base-300 dark:bg-base-700 size-full rounded-full"></div>
111 {/if}
112
113 <!-- Hover overlay -->
114 <div
115 class={[
116 'absolute inset-0 flex items-center justify-center rounded-full bg-black/50 transition-opacity duration-200',
117 isHoveringAvatar ? 'opacity-100' : 'opacity-0'
118 ]}
119 >
120 <div class="text-center text-sm text-white">
121 <svg
122 xmlns="http://www.w3.org/2000/svg"
123 fill="none"
124 viewBox="0 0 24 24"
125 stroke-width="1.5"
126 stroke="currentColor"
127 class="mx-auto mb-1 size-6"
128 >
129 <path
130 stroke-linecap="round"
131 stroke-linejoin="round"
132 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"
133 />
134 <path
135 stroke-linecap="round"
136 stroke-linejoin="round"
137 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"
138 />
139 </svg>
140 <span class="font-medium">Click to change</span>
141 </div>
142 </div>
143 </button>
144
145 <input
146 bind:this={fileInput}
147 type="file"
148 accept="image/*"
149 class="hidden"
150 onchange={handleAvatarChange}
151 />
152
153 <!-- Editable Name -->
154 {#if data.publication}
155 <div class="text-4xl font-bold wrap-anywhere">
156 <PlainTextEditor bind:contentDict={data.publication} key="name" placeholder="Your name" />
157 </div>
158 {/if}
159
160 <!-- Editable Description -->
161 <div class="scrollbar -mx-4 grow overflow-x-hidden overflow-y-scroll px-4">
162 {#if data.publication}
163 <MarkdownTextEditor
164 bind:contentDict={data.publication}
165 key="description"
166 placeholder="Something about me..."
167 class=""
168 />
169 {/if}
170 </div>
171
172 <div class={['h-10.5 w-1', profilePosition === 'side' && '@5xl/wrapper:hidden']}></div>
173
174 <MadeWithBlento class="hidden {profilePosition === 'side' && '@5xl/wrapper:block'}" />
175 </div>
176</div>