tangled
alpha
login
or
join now
flo-bit.dev
/
blento
your personal website on atproto - mirror
blento.app
20
fork
atom
overview
issues
pulls
pipelines
allow editing profile in place
Florian
2 weeks ago
e5f48660
0466dbed
+206
-27
14 changed files
expand all
collapse all
unified
split
src
lib
cards
BaseCard
BaseEditingCard.svelte
LinkCard
EditingLinkCard.svelte
SectionCard
EditingSectionCard.svelte
TextCard
EditingTextCard.svelte
YoutubeVideoCard
YoutubeCard.svelte
components
MarkdownTextEditor.svelte
PlainTextEditor.svelte
YoutubeVideoPlayer.svelte
extensions
RichTextLink.ts
helper.ts
website
EditableProfile.svelte
EditableWebsite.svelte
Profile.svelte
routes
+layout.svelte
+2
-2
src/lib/cards/BaseCard/BaseEditingCard.svelte
···
8
8
import { AllCardDefinitions, CardDefinitionsByType, getColor } from '..';
9
9
import { COLUMNS } from '$lib';
10
10
import { getCanEdit, getIsMobile } from '$lib/website/context';
11
11
-
import PlainTextEditor from '../utils/PlainTextEditor.svelte';
11
11
+
import PlainTextEditor from '$lib/components/PlainTextEditor.svelte';
12
12
13
13
let colorsChoices = [
14
14
{ class: 'text-base-500', label: 'base' },
···
189
189
<PlainTextEditor
190
190
class="text-base-900 dark:text-base-50 w-fit text-base font-semibold"
191
191
key="label"
192
192
-
bind:item
192
192
+
bind:contentDict={item.cardData}
193
193
placeholder="Label"
194
194
/>
195
195
</div>
+2
-2
src/lib/cards/LinkCard/EditingLinkCard.svelte
···
3
3
import { getImage } from '$lib/helper';
4
4
import { getDidContext, getIsMobile } from '$lib/website/context';
5
5
import type { ContentComponentProps } from '../types';
6
6
-
import PlainTextEditor from '../utils/PlainTextEditor.svelte';
6
6
+
import PlainTextEditor from '$lib/components/PlainTextEditor.svelte';
7
7
8
8
let { item = $bindable() }: ContentComponentProps = $props();
9
9
···
104
104
<PlainTextEditor
105
105
class="text-base-900 dark:text-base-50 line-clamp-2 text-lg font-bold"
106
106
key="title"
107
107
-
bind:item
107
107
+
bind:contentDict={item.cardData}
108
108
placeholder="Title here"
109
109
/>
110
110
{:else}
+2
-2
src/lib/cards/SectionCard/EditingSectionCard.svelte
···
2
2
import type { Item } from '$lib/types';
3
3
import { textAlignClasses, textSizeClasses, verticalAlignClasses } from '.';
4
4
import type { ContentComponentProps } from '../types';
5
5
-
import PlainTextEditor from '../utils/PlainTextEditor.svelte';
5
5
+
import PlainTextEditor from '$lib/components/PlainTextEditor.svelte';
6
6
7
7
let { item = $bindable<Item>() }: ContentComponentProps = $props();
8
8
</script>
···
15
15
textSizeClasses[(item.cardData.textSize ?? 1) as number]
16
16
]}
17
17
>
18
18
-
<PlainTextEditor bind:item key="text" class="line-clamp-1 w-full" />
18
18
+
<PlainTextEditor bind:contentDict={item.cardData} key="text" class="line-clamp-1 w-full" />
19
19
</div>
+2
-2
src/lib/cards/TextCard/EditingTextCard.svelte
···
3
3
import type { Editor } from '@tiptap/core';
4
4
import { textAlignClasses, textSizeClasses, verticalAlignClasses } from '.';
5
5
import type { ContentComponentProps } from '../types';
6
6
-
import MarkdownTextEditor from '../utils/MarkdownTextEditor.svelte';
6
6
+
import MarkdownTextEditor from '$lib/components/MarkdownTextEditor.svelte';
7
7
import { cn } from '@foxui/core';
8
8
9
9
let { item = $bindable<Item>() }: ContentComponentProps = $props();
···
26
26
editor?.commands.focus('end');
27
27
}}
28
28
>
29
29
-
<MarkdownTextEditor bind:item bind:editor />
29
29
+
<MarkdownTextEditor bind:contentDict={item.cardData} key="text" bind:editor />
30
30
</div>
+1
-1
src/lib/cards/YoutubeVideoCard/YoutubeCard.svelte
···
1
1
<script lang="ts">
2
2
-
import { videoPlayer } from '../utils/YoutubeVideoPlayer.svelte';
2
2
+
import { videoPlayer } from '$lib/components/YoutubeVideoPlayer.svelte';
3
3
import type { ContentComponentProps } from '../types';
4
4
5
5
let { item }: ContentComponentProps = $props();
+11
-7
src/lib/cards/utils/MarkdownTextEditor.svelte
src/lib/components/MarkdownTextEditor.svelte
···
15
15
16
16
let {
17
17
editor = $bindable(),
18
18
-
item = $bindable(),
18
18
+
contentDict = $bindable(),
19
19
+
key = 'text',
19
20
placeholder = '',
20
20
-
defaultContent = ''
21
21
+
defaultContent = '',
22
22
+
class: className
21
23
}: {
22
24
editor: Editor | null;
23
23
-
item: Item;
25
25
+
contentDict: Record<string, any>;
26
26
+
key: string;
24
27
placeholder?: string;
25
28
defaultContent?: string;
29
29
+
class?: string;
26
30
} = $props();
27
31
28
32
const update = async () => {
···
36
40
});
37
41
const markdown = turndownService.turndown(html);
38
42
39
39
-
item.cardData.text = markdown;
43
43
+
contentDict[key] = markdown;
40
44
};
41
45
42
46
onMount(async () => {
···
45
49
let json: Content = '';
46
50
47
51
try {
48
48
-
let html = await marked.parse(item.cardData.text ?? (defaultContent as string));
52
52
+
let html = await marked.parse(contentDict[key] ?? (defaultContent as string));
49
53
50
54
// parse to json
51
55
json = generateJSON(html, [
···
100
104
101
105
editorProps: {
102
106
attributes: {
103
103
-
class: 'outline-none w-full'
107
107
+
class: 'outline-none w-full text-base-600 dark:text-base-400 prose dark:prose-invert prose-a:text-accent-500 prose-a:no-underline'
104
108
},
105
109
handleDOMEvents: { drop: () => false }
106
110
}
···
114
118
});
115
119
</script>
116
120
117
117
-
<div class="w-full cursor-text" bind:this={element}></div>
121
121
+
<div class={["w-full cursor-text", className]} bind:this={element}></div>
118
122
119
123
<style>
120
124
:global(.tiptap p.is-editor-empty:first-child::before) {
+11
-4
src/lib/cards/utils/PlainTextEditor.svelte
src/lib/components/PlainTextEditor.svelte
···
11
11
let editor: Editor | null = $state(null);
12
12
13
13
let {
14
14
-
item = $bindable(),
14
14
+
contentDict = $bindable(),
15
15
key,
16
16
class: className,
17
17
placeholder = '',
18
18
defaultContent = ''
19
19
}: {
20
20
-
item: Item;
20
20
+
contentDict: Record<string, any>;
21
21
key: string;
22
22
class?: string;
23
23
placeholder?: string;
···
27
27
const update = async () => {
28
28
if (!editor) return;
29
29
30
30
-
item.cardData[key] = editor.getText();
30
30
+
contentDict[key] = editor.getText();
31
31
};
32
32
33
33
onMount(async () => {
···
53
53
update();
54
54
},
55
55
56
56
-
content: item.cardData[key] ?? defaultContent,
56
56
+
content: contentDict[key] ?? defaultContent,
57
57
58
58
editorProps: {
59
59
attributes: {
60
60
class: 'outline-none pointer-events-auto'
61
61
+
},
62
62
+
handleKeyDown: (_view, event) => {
63
63
+
// Prevent newlines by blocking Enter key
64
64
+
if (event.key === 'Enter') {
65
65
+
return true;
66
66
+
}
67
67
+
return false;
61
68
}
62
69
}
63
70
});
src/lib/cards/utils/YoutubeVideoPlayer.svelte
src/lib/components/YoutubeVideoPlayer.svelte
src/lib/cards/utils/extensions/RichTextLink.ts
src/lib/components/extensions/RichTextLink.ts
+6
-2
src/lib/helper.ts
···
576
576
}
577
577
}
578
578
579
579
-
export function getImage(objectWithImage: Record<string, any>, did: string, key: string = 'image') {
580
580
-
if (!objectWithImage[key]) return;
579
579
+
export function getImage(
580
580
+
objectWithImage: Record<string, any> | undefined,
581
581
+
did: string,
582
582
+
key: string = 'image'
583
583
+
) {
584
584
+
if (!objectWithImage?.[key]) return;
581
585
582
586
if (objectWithImage[key].objectUrl) return objectWithImage[key].objectUrl;
583
587
+158
src/lib/website/EditableProfile.svelte
···
1
1
+
<script lang="ts">
2
2
+
import type { WebsiteData } from '$lib/types';
3
3
+
import { getDescription, getName, getImage, compressImage } from '$lib/helper';
4
4
+
import PlainTextEditor from '$lib/components/PlainTextEditor.svelte';
5
5
+
import MarkdownTextEditor from '$lib/components/MarkdownTextEditor.svelte';
6
6
+
import type { Editor } from '@tiptap/core';
7
7
+
8
8
+
let { data = $bindable() }: { data: WebsiteData } = $props();
9
9
+
10
10
+
let fileInput: HTMLInputElement;
11
11
+
let isHoveringAvatar = $state(false);
12
12
+
let descriptionEditor: Editor | null = $state(null);
13
13
+
14
14
+
// Initialize publication if needed
15
15
+
$effect(() => {
16
16
+
if (!data.publication) {
17
17
+
data.publication = {
18
18
+
name: getName(data),
19
19
+
description: getDescription(data)
20
20
+
};
21
21
+
} else {
22
22
+
if (data.publication.name === undefined) {
23
23
+
data.publication.name = getName(data);
24
24
+
}
25
25
+
if (data.publication.description === undefined) {
26
26
+
data.publication.description = getDescription(data);
27
27
+
}
28
28
+
}
29
29
+
});
30
30
+
31
31
+
async function handleAvatarChange(event: Event) {
32
32
+
const target = event.target as HTMLInputElement;
33
33
+
const file = target.files?.[0];
34
34
+
if (!file) return;
35
35
+
36
36
+
try {
37
37
+
const compressedBlob = await compressImage(file);
38
38
+
const objectUrl = URL.createObjectURL(compressedBlob);
39
39
+
40
40
+
data.publication ??= {};
41
41
+
data.publication.icon = {
42
42
+
blob: compressedBlob,
43
43
+
objectUrl
44
44
+
} as any;
45
45
+
46
46
+
data = { ...data };
47
47
+
} catch (error) {
48
48
+
console.error('Failed to process image:', error);
49
49
+
}
50
50
+
}
51
51
+
52
52
+
function getAvatarUrl(): string | undefined {
53
53
+
const customIcon = getImage(data.publication ?? {}, data.did, 'icon');
54
54
+
if (customIcon) return customIcon;
55
55
+
return data.profile.avatar;
56
56
+
}
57
57
+
58
58
+
function handleFileInputClick() {
59
59
+
fileInput.click();
60
60
+
}
61
61
+
</script>
62
62
+
63
63
+
<div
64
64
+
class="mx-auto flex max-w-lg flex-col justify-between px-8 @5xl/wrapper:fixed @5xl/wrapper:h-screen @5xl/wrapper:w-1/4 @5xl/wrapper:max-w-none @5xl/wrapper:px-12"
65
65
+
>
66
66
+
<div class="flex flex-col gap-4 pt-16 pb-8 @5xl/wrapper:h-screen @5xl/wrapper:pt-24">
67
67
+
<!-- Avatar with edit capability -->
68
68
+
<button
69
69
+
type="button"
70
70
+
class="group relative size-32 cursor-pointer overflow-hidden rounded-full @5xl/wrapper:size-44"
71
71
+
onmouseenter={() => (isHoveringAvatar = true)}
72
72
+
onmouseleave={() => (isHoveringAvatar = false)}
73
73
+
onclick={handleFileInputClick}
74
74
+
>
75
75
+
{#if getAvatarUrl()}
76
76
+
<img
77
77
+
class="border-base-400 dark:border-base-800 size-full rounded-full border object-cover"
78
78
+
src={getAvatarUrl()}
79
79
+
alt=""
80
80
+
/>
81
81
+
{:else}
82
82
+
<div class="bg-base-300 dark:bg-base-700 size-full rounded-full"></div>
83
83
+
{/if}
84
84
+
85
85
+
<!-- Hover overlay -->
86
86
+
<div
87
87
+
class={[
88
88
+
'absolute inset-0 flex items-center justify-center rounded-full bg-black/50 transition-opacity duration-200',
89
89
+
isHoveringAvatar ? 'opacity-100' : 'opacity-0'
90
90
+
]}
91
91
+
>
92
92
+
<div class="text-center text-sm text-white">
93
93
+
<svg
94
94
+
xmlns="http://www.w3.org/2000/svg"
95
95
+
fill="none"
96
96
+
viewBox="0 0 24 24"
97
97
+
stroke-width="1.5"
98
98
+
stroke="currentColor"
99
99
+
class="mx-auto mb-1 size-6"
100
100
+
>
101
101
+
<path
102
102
+
stroke-linecap="round"
103
103
+
stroke-linejoin="round"
104
104
+
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"
105
105
+
/>
106
106
+
<path
107
107
+
stroke-linecap="round"
108
108
+
stroke-linejoin="round"
109
109
+
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"
110
110
+
/>
111
111
+
</svg>
112
112
+
<span>Click to change</span>
113
113
+
</div>
114
114
+
</div>
115
115
+
</button>
116
116
+
117
117
+
<input
118
118
+
bind:this={fileInput}
119
119
+
type="file"
120
120
+
accept="image/*"
121
121
+
class="hidden"
122
122
+
onchange={handleAvatarChange}
123
123
+
/>
124
124
+
125
125
+
<!-- Editable Name -->
126
126
+
{#if data.publication}
127
127
+
<div class="text-4xl font-bold wrap-anywhere">
128
128
+
<PlainTextEditor bind:contentDict={data.publication} key="name" placeholder="Your name" />
129
129
+
</div>
130
130
+
{/if}
131
131
+
132
132
+
<!-- Editable Description -->
133
133
+
<div class="scrollbar -mx-4 grow overflow-x-hidden overflow-y-scroll px-4">
134
134
+
{#if data.publication}
135
135
+
136
136
+
137
137
+
<MarkdownTextEditor
138
138
+
bind:editor={descriptionEditor}
139
139
+
bind:contentDict={data.publication}
140
140
+
key="description"
141
141
+
placeholder="Add a description... (supports markdown)"
142
142
+
class=""
143
143
+
/>
144
144
+
{/if}
145
145
+
</div>
146
146
+
147
147
+
<div class="h-10.5 w-1 @5xl/wrapper:hidden"></div>
148
148
+
149
149
+
<div class="hidden text-xs font-light @5xl/wrapper:block">
150
150
+
made with <a
151
151
+
href="https://blento.app"
152
152
+
target="_blank"
153
153
+
class="hover:text-accent-600 dark:hover:text-accent-400 font-medium transition-colors duration-200"
154
154
+
>blento</a
155
155
+
>
156
156
+
</div>
157
157
+
</div>
158
158
+
</div>
+8
-2
src/lib/website/EditableWebsite.svelte
···
2
2
import { Button, toast, Toaster, Sidebar } from '@foxui/core';
3
3
import { COLUMNS, margin, mobileMargin } from '$lib';
4
4
import {
5
5
+
checkAndUploadImage,
5
6
clamp,
6
7
compactItems,
7
8
createEmptyCard,
···
14
15
setPositionOfNewItem,
15
16
validateLink
16
17
} from '../helper';
17
17
-
import Profile from './Profile.svelte';
18
18
+
import EditableProfile from './EditableProfile.svelte';
18
19
import type { Item, WebsiteData } from '../types';
19
20
import { innerWidth } from 'svelte/reactivity/window';
20
21
import EditingCard from '../cards/Card/EditingCard.svelte';
···
132
133
isSaving = true;
133
134
134
135
try {
136
136
+
// Upload profile icon if changed
137
137
+
if (data.publication?.icon) {
138
138
+
await checkAndUploadImage(data.publication, 'icon');
139
139
+
}
140
140
+
135
141
await savePage(data, items, publication);
136
142
137
143
publication = JSON.stringify(data.publication);
···
559
565
]}
560
566
>
561
567
{#if !getHideProfileSection(data)}
562
562
-
<Profile {data} />
568
568
+
<EditableProfile bind:data />
563
569
{/if}
564
570
565
571
<div
+2
-2
src/lib/website/Profile.svelte
···
5
5
import { BlueskyLogin } from '@foxui/social';
6
6
import { env } from '$env/dynamic/public';
7
7
import type { WebsiteData } from '$lib/types';
8
8
-
import { getDescription, getName } from '$lib/helper';
8
8
+
import { getDescription, getImage, getName } from '$lib/helper';
9
9
import { page } from '$app/state';
10
10
import type { ActorIdentifier } from '@atcute/lexicons';
11
11
···
30
30
{#if data.profile.avatar}
31
31
<img
32
32
class="border-base-400 dark:border-base-800 size-32 rounded-full border @5xl/wrapper:size-44"
33
33
-
src={data.profile.avatar}
33
33
+
src={getImage(data.publication, data.did, 'icon') || data.profile.avatar}
34
34
alt=""
35
35
/>
36
36
{:else}
+1
-1
src/routes/+layout.svelte
···
4
4
import { ThemeToggle } from '@foxui/core';
5
5
import { onMount } from 'svelte';
6
6
import { initClient } from '$lib/atproto';
7
7
-
import YoutubeVideoPlayer, { videoPlayer } from '$lib/cards/utils/YoutubeVideoPlayer.svelte';
7
7
+
import YoutubeVideoPlayer, { videoPlayer } from '$lib/components/YoutubeVideoPlayer.svelte';
8
8
9
9
let { children } = $props();
10
10