tangled
alpha
login
or
join now
flo-bit.dev
/
blento
21
fork
atom
your personal website on atproto - mirror
blento.app
21
fork
atom
overview
issues
pulls
pipelines
add semble collection card
Florian
3 weeks ago
303be860
5ec0e9de
+305
-2
5 changed files
expand all
collapse all
unified
split
src
lib
cards
index.ts
social
MarginCard
MarginCard.svelte
SembleCollectionCard
CreateSembleCollectionCardModal.svelte
SembleCollectionCard.svelte
index.ts
+3
-1
src/lib/cards/index.ts
···
48
48
import { LastFMProfileCardDefinition } from './media/LastFMCard/LastFMProfileCard';
49
49
import { PlyrFMCardDefinition } from './media/PlyrFMCard';
50
50
import { MarginCardDefinition } from './social/MarginCard';
51
51
+
import { SembleCollectionCardDefinition } from './social/SembleCollectionCard';
51
52
// import { Model3DCardDefinition } from './visual/Model3DCard';
52
53
53
54
export const AllCardDefinitions = [
···
100
101
LastFMTopAlbumsCardDefinition,
101
102
LastFMProfileCardDefinition,
102
103
PlyrFMCardDefinition,
103
103
-
MarginCardDefinition
104
104
+
MarginCardDefinition,
105
105
+
SembleCollectionCardDefinition
104
106
] as const;
105
107
106
108
export const CardDefinitionsByType = AllCardDefinitions.reduce(
+3
-1
src/lib/cards/social/MarginCard/MarginCard.svelte
···
137
137
{/if}
138
138
139
139
{#if entry.type === 'annotation' && entry.value.body?.value}
140
140
-
<span class="text-base-900 dark:text-base-100 accent:text-black text-sm leading-snug font-medium">
140
140
+
<span
141
141
+
class="text-base-900 dark:text-base-100 accent:text-black text-sm leading-snug font-medium"
142
142
+
>
141
143
{truncate(entry.value.body.value as string, 120)}
142
144
</span>
143
145
{/if}
+43
src/lib/cards/social/SembleCollectionCard/CreateSembleCollectionCardModal.svelte
···
1
1
+
<script lang="ts">
2
2
+
import { Alert, Button, Input, Modal, Subheading } from '@foxui/core';
3
3
+
import type { CreationModalComponentProps } from '../../types';
4
4
+
5
5
+
let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props();
6
6
+
7
7
+
let url = $state('');
8
8
+
let errorMessage = $state('');
9
9
+
10
10
+
function checkUrl() {
11
11
+
errorMessage = '';
12
12
+
const match = url.match(/^https?:\/\/semble\.so\/profile\/([^/]+)\/collections\/([a-z0-9]+)$/);
13
13
+
if (!match) {
14
14
+
errorMessage = 'Please enter a valid Semble collection URL.';
15
15
+
return false;
16
16
+
}
17
17
+
18
18
+
item.cardData.handle = match[1];
19
19
+
item.cardData.collectionRkey = match[2];
20
20
+
item.cardData.href = url;
21
21
+
return true;
22
22
+
}
23
23
+
</script>
24
24
+
25
25
+
<Modal open={true} closeButton={false}>
26
26
+
<Subheading>Enter a Semble collection URL</Subheading>
27
27
+
<Input bind:value={url} placeholder="https://semble.so/profile/.../collections/..." />
28
28
+
29
29
+
{#if errorMessage}
30
30
+
<Alert type="error" title="Invalid URL"><span>{errorMessage}</span></Alert>
31
31
+
{/if}
32
32
+
33
33
+
<div class="mt-4 flex justify-end gap-2">
34
34
+
<Button onclick={oncancel} variant="ghost">Cancel</Button>
35
35
+
<Button
36
36
+
onclick={() => {
37
37
+
if (checkUrl()) oncreate();
38
38
+
}}
39
39
+
>
40
40
+
Create
41
41
+
</Button>
42
42
+
</div>
43
43
+
</Modal>
+123
src/lib/cards/social/SembleCollectionCard/SembleCollectionCard.svelte
···
1
1
+
<script lang="ts">
2
2
+
import type { Item } from '$lib/types';
3
3
+
import { onMount } from 'svelte';
4
4
+
import { getAdditionalUserData, getDidContext, getHandleContext } from '$lib/website/context';
5
5
+
import { CardDefinitionsByType } from '../..';
6
6
+
import type { SembleCollectionData } from './index';
7
7
+
8
8
+
let { item }: { item: Item } = $props();
9
9
+
10
10
+
const additionalData = getAdditionalUserData();
11
11
+
let did = getDidContext();
12
12
+
let handle = getHandleContext();
13
13
+
14
14
+
let key = $derived(`${item.cardData.handle}/${item.cardData.collectionRkey}`);
15
15
+
16
16
+
// svelte-ignore state_referenced_locally
17
17
+
let collectionData = $state(
18
18
+
additionalData[item.cardType] != null
19
19
+
? (additionalData[item.cardType] as Record<string, SembleCollectionData>)[key]
20
20
+
: undefined
21
21
+
);
22
22
+
23
23
+
onMount(async () => {
24
24
+
if (!collectionData) {
25
25
+
const result = (await CardDefinitionsByType[item.cardType]?.loadData?.([item], {
26
26
+
did,
27
27
+
handle
28
28
+
})) as Record<string, SembleCollectionData>;
29
29
+
30
30
+
if (result) {
31
31
+
additionalData[item.cardType] = {
32
32
+
...((additionalData[item.cardType] as Record<string, SembleCollectionData>) ?? {}),
33
33
+
...result
34
34
+
};
35
35
+
collectionData = result[key];
36
36
+
}
37
37
+
}
38
38
+
});
39
39
+
40
40
+
function getDisplayUrl(url: string) {
41
41
+
try {
42
42
+
const u = new URL(url);
43
43
+
return u.hostname + (u.pathname !== '/' ? u.pathname : '');
44
44
+
} catch {
45
45
+
return url;
46
46
+
}
47
47
+
}
48
48
+
49
49
+
function truncate(text: string, max: number) {
50
50
+
if (text.length <= max) return text;
51
51
+
return text.slice(0, max) + '…';
52
52
+
}
53
53
+
</script>
54
54
+
55
55
+
<div class={['flex h-full flex-col overflow-y-auto px-5 py-4', item.cardData.label ? 'pt-12' : '']}>
56
56
+
{#if collectionData}
57
57
+
<div class="mb-3 flex flex-col gap-1">
58
58
+
<h3 class="text-base-900 dark:text-base-100 accent:text-black text-sm font-semibold">
59
59
+
{collectionData.name}
60
60
+
</h3>
61
61
+
{#if collectionData.description}
62
62
+
<p class="text-base-500 dark:text-base-400 accent:text-black/60 text-xs">
63
63
+
{collectionData.description}
64
64
+
</p>
65
65
+
{/if}
66
66
+
</div>
67
67
+
68
68
+
{#if collectionData.cards.length > 0}
69
69
+
<div class="flex flex-col gap-3">
70
70
+
{#each collectionData.cards as card (card.uri)}
71
71
+
{#if card.type === 'URL' && card.url}
72
72
+
<a
73
73
+
href={card.url}
74
74
+
target="_blank"
75
75
+
rel="noopener noreferrer"
76
76
+
class="bg-base-100 dark:bg-base-800 accent:bg-black/10 hover:bg-base-200 dark:hover:bg-base-700 accent:hover:bg-black/15 flex flex-col gap-1.5 rounded-xl px-5 py-3 transition-colors"
77
77
+
>
78
78
+
{#if card.title}
79
79
+
<span
80
80
+
class="text-base-900 dark:text-base-100 accent:text-black text-sm leading-snug font-medium"
81
81
+
>
82
82
+
{truncate(card.title, 80)}
83
83
+
</span>
84
84
+
{/if}
85
85
+
{#if card.description}
86
86
+
<span
87
87
+
class="text-base-600 dark:text-base-400 accent:text-black/70 text-xs leading-snug"
88
88
+
>
89
89
+
{truncate(card.description, 120)}
90
90
+
</span>
91
91
+
{/if}
92
92
+
<span class="text-base-400 dark:text-base-500 accent:text-black/60 truncate text-xs">
93
93
+
{getDisplayUrl(card.url)}
94
94
+
</span>
95
95
+
</a>
96
96
+
{:else if card.type === 'NOTE' && card.text}
97
97
+
<div
98
98
+
class="bg-base-100 dark:bg-base-800 accent:bg-black/10 flex flex-col gap-1.5 rounded-xl px-5 py-3"
99
99
+
>
100
100
+
<span
101
101
+
class="text-base-700 dark:text-base-300 accent:text-black/80 border-accent-500 accent:border-black/60 border-l-2 pl-3 text-sm leading-snug italic"
102
102
+
>
103
103
+
{truncate(card.text, 200)}
104
104
+
</span>
105
105
+
</div>
106
106
+
{/if}
107
107
+
{/each}
108
108
+
</div>
109
109
+
{:else}
110
110
+
<div
111
111
+
class="text-base-500 dark:text-base-400 accent:text-black/60 flex flex-1 items-center justify-center text-center text-sm"
112
112
+
>
113
113
+
No cards in this collection yet.
114
114
+
</div>
115
115
+
{/if}
116
116
+
{:else}
117
117
+
<div
118
118
+
class="text-base-500 dark:text-base-400 accent:text-black/60 flex h-full w-full items-center justify-center text-center text-sm"
119
119
+
>
120
120
+
Loading...
121
121
+
</div>
122
122
+
{/if}
123
123
+
</div>
+133
src/lib/cards/social/SembleCollectionCard/index.ts
···
1
1
+
import type { CardDefinition } from '../../types';
2
2
+
import { listRecords, getRecord, resolveHandle } from '$lib/atproto';
3
3
+
import SembleCollectionCard from './SembleCollectionCard.svelte';
4
4
+
import CreateSembleCollectionCardModal from './CreateSembleCollectionCardModal.svelte';
5
5
+
6
6
+
export type SembleCard = {
7
7
+
uri: string;
8
8
+
type: 'URL' | 'NOTE';
9
9
+
url?: string;
10
10
+
title?: string;
11
11
+
description?: string;
12
12
+
imageUrl?: string;
13
13
+
siteName?: string;
14
14
+
text?: string;
15
15
+
createdAt?: string;
16
16
+
};
17
17
+
18
18
+
export type SembleCollectionData = {
19
19
+
name: string;
20
20
+
description?: string;
21
21
+
cards: SembleCard[];
22
22
+
};
23
23
+
24
24
+
function parseSembleUrl(url: string) {
25
25
+
const match = url.match(/^https?:\/\/semble\.so\/profile\/([^/]+)\/collections\/([a-z0-9]+)$/);
26
26
+
if (!match) return null;
27
27
+
return { handle: match[1], rkey: match[2] };
28
28
+
}
29
29
+
30
30
+
async function loadCollectionData(
31
31
+
handle: string,
32
32
+
collectionRkey: string
33
33
+
): Promise<SembleCollectionData | undefined> {
34
34
+
const did = await resolveHandle({ handle: handle as `${string}.${string}` });
35
35
+
if (!did) return undefined;
36
36
+
37
37
+
const collectionUri = `at://${did}/network.cosmik.collection/${collectionRkey}`;
38
38
+
39
39
+
const [collection, allLinks, allCards] = await Promise.all([
40
40
+
getRecord({
41
41
+
did,
42
42
+
collection: 'network.cosmik.collection',
43
43
+
rkey: collectionRkey
44
44
+
}).catch(() => undefined),
45
45
+
listRecords({ did, collection: 'network.cosmik.collectionLink' }).catch(() => []),
46
46
+
listRecords({ did, collection: 'network.cosmik.card' }).catch(() => [])
47
47
+
]);
48
48
+
49
49
+
if (!collection) return undefined;
50
50
+
51
51
+
const linkedCardUris = new Set(
52
52
+
allLinks
53
53
+
.filter((link: any) => link.value.collection?.uri === collectionUri)
54
54
+
.map((link: any) => link.value.card?.uri)
55
55
+
);
56
56
+
57
57
+
const cards: SembleCard[] = allCards
58
58
+
.filter((card: any) => linkedCardUris.has(card.uri))
59
59
+
.map((card: any) => {
60
60
+
const v = card.value;
61
61
+
const content = v.content;
62
62
+
if (v.type === 'URL') {
63
63
+
return {
64
64
+
uri: card.uri,
65
65
+
type: 'URL' as const,
66
66
+
url: content?.url,
67
67
+
title: content?.metadata?.title,
68
68
+
description: content?.metadata?.description,
69
69
+
imageUrl: content?.metadata?.imageUrl,
70
70
+
siteName: content?.metadata?.siteName,
71
71
+
createdAt: v.createdAt
72
72
+
};
73
73
+
}
74
74
+
return {
75
75
+
uri: card.uri,
76
76
+
type: 'NOTE' as const,
77
77
+
text: content?.text,
78
78
+
createdAt: v.createdAt
79
79
+
};
80
80
+
});
81
81
+
82
82
+
return {
83
83
+
name: collection.value.name as string,
84
84
+
description: collection.value.description as string | undefined,
85
85
+
cards
86
86
+
};
87
87
+
}
88
88
+
89
89
+
export const SembleCollectionCardDefinition = {
90
90
+
type: 'sembleCollection',
91
91
+
contentComponent: SembleCollectionCard,
92
92
+
creationModalComponent: CreateSembleCollectionCardModal,
93
93
+
createNew: (card) => {
94
94
+
card.w = 4;
95
95
+
card.mobileW = 8;
96
96
+
card.h = 4;
97
97
+
card.mobileH = 6;
98
98
+
},
99
99
+
loadData: async (items) => {
100
100
+
const results: Record<string, SembleCollectionData> = {};
101
101
+
for (const item of items) {
102
102
+
const handle = item.cardData.handle;
103
103
+
const rkey = item.cardData.collectionRkey;
104
104
+
if (!handle || !rkey) continue;
105
105
+
try {
106
106
+
const data = await loadCollectionData(handle, rkey);
107
107
+
if (data) results[`${handle}/${rkey}`] = data;
108
108
+
} catch {
109
109
+
// skip failed fetches
110
110
+
}
111
111
+
}
112
112
+
return results;
113
113
+
},
114
114
+
onUrlHandler: (url, item) => {
115
115
+
const parsed = parseSembleUrl(url);
116
116
+
if (!parsed) return null;
117
117
+
item.cardData.handle = parsed.handle;
118
118
+
item.cardData.collectionRkey = parsed.rkey;
119
119
+
item.cardData.href = url;
120
120
+
item.w = 4;
121
121
+
item.mobileW = 8;
122
122
+
item.h = 4;
123
123
+
item.mobileH = 6;
124
124
+
return item;
125
125
+
},
126
126
+
urlHandlerPriority: 5,
127
127
+
minH: 2,
128
128
+
129
129
+
keywords: ['semble', 'collection', 'bookmarks', 'links', 'cards', 'cosmik'],
130
130
+
groups: ['Social'],
131
131
+
name: 'Semble Collection',
132
132
+
icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v12a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9a2.25 2.25 0 0 0-2.25-2.25h-5.379a1.5 1.5 0 0 1-1.06-.44Z" /></svg>`
133
133
+
} as CardDefinition & { type: 'sembleCollection' };