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