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
improve link adding
Florian
3 weeks ago
604ac7af
0faab9c0
+361
-205
12 changed files
expand all
collapse all
unified
split
src
lib
cards
BaseCard
BaseEditingCard.svelte
BigSocialCard
index.ts
LinkCard
CreateLinkCardModal.svelte
EditingLinkCard.svelte
LinkCard.svelte
LinkCardSettings.svelte
index.ts
TextCard
TextCardSettings.svelte
types.ts
utils
PlainTextEditor.svelte
helper.ts
website
EditableWebsite.svelte
+16
-5
src/lib/cards/BaseCard/BaseEditingCard.svelte
···
136
if (!cardDef) return false;
137
138
if (isMobile()) {
139
-
140
-
return w >= minW && w*2 <= maxW && h >= minH && h*2 <= maxH;
141
}
142
143
return w >= minW && w <= maxW && h >= minH && h <= maxH;
···
154
let settingsPopoverOpen = $state(false);
155
</script>
156
157
-
<BaseCard {item} isEditing={true} bind:ref showOutline={isResizing} class="starting:scale-0 scale-100 starting:opacity-0 opacity-100" {...rest} >
0
0
0
0
0
0
0
158
{@render children?.()}
159
160
{#snippet controls()}
···
283
{/if}
284
285
{#if cardDef.settingsComponent}
286
-
<Popover bind:open={settingsPopoverOpen}>
287
{#snippet child({ props })}
288
<button {...props} class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2">
289
<svg
···
307
</svg>
308
</button>
309
{/snippet}
310
-
<cardDef.settingsComponent bind:item />
0
0
0
0
0
311
</Popover>
312
{/if}
313
</div>
···
136
if (!cardDef) return false;
137
138
if (isMobile()) {
139
+
return w >= minW && w * 2 <= maxW && h >= minH && h * 2 <= maxH;
0
140
}
141
142
return w >= minW && w <= maxW && h >= minH && h <= maxH;
···
153
let settingsPopoverOpen = $state(false);
154
</script>
155
156
+
<BaseCard
157
+
{item}
158
+
isEditing={true}
159
+
bind:ref
160
+
showOutline={isResizing}
161
+
class="scale-100 opacity-100 starting:scale-0 starting:opacity-0"
162
+
{...rest}
163
+
>
164
{@render children?.()}
165
166
{#snippet controls()}
···
289
{/if}
290
291
{#if cardDef.settingsComponent}
292
+
<Popover bind:open={settingsPopoverOpen} class="bg-base-50 dark:bg-base-900">
293
{#snippet child({ props })}
294
<button {...props} class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2">
295
<svg
···
313
</svg>
314
</button>
315
{/snippet}
316
+
<cardDef.settingsComponent
317
+
bind:item
318
+
onclose={() => {
319
+
settingsPopoverOpen = false;
320
+
}}
321
+
/>
322
</Popover>
323
{/if}
324
</div>
+2
-1
src/lib/cards/BigSocialCard/index.ts
···
32
item.cardData.href = url;
33
34
return item;
35
-
}
0
36
} as CardDefinition & { type: 'bigsocial' };
37
38
import {
···
32
item.cardData.href = url;
33
34
return item;
35
+
},
36
+
urlHandlerPriority: 1
37
} as CardDefinition & { type: 'bigsocial' };
38
39
import {
-59
src/lib/cards/LinkCard/CreateLinkCardModal.svelte
···
1
-
<script lang="ts">
2
-
import { Alert, Button, Input, Modal, Subheading } from '@foxui/core';
3
-
import type { CreationModalComponentProps } from '../types';
4
-
5
-
let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props();
6
-
7
-
let isFetchingMetadata = $state(false);
8
-
9
-
let errorMessage = $state('');
10
-
11
-
async function fetchMetadata() {
12
-
errorMessage = '';
13
-
try {
14
-
item.cardData.domain = new URL(item.cardData.href).hostname;
15
-
} catch (error) {
16
-
errorMessage = 'Invalid URL!';
17
-
return false;
18
-
}
19
-
isFetchingMetadata = true;
20
-
21
-
try {
22
-
const response = await fetch('/api/links?link=' + encodeURIComponent(item.cardData.href));
23
-
if (response.ok) {
24
-
const data = await response.json();
25
-
item.cardData.description = data.description || '';
26
-
item.cardData.title = data.title || '';
27
-
item.cardData.image = data.images?.[0] || '';
28
-
item.cardData.favicon = data.favicons?.[0] || undefined;
29
-
} else {
30
-
throw new Error();
31
-
}
32
-
} catch (error) {
33
-
errorMessage = "Couldn't fetch metadata for this link!";
34
-
return false;
35
-
} finally {
36
-
isFetchingMetadata = false;
37
-
}
38
-
return true;
39
-
}
40
-
</script>
41
-
42
-
<Modal open={true} closeButton={false}>
43
-
<Subheading>Enter a link</Subheading>
44
-
<Input bind:value={item.cardData.href} />
45
-
46
-
{#if errorMessage}
47
-
<Alert type="error" title="Failed to create link card"><span>{errorMessage}</span></Alert>
48
-
{/if}
49
-
50
-
<div class="mt-4 flex justify-end gap-2">
51
-
<Button onclick={oncancel} variant="ghost">Cancel</Button>
52
-
<Button
53
-
disabled={isFetchingMetadata}
54
-
onclick={async () => {
55
-
if (await fetchMetadata()) oncreate();
56
-
}}>{isFetchingMetadata ? 'Creating...' : 'Create'}</Button
57
-
>
58
-
</div>
59
-
</Modal>
···
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
+99
-38
src/lib/cards/LinkCard/EditingLinkCard.svelte
···
3
import { getIsMobile } from '$lib/website/context';
4
import type { ContentComponentProps } from '../types';
5
import PlainTextEditor from '../utils/PlainTextEditor.svelte';
0
6
7
let { item = $bindable() }: ContentComponentProps = $props();
8
9
let isMobile = getIsMobile();
10
11
let faviconHasError = $state(false);
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
12
</script>
13
14
-
<div class="flex h-full flex-col justify-between p-4">
15
-
<div>
16
-
{#if item.cardData.favicon}
17
-
<div
18
-
class="bg-base-100 border-base-300 dark:border-base-800 dark:bg-base-900 mb-2 inline-flex size-8 items-center justify-center rounded-xl border shadow-sm"
19
-
>
20
-
{#if !faviconHasError}
21
-
<img
22
-
class="size-6 rounded-lg object-cover"
23
-
onerror={() => (faviconHasError = true)}
24
-
src={item.cardData.favicon}
25
-
alt=""
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
26
/>
27
-
{:else}
28
-
<svg
29
-
xmlns="http://www.w3.org/2000/svg"
30
-
fill="none"
31
-
viewBox="0 0 24 24"
32
-
stroke-width="1.5"
33
-
stroke="currentColor"
34
-
class="size-4"
35
-
>
36
-
<path
37
-
stroke-linecap="round"
38
-
stroke-linejoin="round"
39
-
d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244"
40
-
/>
41
-
</svg>
42
-
{/if}
43
-
</div>
44
-
{/if}
45
46
<div
47
-
class="hover:bg-base-200/70 dark:hover:bg-base-800/70 accent:hover:bg-accent-400 -m-1 rounded-md p-1 transition-colors duration-200"
0
0
0
0
0
48
>
49
-
<PlainTextEditor
50
-
class="text-base-900 dark:text-base-50 text-lg font-bold"
51
-
key="title"
52
-
bind:item
53
-
/>
0
0
0
0
0
0
0
54
</div>
55
<!-- <div class="text-base-800 dark:text-base-100 mt-2 text-xs">{item.cardData.description}</div> -->
56
-
<div class="text-accent-600 accent:text-accent-950 font-semibold dark:text-accent-400 mt-2 text-xs">
0
0
57
{item.cardData.domain}
58
</div>
59
</div>
60
61
-
{#if browser && ((isMobile() && item.mobileH >= 8) || (!isMobile() && item.h >= 4)) && item.cardData.image}
62
<img class=" mb-2 max-h-32 w-full rounded-xl object-cover" src={item.cardData.image} alt="" />
63
{/if}
64
</div>
···
3
import { getIsMobile } from '$lib/website/context';
4
import type { ContentComponentProps } from '../types';
5
import PlainTextEditor from '../utils/PlainTextEditor.svelte';
6
+
import { onMount } from 'svelte';
7
8
let { item = $bindable() }: ContentComponentProps = $props();
9
10
let isMobile = getIsMobile();
11
12
let faviconHasError = $state(false);
13
+
let isFetchingMetadata = $state(false);
14
+
15
+
let hasFetched = $derived(item.cardData.hasFetched !== false);
16
+
17
+
async function fetchMetadata() {
18
+
let domain: string;
19
+
try {
20
+
domain = new URL(item.cardData.href).hostname;
21
+
} catch (error) {
22
+
return;
23
+
}
24
+
item.cardData.domain = domain;
25
+
faviconHasError = false;
26
+
27
+
try {
28
+
const response = await fetch('/api/links?link=' + encodeURIComponent(item.cardData.href));
29
+
if (!response.ok) {
30
+
throw new Error();
31
+
}
32
+
const data = await response.json();
33
+
item.cardData.description = data.description || '';
34
+
item.cardData.title = data.title || '';
35
+
item.cardData.image = data.images?.[0] || '';
36
+
item.cardData.favicon = data.favicons?.[0] || undefined;
37
+
} catch (error) {
38
+
return;
39
+
}
40
+
}
41
+
42
+
$effect(() => {
43
+
if (hasFetched !== false || isFetchingMetadata) {
44
+
return;
45
+
}
46
+
47
+
isFetchingMetadata = true;
48
+
49
+
fetchMetadata().then(() => {
50
+
item.cardData.hasFetched = true;
51
+
isFetchingMetadata = false;
52
+
});
53
+
});
54
</script>
55
56
+
<div class="relative flex h-full flex-col justify-between p-4">
57
+
<div
58
+
class={[
59
+
'accent:bg-accent-500/50 absolute inset-0 z-20 bg-white/50 dark:bg-black/50',
60
+
!hasFetched ? 'animate-pulse' : 'hidden'
61
+
]}
62
+
></div>
63
+
64
+
<div class={isFetchingMetadata ? 'pointer-events-none' : ''}>
65
+
<div
66
+
class="bg-base-100 border-base-300 accent:bg-accent-100/50 accent:border-accent-200 dark:border-base-800 dark:bg-base-900 mb-2 inline-flex size-8 items-center justify-center rounded-xl border"
67
+
>
68
+
{#if hasFetched && item.cardData.favicon && !faviconHasError}
69
+
<img
70
+
class="size-6 rounded-lg object-cover"
71
+
onerror={() => (faviconHasError = true)}
72
+
src={item.cardData.favicon}
73
+
alt=""
74
+
/>
75
+
{:else}
76
+
<svg
77
+
xmlns="http://www.w3.org/2000/svg"
78
+
fill="none"
79
+
viewBox="0 0 24 24"
80
+
stroke-width="1.5"
81
+
stroke="currentColor"
82
+
class="size-4"
83
+
>
84
+
<path
85
+
stroke-linecap="round"
86
+
stroke-linejoin="round"
87
+
d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 1 1.242 7.244"
88
/>
89
+
</svg>
90
+
{/if}
91
+
</div>
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
92
93
<div
94
+
class={[
95
+
'-m-1 rounded-md p-1 transition-colors duration-200',
96
+
hasFetched
97
+
? 'hover:bg-base-200/70 dark:hover:bg-base-800/70 accent:hover:bg-accent-200/30'
98
+
: ''
99
+
]}
100
>
101
+
{#if hasFetched}
102
+
<PlainTextEditor
103
+
class="text-base-900 dark:text-base-50 line-clamp-2 text-lg font-bold"
104
+
key="title"
105
+
bind:item
106
+
placeholder="Title here"
107
+
/>
108
+
{:else}
109
+
<span class={'text-base-900 dark:text-base-50 line-clamp-2 text-lg font-bold'}>
110
+
Loading data...
111
+
</span>
112
+
{/if}
113
</div>
114
<!-- <div class="text-base-800 dark:text-base-100 mt-2 text-xs">{item.cardData.description}</div> -->
115
+
<div
116
+
class="text-accent-600 accent:text-accent-950 dark:text-accent-400 mt-2 text-xs font-semibold"
117
+
>
118
{item.cardData.domain}
119
</div>
120
</div>
121
122
+
{#if hasFetched && browser && ((isMobile() && item.mobileH >= 8) || (!isMobile() && item.h >= 4)) && item.cardData.image}
123
<img class=" mb-2 max-h-32 w-full rounded-xl object-cover" src={item.cardData.image} alt="" />
124
{/if}
125
</div>
+31
-29
src/lib/cards/LinkCard/LinkCard.svelte
···
12
13
<div class="flex h-full flex-col justify-between p-4">
14
<div>
15
-
{#if item.cardData.favicon}
16
-
<div
17
-
class="bg-base-100 border-base-300 dark:border-base-800 dark:bg-base-900 mb-2 inline-flex size-8 items-center justify-center rounded-xl border shadow-sm"
18
-
>
19
-
{#if !faviconHasError}
20
-
<img
21
-
class="size-6 rounded-lg object-cover"
22
-
onerror={() => (faviconHasError = true)}
23
-
src={item.cardData.favicon}
24
-
alt=""
0
0
0
0
0
0
0
0
0
0
0
0
0
25
/>
26
-
{:else}
27
-
<svg
28
-
xmlns="http://www.w3.org/2000/svg"
29
-
fill="none"
30
-
viewBox="0 0 24 24"
31
-
stroke-width="1.5"
32
-
stroke="currentColor"
33
-
class="size-4"
34
-
>
35
-
<path
36
-
stroke-linecap="round"
37
-
stroke-linejoin="round"
38
-
d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244"
39
-
/>
40
-
</svg>
41
-
{/if}
42
-
</div>
43
-
{/if}
44
<div
45
class={[
46
'text-base-900 dark:text-base-50 text-lg font-bold',
···
58
</div>
59
60
{#if browser && ((isMobile() && item.mobileH >= 8) || (!isMobile() && item.h >= 4)) && item.cardData.image}
61
-
<img class="mb-2 max-h-32 w-full starting:opacity-0 opacity-100 transition-opacity duration-100 rounded-xl object-cover" src={item.cardData.image} alt="" />
0
0
0
0
62
{/if}
63
{#if item.cardData.href}
64
<a
···
12
13
<div class="flex h-full flex-col justify-between p-4">
14
<div>
15
+
<div
16
+
class="bg-base-100 border-base-300 accent:bg-accent-100/50 accent:border-accent-200 dark:border-base-800 dark:bg-base-900 mb-2 inline-flex size-8 items-center justify-center rounded-xl border"
17
+
>
18
+
{#if item.cardData.favicon && !faviconHasError}
19
+
<img
20
+
class="size-6 rounded-lg object-cover"
21
+
onerror={() => (faviconHasError = true)}
22
+
src={item.cardData.favicon}
23
+
alt=""
24
+
/>
25
+
{:else}
26
+
<svg
27
+
xmlns="http://www.w3.org/2000/svg"
28
+
fill="none"
29
+
viewBox="0 0 24 24"
30
+
stroke-width="1.5"
31
+
stroke="currentColor"
32
+
class="size-4"
33
+
>
34
+
<path
35
+
stroke-linecap="round"
36
+
stroke-linejoin="round"
37
+
d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244"
38
/>
39
+
</svg>
40
+
{/if}
41
+
</div>
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
42
<div
43
class={[
44
'text-base-900 dark:text-base-50 text-lg font-bold',
···
56
</div>
57
58
{#if browser && ((isMobile() && item.mobileH >= 8) || (!isMobile() && item.h >= 4)) && item.cardData.image}
59
+
<img
60
+
class="mb-2 max-h-32 w-full rounded-xl object-cover opacity-100 transition-opacity duration-100 starting:opacity-0"
61
+
src={item.cardData.image}
62
+
alt=""
63
+
/>
64
{/if}
65
{#if item.cardData.href}
66
<a
+50
src/lib/cards/LinkCard/LinkCardSettings.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
···
1
+
<script lang="ts">
2
+
import { validateLink } from '$lib/helper';
3
+
import type { Item } from '$lib/types';
4
+
import { Button, Input, toast } from '@foxui/core';
5
+
6
+
let { item, onclose }: { item: Item; onclose: () => void } = $props();
7
+
8
+
let linkValue = $derived(item.cardData.href.replace('https://', '').replace('http://', ''));
9
+
10
+
function updateLink() {
11
+
if (!linkValue.trim()) return;
12
+
13
+
let link = validateLink(linkValue);
14
+
if (!link) {
15
+
toast.error('Invalid link');
16
+
return;
17
+
}
18
+
19
+
item.cardData.href = link;
20
+
item.cardData.domain = new URL(link).hostname;
21
+
item.cardData.hasFetched = false;
22
+
23
+
onclose?.();
24
+
}
25
+
</script>
26
+
27
+
<Input
28
+
spellcheck={false}
29
+
type="url"
30
+
bind:value={linkValue}
31
+
onkeydown={(event) => {
32
+
if (event.code === 'Enter') {
33
+
updateLink();
34
+
event.preventDefault();
35
+
}
36
+
}}
37
+
placeholder="Enter link"
38
+
/>
39
+
<Button onclick={updateLink} size="icon"
40
+
><svg
41
+
xmlns="http://www.w3.org/2000/svg"
42
+
fill="none"
43
+
viewBox="0 0 24 24"
44
+
stroke-width="1.5"
45
+
stroke="currentColor"
46
+
class="size-6"
47
+
>
48
+
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
49
+
</svg>
50
+
</Button>
+9
-5
src/lib/cards/LinkCard/index.ts
···
1
import type { CardDefinition } from '../types';
2
-
import CreateLinkCardModal from './CreateLinkCardModal.svelte';
3
import EditingLinkCard from './EditingLinkCard.svelte';
4
import LinkCard from './LinkCard.svelte';
0
5
6
export const LinkCardDefinition = {
7
type: 'link',
···
9
editingContentComponent: EditingLinkCard,
10
createNew: (card) => {
11
card.cardType = 'link';
12
-
card.cardData = {
13
-
href: 'https://'
14
-
};
15
},
16
-
creationModalComponent: CreateLinkCardModal
0
0
0
0
0
0
0
17
} as CardDefinition & { type: 'link' };
···
1
import type { CardDefinition } from '../types';
0
2
import EditingLinkCard from './EditingLinkCard.svelte';
3
import LinkCard from './LinkCard.svelte';
4
+
import LinkCardSettings from './LinkCardSettings.svelte';
5
6
export const LinkCardDefinition = {
7
type: 'link',
···
9
editingContentComponent: EditingLinkCard,
10
createNew: (card) => {
11
card.cardType = 'link';
0
0
0
12
},
13
+
settingsComponent: LinkCardSettings,
14
+
onUrlHandler: (url, item) => {
15
+
item.cardData.href = url;
16
+
item.cardData.domain = new URL(url).hostname;
17
+
item.cardData.hasFetched = false;
18
+
return item;
19
+
},
20
+
urlHandlerPriority: 0
21
} as CardDefinition & { type: 'link' };
+6
-6
src/lib/cards/TextCard/TextCardSettings.svelte
···
27
viewBox="0 0 24 24"
28
fill="none"
29
stroke="currentColor"
30
-
stroke-width="1.5"
31
stroke-linecap="round"
32
stroke-linejoin="round"><path d="M21 5H3" /><path d="M15 12H3" /><path d="M17 19H3" /></svg
33
></ToggleGroupItem
···
38
viewBox="0 0 24 24"
39
fill="none"
40
stroke="currentColor"
41
-
stroke-width="1.5"
42
stroke-linecap="round"
43
stroke-linejoin="round"><path d="M21 5H3" /><path d="M17 12H7" /><path d="M19 19H5" /></svg
44
></ToggleGroupItem
···
49
viewBox="0 0 24 24"
50
fill="none"
51
stroke="currentColor"
52
-
stroke-width="1.5"
53
stroke-linecap="round"
54
stroke-linejoin="round"><path d="M21 5H3" /><path d="M21 12H9" /><path d="M21 19H7" /></svg
55
></ToggleGroupItem
···
74
viewBox="0 0 24 24"
75
fill="none"
76
stroke="currentColor"
77
-
stroke-width="1.5"
78
stroke-linecap="round"
79
stroke-linejoin="round"
80
><rect width="6" height="16" x="4" y="6" rx="2" /><rect
···
92
viewBox="0 0 24 24"
93
fill="none"
94
stroke="currentColor"
95
-
stroke-width="1.5"
96
stroke-linecap="round"
97
stroke-linejoin="round"
98
><rect width="10" height="6" x="7" y="9" rx="2" /><path d="M22 20H2" /><path
···
106
viewBox="0 0 24 24"
107
fill="none"
108
stroke="currentColor"
109
-
stroke-width="1.5"
110
stroke-linecap="round"
111
stroke-linejoin="round"
112
><rect width="14" height="6" x="5" y="12" rx="2" /><rect
···
27
viewBox="0 0 24 24"
28
fill="none"
29
stroke="currentColor"
30
+
stroke-width="2"
31
stroke-linecap="round"
32
stroke-linejoin="round"><path d="M21 5H3" /><path d="M15 12H3" /><path d="M17 19H3" /></svg
33
></ToggleGroupItem
···
38
viewBox="0 0 24 24"
39
fill="none"
40
stroke="currentColor"
41
+
stroke-width="2"
42
stroke-linecap="round"
43
stroke-linejoin="round"><path d="M21 5H3" /><path d="M17 12H7" /><path d="M19 19H5" /></svg
44
></ToggleGroupItem
···
49
viewBox="0 0 24 24"
50
fill="none"
51
stroke="currentColor"
52
+
stroke-width="2"
53
stroke-linecap="round"
54
stroke-linejoin="round"><path d="M21 5H3" /><path d="M21 12H9" /><path d="M21 19H7" /></svg
55
></ToggleGroupItem
···
74
viewBox="0 0 24 24"
75
fill="none"
76
stroke="currentColor"
77
+
stroke-width="2"
78
stroke-linecap="round"
79
stroke-linejoin="round"
80
><rect width="6" height="16" x="4" y="6" rx="2" /><rect
···
92
viewBox="0 0 24 24"
93
fill="none"
94
stroke="currentColor"
95
+
stroke-width="2"
96
stroke-linecap="round"
97
stroke-linejoin="round"
98
><rect width="10" height="6" x="7" y="9" rx="2" /><path d="M22 20H2" /><path
···
106
viewBox="0 0 24 24"
107
fill="none"
108
stroke="currentColor"
109
+
stroke-width="2"
110
stroke-linecap="round"
111
stroke-linejoin="round"
112
><rect width="14" height="6" x="5" y="12" rx="2" /><rect
+4
-4
src/lib/cards/types.ts
···
7
oncancel: () => void;
8
};
9
10
-
export type SettingsModalComponentProps = {
11
item: Item;
12
-
onsave: (item: Item) => void;
13
-
oncancel: () => void;
14
};
15
16
export type SidebarComponentProps = {
···
37
sidebarButtonText?: string;
38
39
// if this component exists, a settings button with a popover will be shown containing this component
40
-
settingsComponent?: Component<ContentComponentProps>;
41
42
// optionally load some extra data
43
loadData?: (
···
62
canResize?: boolean;
63
64
onUrlHandler?: (url: string, item: Item) => Item | null;
0
65
};
···
7
oncancel: () => void;
8
};
9
10
+
export type SettingsComponentProps = {
11
item: Item;
12
+
onclose: () => void;
0
13
};
14
15
export type SidebarComponentProps = {
···
36
sidebarButtonText?: string;
37
38
// if this component exists, a settings button with a popover will be shown containing this component
39
+
settingsComponent?: Component<SettingsComponentProps>;
40
41
// optionally load some extra data
42
loadData?: (
···
61
canResize?: boolean;
62
63
onUrlHandler?: (url: string, item: Item) => Item | null;
64
+
urlHandlerPriority?: number;
65
};
+1
src/lib/cards/utils/PlainTextEditor.svelte
···
76
:global(.tiptap p.is-editor-empty:first-child::before) {
77
color: var(--color-base-800);
78
content: attr(data-placeholder);
0
79
float: left;
80
height: 0;
81
pointer-events: none;
···
76
:global(.tiptap p.is-editor-empty:first-child::before) {
77
color: var(--color-base-800);
78
content: attr(data-placeholder);
79
+
opacity: 50%;
80
float: left;
81
height: 0;
82
pointer-events: none;
+25
src/lib/helper.ts
···
299
300
return isEditable;
301
}
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
···
299
300
return isEditable;
301
}
302
+
303
+
export function validateLink(
304
+
link: string | undefined,
305
+
tryAdding: boolean = true
306
+
): string | undefined {
307
+
if (!link) return;
308
+
try {
309
+
new URL(link);
310
+
311
+
return link;
312
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
313
+
} catch (e) {
314
+
if (!tryAdding) return;
315
+
316
+
try {
317
+
link = 'https://' + link;
318
+
new URL(link);
319
+
320
+
return link;
321
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
322
+
} catch (e) {
323
+
return;
324
+
}
325
+
}
326
+
}
+118
-58
src/lib/website/EditableWebsite.svelte
···
1
<script lang="ts">
2
import { client, login } from '$lib/oauth/auth.svelte.js';
3
4
-
import { Navbar, Button, toast, Toaster, Toggle, Sidebar } from '@foxui/core';
0
0
0
0
0
0
0
0
0
0
5
import { BlueskyLogin } from '@foxui/social';
6
7
import { COLUMNS, margin, mobileMargin } from '$lib';
···
14
getHideProfile,
15
getName,
16
isTyping,
17
-
setPositionOfNewItem
0
18
} from '../helper';
19
import Profile from './Profile.svelte';
20
import type { Item, WebsiteData } from '../types';
···
76
77
let maxHeight = $derived(items.reduce((max, item) => Math.max(max, getY(item) + getH(item)), 0));
78
79
-
function newCard(type: string = 'link') {
80
// close sidebar if open
81
const popover = document.getElementById('mobile-menu');
82
if (popover) {
···
94
mobileX: 0,
95
mobileY: 0,
96
cardType: type,
97
-
cardData: {},
98
version: 2,
99
page: data.page
100
};
···
232
233
let debugPoint = $state({ x: 0, y: 0 });
234
0
0
235
function getDragXY(
236
e: DragEvent & {
237
currentTarget: EventTarget & HTMLDivElement;
···
264
}
265
return { x: gridX, y: gridY };
266
}
267
-
</script>
268
269
-
<svelte:body
270
-
onpaste={(event) => {
271
-
if (isTyping()) return;
272
273
-
const text = event.clipboardData?.getData('text/plain');
274
-
275
-
if (!text) return;
0
0
0
276
277
-
try {
278
-
const url = new URL(text);
0
0
0
0
0
0
0
0
0
0
0
279
280
-
let item: Item = {
281
-
id: TID.nextStr(),
282
-
x: 0,
283
-
y: 0,
284
-
w: 2,
285
-
h: 2,
286
-
mobileH: 4,
287
-
mobileW: 4,
288
-
mobileX: 0,
289
-
mobileY: 0,
290
-
cardType: '',
291
-
cardData: {}
292
-
};
293
294
-
newItem.item = item;
0
0
295
296
-
for (const cardDef of AllCardDefinitions) {
297
-
if (cardDef.onUrlHandler?.(text, item)) {
298
-
item.cardType = cardDef.type;
299
-
saveNewItem();
300
-
return;
301
-
}
0
302
}
0
303
304
-
newItem = {};
305
-
} catch (e) {
306
-
return;
0
0
307
}
0
0
0
0
0
0
0
0
0
0
0
0
308
}}
309
/>
310
···
514
/></svg
515
>
516
</Button>
517
-
<Button
518
-
size="iconLg"
519
-
variant="ghost"
520
-
class="backdrop-blur-none"
521
-
onclick={() => {
522
-
newCard('link');
523
-
}}
524
-
>
525
-
<svg
526
-
xmlns="http://www.w3.org/2000/svg"
527
-
fill="none"
528
-
viewBox="-2 -2 28 28"
529
-
stroke-width="1.5"
530
-
stroke="currentColor"
531
-
>
532
-
<path
533
-
stroke-linecap="round"
534
-
stroke-linejoin="round"
535
-
d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244"
536
-
/>
537
-
</svg>
538
-
</Button>
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
539
540
<Button
541
size="iconLg"
···
1
<script lang="ts">
2
import { client, login } from '$lib/oauth/auth.svelte.js';
3
4
+
import {
5
+
Navbar,
6
+
Button,
7
+
toast,
8
+
Toaster,
9
+
Toggle,
10
+
Sidebar,
11
+
Popover,
12
+
Input,
13
+
Label
14
+
} from '@foxui/core';
15
import { BlueskyLogin } from '@foxui/social';
16
17
import { COLUMNS, margin, mobileMargin } from '$lib';
···
24
getHideProfile,
25
getName,
26
isTyping,
27
+
setPositionOfNewItem,
28
+
validateLink
29
} from '../helper';
30
import Profile from './Profile.svelte';
31
import type { Item, WebsiteData } from '../types';
···
87
88
let maxHeight = $derived(items.reduce((max, item) => Math.max(max, getY(item) + getH(item)), 0));
89
90
+
function newCard(type: string = 'link', cardData?: any) {
91
// close sidebar if open
92
const popover = document.getElementById('mobile-menu');
93
if (popover) {
···
105
mobileX: 0,
106
mobileY: 0,
107
cardType: type,
108
+
cardData: cardData ?? {},
109
version: 2,
110
page: data.page
111
};
···
243
244
let debugPoint = $state({ x: 0, y: 0 });
245
246
+
let linkPopoverOpen = $state(false);
247
+
248
function getDragXY(
249
e: DragEvent & {
250
currentTarget: EventTarget & HTMLDivElement;
···
277
}
278
return { x: gridX, y: gridY };
279
}
0
280
281
+
let linkValue = $state('');
0
0
282
283
+
function addLink(url: string) {
284
+
let link = validateLink(url);
285
+
if (!link) {
286
+
toast.error('invalid link');
287
+
return;
288
+
}
289
290
+
let item: Item = {
291
+
id: TID.nextStr(),
292
+
x: 0,
293
+
y: 0,
294
+
w: 2,
295
+
h: 2,
296
+
mobileH: 4,
297
+
mobileW: 4,
298
+
mobileX: 0,
299
+
mobileY: 0,
300
+
cardType: '',
301
+
cardData: {}
302
+
};
303
304
+
newItem.item = item;
0
0
0
0
0
0
0
0
0
0
0
0
305
306
+
console.log(AllCardDefinitions.toSorted(
307
+
(a, b) => (b.urlHandlerPriority ?? 0) - (a.urlHandlerPriority ?? 0)
308
+
));
309
310
+
for (const cardDef of AllCardDefinitions.toSorted(
311
+
(a, b) => (b.urlHandlerPriority ?? 0) - (a.urlHandlerPriority ?? 0)
312
+
)) {
313
+
if (cardDef.onUrlHandler?.(link, item)) {
314
+
item.cardType = cardDef.type;
315
+
saveNewItem();
316
+
break;
317
}
318
+
}
319
320
+
newItem = {};
321
+
322
+
if(linkValue === url) {
323
+
linkValue = '';
324
+
linkPopoverOpen = false;
325
}
326
+
}
327
+
</script>
328
+
329
+
<svelte:body
330
+
onpaste={(event) => {
331
+
if (isTyping()) return;
332
+
333
+
const text = event.clipboardData?.getData('text/plain');
334
+
const link = validateLink(text, false);
335
+
if (!link) return;
336
+
337
+
addLink(link);
338
}}
339
/>
340
···
544
/></svg
545
>
546
</Button>
547
+
548
+
<Popover sideOffset={16} bind:open={linkPopoverOpen} class="bg-base-100 dark:bg-base-900">
549
+
{#snippet child({ props })}
550
+
<Button
551
+
size="iconLg"
552
+
variant="ghost"
553
+
class="backdrop-blur-none"
554
+
onclick={() => {
555
+
newCard('link');
556
+
}}
557
+
{...props}
558
+
>
559
+
<svg
560
+
xmlns="http://www.w3.org/2000/svg"
561
+
fill="none"
562
+
viewBox="-2 -2 28 28"
563
+
stroke-width="1.5"
564
+
stroke="currentColor"
565
+
>
566
+
<path
567
+
stroke-linecap="round"
568
+
stroke-linejoin="round"
569
+
d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244"
570
+
/>
571
+
</svg>
572
+
</Button>
573
+
{/snippet}
574
+
<Input
575
+
spellcheck={false}
576
+
type="url"
577
+
bind:value={linkValue}
578
+
onkeydown={(event) => {
579
+
if (event.code === 'Enter') {
580
+
addLink(linkValue);
581
+
event.preventDefault();
582
+
}
583
+
}}
584
+
placeholder="Enter link"
585
+
/>
586
+
<Button onclick={() => addLink(linkValue)} size="icon"
587
+
><svg
588
+
xmlns="http://www.w3.org/2000/svg"
589
+
fill="none"
590
+
viewBox="0 0 24 24"
591
+
stroke-width="1.5"
592
+
stroke="currentColor"
593
+
class="size-6"
594
+
>
595
+
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
596
+
</svg>
597
+
</Button>
598
+
</Popover>
599
600
<Button
601
size="iconLg"