tangled
alpha
login
or
join now
treethought.xyz
/
obsidian-atmark
8
fork
atom
AT protocol bookmarking platforms in obsidian
8
fork
atom
overview
issues
pulls
pipelines
refactor and add support for community bookmakrs/kipclip
treethought
1 month ago
f13ae0b8
6443d8c1
+1203
-792
14 changed files
expand all
collapse all
unified
split
src
components
cardDetailModal.ts
profileIcon.ts
lib
atproto.ts
bookmarks.ts
cosmik.ts
lib.ts
main.ts
sources
bookmark.ts
semble.ts
types.ts
views
atmark.ts
cards.ts
collections.ts
styles.css
+20
-130
src/components/cardDetailModal.ts
···
1
1
-
import { Modal, Notice, setIcon } from "obsidian";
1
1
+
import { Modal, Notice } from "obsidian";
2
2
import type ATmarkPlugin from "../main";
3
3
-
import type { Main as Card, NoteContent, UrlContent } from "../lexicons/types/network/cosmik/card";
4
3
import { createNoteCard, deleteRecord } from "../lib";
5
5
-
6
6
-
interface AttachedNote {
7
7
-
uri: string;
8
8
-
text: string;
9
9
-
}
10
10
-
11
11
-
interface CardRecord {
12
12
-
uri: string;
13
13
-
cid: string;
14
14
-
value: Card;
15
15
-
}
16
16
-
17
17
-
interface CardWithNotes extends CardRecord {
18
18
-
attachedNotes: AttachedNote[];
19
19
-
}
4
4
+
import type { ATmarkItem } from "../sources/types";
20
5
21
6
export class CardDetailModal extends Modal {
22
7
plugin: ATmarkPlugin;
23
23
-
card: CardWithNotes;
8
8
+
item: ATmarkItem;
24
9
onSuccess?: () => void;
25
10
noteInput: HTMLTextAreaElement | null = null;
26
11
27
27
-
constructor(plugin: ATmarkPlugin, card: CardWithNotes, onSuccess?: () => void) {
12
12
+
constructor(plugin: ATmarkPlugin, item: ATmarkItem, onSuccess?: () => void) {
28
13
super(plugin.app);
29
14
this.plugin = plugin;
30
30
-
this.card = card;
15
15
+
this.item = item;
31
16
this.onSuccess = onSuccess;
32
17
}
33
18
···
36
21
contentEl.empty();
37
22
contentEl.addClass("semble-detail-modal");
38
23
39
39
-
const card = this.card.value;
40
40
-
41
41
-
// Header with type badge
24
24
+
// Header with source badge
42
25
const header = contentEl.createEl("div", { cls: "semble-detail-header" });
26
26
+
const source = this.item.getSource();
43
27
header.createEl("span", {
44
44
-
text: card.type,
45
45
-
cls: `semble-badge semble-badge-${card.type?.toLowerCase() || "unknown"}`,
28
28
+
text: source,
29
29
+
cls: `semble-badge semble-badge-source semble-badge-${source}`,
46
30
});
47
31
48
48
-
if (card.type === "NOTE") {
49
49
-
this.renderNoteCard(contentEl, card);
50
50
-
} else if (card.type === "URL") {
51
51
-
this.renderUrlCard(contentEl, card);
52
52
-
}
32
32
+
// Render item detail content
33
33
+
this.item.renderDetail(contentEl);
53
34
54
54
-
// Attached notes section
55
55
-
if (this.card.attachedNotes.length > 0) {
56
56
-
const notesSection = contentEl.createEl("div", { cls: "semble-detail-notes-section" });
57
57
-
notesSection.createEl("h3", { text: "Notes", cls: "semble-detail-section-title" });
58
58
-
59
59
-
for (const note of this.card.attachedNotes) {
60
60
-
const noteEl = notesSection.createEl("div", { cls: "semble-detail-note" });
61
61
-
62
62
-
const noteContent = noteEl.createEl("div", { cls: "semble-detail-note-content" });
63
63
-
const noteIcon = noteContent.createEl("span", { cls: "semble-detail-note-icon" });
64
64
-
setIcon(noteIcon, "message-square");
65
65
-
noteContent.createEl("p", { text: note.text, cls: "semble-detail-note-text" });
66
66
-
67
67
-
const deleteBtn = noteEl.createEl("button", { cls: "semble-note-delete-btn" });
68
68
-
setIcon(deleteBtn, "trash-2");
69
69
-
deleteBtn.setAttribute("aria-label", "Delete note");
70
70
-
deleteBtn.addEventListener("click", () => { void this.handleDeleteNote(note.uri); });
71
71
-
}
35
35
+
// Add note form (only for items that support it)
36
36
+
if (this.item.canAddNotes()) {
37
37
+
this.renderAddNoteForm(contentEl);
72
38
}
73
39
74
74
-
// Add note form
75
75
-
this.renderAddNoteForm(contentEl);
76
76
-
77
40
// Footer with date
78
78
-
if (card.createdAt) {
79
79
-
const footer = contentEl.createEl("div", { cls: "semble-detail-footer" });
80
80
-
footer.createEl("span", {
81
81
-
text: `Created ${new Date(card.createdAt).toLocaleDateString()}`,
82
82
-
cls: "semble-detail-date",
83
83
-
});
84
84
-
}
85
85
-
}
86
86
-
87
87
-
private renderNoteCard(contentEl: HTMLElement, card: Card) {
88
88
-
const content = card.content as NoteContent;
89
89
-
const body = contentEl.createEl("div", { cls: "semble-detail-body" });
90
90
-
body.createEl("p", { text: content.text, cls: "semble-detail-text" });
91
91
-
}
92
92
-
93
93
-
private renderUrlCard(contentEl: HTMLElement, card: Card) {
94
94
-
const content = card.content as UrlContent;
95
95
-
const meta = content.metadata;
96
96
-
const body = contentEl.createEl("div", { cls: "semble-detail-body" });
97
97
-
98
98
-
// Title
99
99
-
if (meta?.title) {
100
100
-
body.createEl("h2", { text: meta.title, cls: "semble-detail-title" });
101
101
-
}
102
102
-
103
103
-
// Image
104
104
-
if (meta?.imageUrl) {
105
105
-
const img = body.createEl("img", { cls: "semble-detail-image" });
106
106
-
img.src = meta.imageUrl;
107
107
-
img.alt = meta.title || "Image";
108
108
-
}
109
109
-
110
110
-
// Full description
111
111
-
if (meta?.description) {
112
112
-
body.createEl("p", { text: meta.description, cls: "semble-detail-description" });
113
113
-
}
114
114
-
115
115
-
// Metadata grid
116
116
-
const metaGrid = body.createEl("div", { cls: "semble-detail-meta" });
117
117
-
118
118
-
if (meta?.siteName) {
119
119
-
this.addMetaItem(metaGrid, "Site", meta.siteName);
120
120
-
}
121
121
-
122
122
-
if (meta?.author) {
123
123
-
this.addMetaItem(metaGrid, "Author", meta.author);
124
124
-
}
125
125
-
126
126
-
if (meta?.publishedDate) {
127
127
-
this.addMetaItem(metaGrid, "Published", new Date(meta.publishedDate).toLocaleDateString());
128
128
-
}
129
129
-
130
130
-
if (meta?.type) {
131
131
-
this.addMetaItem(metaGrid, "Type", meta.type);
132
132
-
}
133
133
-
134
134
-
if (meta?.doi) {
135
135
-
this.addMetaItem(metaGrid, "DOI", meta.doi);
136
136
-
}
137
137
-
138
138
-
if (meta?.isbn) {
139
139
-
this.addMetaItem(metaGrid, "ISBN", meta.isbn);
140
140
-
}
141
141
-
142
142
-
// URL link
143
143
-
const linkWrapper = body.createEl("div", { cls: "semble-detail-link-wrapper" });
144
144
-
const link = linkWrapper.createEl("a", {
145
145
-
text: content.url,
146
146
-
href: content.url,
147
147
-
cls: "semble-detail-link",
41
41
+
const footer = contentEl.createEl("div", { cls: "semble-detail-footer" });
42
42
+
footer.createEl("span", {
43
43
+
text: `Created ${new Date(this.item.getCreatedAt()).toLocaleDateString()}`,
44
44
+
cls: "semble-detail-date",
148
45
});
149
149
-
link.setAttr("target", "_blank");
150
46
}
151
47
152
48
private renderAddNoteForm(contentEl: HTMLElement) {
···
157
53
158
54
this.noteInput = form.createEl("textarea", {
159
55
cls: "semble-textarea semble-note-input",
160
160
-
attr: { placeholder: "Write a note about this card..." },
56
56
+
attr: { placeholder: "Write a note about this item..." },
161
57
});
162
58
163
59
const addBtn = form.createEl("button", { text: "Add note", cls: "semble-btn semble-btn-primary" });
···
178
74
this.plugin.client,
179
75
this.plugin.settings.identifier,
180
76
text,
181
181
-
{ uri: this.card.uri, cid: this.card.cid }
77
77
+
{ uri: this.item.getUri(), cid: this.item.getCid() }
182
78
);
183
79
184
80
new Notice("Note added");
···
214
110
const message = err instanceof Error ? err.message : String(err);
215
111
new Notice(`Failed to delete note: ${message}`);
216
112
}
217
217
-
}
218
218
-
219
219
-
private addMetaItem(container: HTMLElement, label: string, value: string) {
220
220
-
const item = container.createEl("div", { cls: "semble-detail-meta-item" });
221
221
-
item.createEl("span", { text: label, cls: "semble-detail-meta-label" });
222
222
-
item.createEl("span", { text: value, cls: "semble-detail-meta-value" });
223
113
}
224
114
225
115
onClose() {
+20
src/components/profileIcon.ts
···
1
1
+
import type { Client } from "@atcute/client";
2
2
+
import { getProfile } from "../lib";
3
3
+
1
4
export interface ProfileData {
2
5
did: string;
3
6
handle: string;
4
7
displayName?: string;
5
8
avatar?: string;
9
9
+
}
10
10
+
11
11
+
export async function fetchProfileData(client: Client, actor: string): Promise<ProfileData | null> {
12
12
+
try {
13
13
+
const resp = await getProfile(client, actor);
14
14
+
if (!resp.ok) return null;
15
15
+
16
16
+
return {
17
17
+
did: resp.data.did,
18
18
+
handle: resp.data.handle,
19
19
+
displayName: resp.data.displayName,
20
20
+
avatar: resp.data.avatar,
21
21
+
};
22
22
+
} catch (e) {
23
23
+
console.error("Failed to fetch profile:", e);
24
24
+
return null;
25
25
+
}
6
26
}
7
27
8
28
export function renderProfileIcon(
+11
-149
src/lib.ts
···
1
1
-
import type { Client } from "@atcute/client";
2
2
-
import type { ActorIdentifier, Nsid } from "@atcute/lexicons";
3
3
-
4
4
-
export async function getProfile(client: Client, actor: string) {
5
5
-
return await client.get("app.bsky.actor.getProfile", {
6
6
-
params: { actor: actor as ActorIdentifier },
7
7
-
});
8
8
-
}
9
9
-
10
10
-
export async function getCollections(client: Client, repo: string) {
11
11
-
return await client.get("com.atproto.repo.listRecords", {
12
12
-
params: {
13
13
-
repo: repo as ActorIdentifier,
14
14
-
collection: "network.cosmik.collection" as Nsid,
15
15
-
limit: 100,
16
16
-
},
17
17
-
});
18
18
-
}
19
19
-
20
20
-
21
21
-
export async function createCollection(client: Client, repo: string, name: string, description: string) {
22
22
-
return await client.post("com.atproto.repo.createRecord", {
23
23
-
input: {
24
24
-
repo: repo as ActorIdentifier,
25
25
-
collection: "network.cosmik.collection" as Nsid,
26
26
-
validate: false,
27
27
-
record: {
28
28
-
$type: "network.cosmik.collection",
29
29
-
name,
30
30
-
description,
31
31
-
accessType: "CLOSED",
32
32
-
createdAt: new Date().toISOString(),
33
33
-
},
34
34
-
},
35
35
-
});
36
36
-
}
37
37
-
38
38
-
39
39
-
export async function createNoteCard(client: Client, repo: string, text: string, originalCard?: { uri: string; cid: string }) {
40
40
-
return await client.post("com.atproto.repo.createRecord", {
41
41
-
input: {
42
42
-
repo: repo as ActorIdentifier,
43
43
-
collection: "network.cosmik.card" as Nsid,
44
44
-
record: {
45
45
-
$type: "network.cosmik.card",
46
46
-
type: "NOTE",
47
47
-
content: {
48
48
-
$type: "network.cosmik.card#noteContent",
49
49
-
text,
50
50
-
},
51
51
-
originalCard: originalCard ? { uri: originalCard.uri, cid: originalCard.cid } : undefined,
52
52
-
createdAt: new Date().toISOString(),
53
53
-
},
54
54
-
},
55
55
-
});
56
56
-
}
57
57
-
58
58
-
export async function createUrlCard(client: Client, repo: string, url: string, metadata?: {
59
59
-
title?: string;
60
60
-
description?: string;
61
61
-
imageUrl?: string;
62
62
-
siteName?: string;
63
63
-
}) {
64
64
-
return await client.post("com.atproto.repo.createRecord", {
65
65
-
input: {
66
66
-
repo: repo as ActorIdentifier,
67
67
-
collection: "network.cosmik.card" as Nsid,
68
68
-
record: {
69
69
-
$type: "network.cosmik.card",
70
70
-
type: "URL",
71
71
-
url,
72
72
-
content: {
73
73
-
$type: "network.cosmik.card#urlContent",
74
74
-
url,
75
75
-
metadata: metadata ? { $type: "network.cosmik.card#urlMetadata", ...metadata } : undefined,
76
76
-
},
77
77
-
createdAt: new Date().toISOString(),
78
78
-
},
79
79
-
},
80
80
-
});
81
81
-
}
82
82
-
83
83
-
export async function getCards(client: Client, repo: string) {
84
84
-
return await client.get("com.atproto.repo.listRecords", {
85
85
-
params: {
86
86
-
repo: repo as ActorIdentifier,
87
87
-
collection: "network.cosmik.card" as Nsid,
88
88
-
limit: 100,
89
89
-
},
90
90
-
});
91
91
-
}
92
92
-
93
93
-
export async function getCollectionLinks(client: Client, repo: string) {
94
94
-
return await client.get("com.atproto.repo.listRecords", {
95
95
-
params: {
96
96
-
repo: repo as ActorIdentifier,
97
97
-
collection: "network.cosmik.collectionLink" as Nsid,
98
98
-
limit: 100,
99
99
-
},
100
100
-
});
101
101
-
}
102
102
-
103
103
-
export async function createCollectionLink(
104
104
-
client: Client,
105
105
-
repo: string,
106
106
-
cardUri: string,
107
107
-
cardCid: string,
108
108
-
collectionUri: string,
109
109
-
collectionCid: string
110
110
-
) {
111
111
-
return await client.post("com.atproto.repo.createRecord", {
112
112
-
input: {
113
113
-
repo: repo as ActorIdentifier,
114
114
-
collection: "network.cosmik.collectionLink" as Nsid,
115
115
-
record: {
116
116
-
$type: "network.cosmik.collectionLink",
117
117
-
card: {
118
118
-
uri: cardUri,
119
119
-
cid: cardCid,
120
120
-
},
121
121
-
collection: {
122
122
-
uri: collectionUri,
123
123
-
cid: collectionCid,
124
124
-
},
125
125
-
addedAt: new Date().toISOString(),
126
126
-
addedBy: repo,
127
127
-
createdAt: new Date().toISOString(),
128
128
-
},
129
129
-
},
130
130
-
});
131
131
-
}
1
1
+
export { getRecord, deleteRecord, getProfile } from "./lib/atproto";
132
2
133
133
-
export async function getRecord(client: Client, repo: string, collection: string, rkey: string) {
134
134
-
return await client.get("com.atproto.repo.getRecord", {
135
135
-
params: {
136
136
-
repo: repo as ActorIdentifier,
137
137
-
collection: collection as Nsid,
138
138
-
rkey,
139
139
-
},
140
140
-
});
141
141
-
}
3
3
+
export {
4
4
+
getSembleCollections as getCollections,
5
5
+
createSembleCollection as createCollection,
6
6
+
getSembleCards as getCards,
7
7
+
createSembleNote as createNoteCard,
8
8
+
createSembleUrlCard as createUrlCard,
9
9
+
getSembleCollectionLinks as getCollectionLinks,
10
10
+
createSembleCollectionLink as createCollectionLink,
11
11
+
} from "./lib/cosmik";
142
12
143
143
-
export async function deleteRecord(client: Client, repo: string, collection: string, rkey: string) {
144
144
-
return await client.post("com.atproto.repo.deleteRecord", {
145
145
-
input: {
146
146
-
repo: repo as ActorIdentifier,
147
147
-
collection: collection as Nsid,
148
148
-
rkey,
149
149
-
},
150
150
-
});
151
151
-
}
13
13
+
export { getBookmarks, createBookmark, getTags, createTag } from "./lib/bookmarks";
+28
src/lib/atproto.ts
···
1
1
+
import type { Client } from "@atcute/client";
2
2
+
import type { ActorIdentifier, Nsid } from "@atcute/lexicons";
3
3
+
4
4
+
export async function getRecord(client: Client, repo: string, collection: string, rkey: string) {
5
5
+
return await client.get("com.atproto.repo.getRecord", {
6
6
+
params: {
7
7
+
repo: repo as ActorIdentifier,
8
8
+
collection: collection as Nsid,
9
9
+
rkey,
10
10
+
},
11
11
+
});
12
12
+
}
13
13
+
14
14
+
export async function deleteRecord(client: Client, repo: string, collection: string, rkey: string) {
15
15
+
return await client.post("com.atproto.repo.deleteRecord", {
16
16
+
input: {
17
17
+
repo: repo as ActorIdentifier,
18
18
+
collection: collection as Nsid,
19
19
+
rkey,
20
20
+
},
21
21
+
});
22
22
+
}
23
23
+
24
24
+
export async function getProfile(client: Client, actor: string) {
25
25
+
return await client.get("app.bsky.actor.getProfile", {
26
26
+
params: { actor: actor as ActorIdentifier },
27
27
+
});
28
28
+
}
+60
src/lib/bookmarks.ts
···
1
1
+
import type { Client } from "@atcute/client";
2
2
+
import type { ActorIdentifier, Nsid } from "@atcute/lexicons";
3
3
+
4
4
+
export async function getBookmarks(client: Client, repo: string) {
5
5
+
return await client.get("com.atproto.repo.listRecords", {
6
6
+
params: {
7
7
+
repo: repo as ActorIdentifier,
8
8
+
collection: "community.lexicon.bookmarks.bookmark" as Nsid,
9
9
+
limit: 100,
10
10
+
},
11
11
+
});
12
12
+
}
13
13
+
14
14
+
export async function createBookmark(
15
15
+
client: Client,
16
16
+
repo: string,
17
17
+
subject: string,
18
18
+
title?: string,
19
19
+
description?: string,
20
20
+
tags?: string[]
21
21
+
) {
22
22
+
return await client.post("com.atproto.repo.createRecord", {
23
23
+
input: {
24
24
+
repo: repo as ActorIdentifier,
25
25
+
collection: "community.lexicon.bookmarks.bookmark" as Nsid,
26
26
+
record: {
27
27
+
$type: "community.lexicon.bookmarks.bookmark",
28
28
+
subject,
29
29
+
title,
30
30
+
description,
31
31
+
tags,
32
32
+
createdAt: new Date().toISOString(),
33
33
+
},
34
34
+
},
35
35
+
});
36
36
+
}
37
37
+
38
38
+
export async function getTags(client: Client, repo: string) {
39
39
+
return await client.get("com.atproto.repo.listRecords", {
40
40
+
params: {
41
41
+
repo: repo as ActorIdentifier,
42
42
+
collection: "com.kipclip.tag" as Nsid,
43
43
+
limit: 100,
44
44
+
},
45
45
+
});
46
46
+
}
47
47
+
48
48
+
export async function createTag(client: Client, repo: string, value: string) {
49
49
+
return await client.post("com.atproto.repo.createRecord", {
50
50
+
input: {
51
51
+
repo: repo as ActorIdentifier,
52
52
+
collection: "com.kipclip.tag" as Nsid,
53
53
+
record: {
54
54
+
$type: "com.kipclip.tag",
55
55
+
value,
56
56
+
createdAt: new Date().toISOString(),
57
57
+
},
58
58
+
},
59
59
+
});
60
60
+
}
+123
src/lib/cosmik.ts
···
1
1
+
import type { Client } from "@atcute/client";
2
2
+
import type { ActorIdentifier, Nsid } from "@atcute/lexicons";
3
3
+
4
4
+
export async function getSembleCollections(client: Client, repo: string) {
5
5
+
return await client.get("com.atproto.repo.listRecords", {
6
6
+
params: {
7
7
+
repo: repo as ActorIdentifier,
8
8
+
collection: "network.cosmik.collection" as Nsid,
9
9
+
limit: 100,
10
10
+
},
11
11
+
});
12
12
+
}
13
13
+
14
14
+
export async function createSembleCollection(client: Client, repo: string, name: string, description: string) {
15
15
+
return await client.post("com.atproto.repo.createRecord", {
16
16
+
input: {
17
17
+
repo: repo as ActorIdentifier,
18
18
+
collection: "network.cosmik.collection" as Nsid,
19
19
+
validate: false,
20
20
+
record: {
21
21
+
$type: "network.cosmik.collection",
22
22
+
name,
23
23
+
description,
24
24
+
accessType: "CLOSED",
25
25
+
createdAt: new Date().toISOString(),
26
26
+
},
27
27
+
},
28
28
+
});
29
29
+
}
30
30
+
31
31
+
export async function getSembleCards(client: Client, repo: string) {
32
32
+
return await client.get("com.atproto.repo.listRecords", {
33
33
+
params: {
34
34
+
repo: repo as ActorIdentifier,
35
35
+
collection: "network.cosmik.card" as Nsid,
36
36
+
limit: 100,
37
37
+
},
38
38
+
});
39
39
+
}
40
40
+
41
41
+
export async function createSembleNote(client: Client, repo: string, text: string, originalCard?: { uri: string; cid: string }) {
42
42
+
return await client.post("com.atproto.repo.createRecord", {
43
43
+
input: {
44
44
+
repo: repo as ActorIdentifier,
45
45
+
collection: "network.cosmik.card" as Nsid,
46
46
+
record: {
47
47
+
$type: "network.cosmik.card",
48
48
+
type: "NOTE",
49
49
+
content: {
50
50
+
$type: "network.cosmik.card#noteContent",
51
51
+
text,
52
52
+
},
53
53
+
originalCard: originalCard ? { uri: originalCard.uri, cid: originalCard.cid } : undefined,
54
54
+
createdAt: new Date().toISOString(),
55
55
+
},
56
56
+
},
57
57
+
});
58
58
+
}
59
59
+
60
60
+
export async function createSembleUrlCard(client: Client, repo: string, url: string, metadata?: {
61
61
+
title?: string;
62
62
+
description?: string;
63
63
+
imageUrl?: string;
64
64
+
siteName?: string;
65
65
+
}) {
66
66
+
return await client.post("com.atproto.repo.createRecord", {
67
67
+
input: {
68
68
+
repo: repo as ActorIdentifier,
69
69
+
collection: "network.cosmik.card" as Nsid,
70
70
+
record: {
71
71
+
$type: "network.cosmik.card",
72
72
+
type: "URL",
73
73
+
url,
74
74
+
content: {
75
75
+
$type: "network.cosmik.card#urlContent",
76
76
+
url,
77
77
+
metadata: metadata ? { $type: "network.cosmik.card#urlMetadata", ...metadata } : undefined,
78
78
+
},
79
79
+
createdAt: new Date().toISOString(),
80
80
+
},
81
81
+
},
82
82
+
});
83
83
+
}
84
84
+
85
85
+
export async function getSembleCollectionLinks(client: Client, repo: string) {
86
86
+
return await client.get("com.atproto.repo.listRecords", {
87
87
+
params: {
88
88
+
repo: repo as ActorIdentifier,
89
89
+
collection: "network.cosmik.collectionLink" as Nsid,
90
90
+
limit: 100,
91
91
+
},
92
92
+
});
93
93
+
}
94
94
+
95
95
+
export async function createSembleCollectionLink(
96
96
+
client: Client,
97
97
+
repo: string,
98
98
+
cardUri: string,
99
99
+
cardCid: string,
100
100
+
collectionUri: string,
101
101
+
collectionCid: string
102
102
+
) {
103
103
+
return await client.post("com.atproto.repo.createRecord", {
104
104
+
input: {
105
105
+
repo: repo as ActorIdentifier,
106
106
+
collection: "network.cosmik.collectionLink" as Nsid,
107
107
+
record: {
108
108
+
$type: "network.cosmik.collectionLink",
109
109
+
card: {
110
110
+
uri: cardUri,
111
111
+
cid: cardCid,
112
112
+
},
113
113
+
collection: {
114
114
+
uri: collectionUri,
115
115
+
cid: collectionCid,
116
116
+
},
117
117
+
addedAt: new Date().toISOString(),
118
118
+
addedBy: repo,
119
119
+
createdAt: new Date().toISOString(),
120
120
+
},
121
121
+
},
122
122
+
});
123
123
+
}
+7
-32
src/main.ts
···
3
3
import { DEFAULT_SETTINGS, AtProtoSettings, SettingTab } from "./settings";
4
4
import { createAuthenticatedClient, createPublicClient } from "./auth";
5
5
import { getProfile } from "./lib";
6
6
-
import { SembleCollectionsView, VIEW_TYPE_SEMBLE_COLLECTIONS } from "views/collections";
7
7
-
import { SembleCardsView, VIEW_TYPE_SEMBLE_CARDS } from "views/cards";
6
6
+
import { ATmarkView, VIEW_TYPE_ATMARK } from "views/atmark";
8
7
import type { ProfileData } from "components/profileIcon";
9
8
10
9
export default class ATmarkPlugin extends Plugin {
···
16
15
await this.loadSettings();
17
16
await this.initClient();
18
17
19
19
-
this.registerView(VIEW_TYPE_SEMBLE_COLLECTIONS, (leaf) => {
20
20
-
return new SembleCollectionsView(leaf, this);
21
21
-
});
22
22
-
23
23
-
this.registerView(VIEW_TYPE_SEMBLE_CARDS, (leaf) => {
24
24
-
return new SembleCardsView(leaf, this);
25
25
-
});
26
26
-
27
27
-
28
28
-
this.addCommand({
29
29
-
id: "view-semble-collections",
30
30
-
name: "View semble collections",
31
31
-
callback: () => { void this.activateView(VIEW_TYPE_SEMBLE_COLLECTIONS); },
18
18
+
this.registerView(VIEW_TYPE_ATMARK, (leaf) => {
19
19
+
return new ATmarkView(leaf, this);
32
20
});
33
21
34
22
this.addCommand({
35
35
-
id: "view-semble-cards",
36
36
-
name: "View semble cards",
37
37
-
callback: () => { void this.activateView(VIEW_TYPE_SEMBLE_CARDS); },
23
23
+
id: "view-atmark",
24
24
+
name: "View ATmark",
25
25
+
callback: () => { void this.activateView(VIEW_TYPE_ATMARK); },
38
26
});
39
27
40
28
this.addSettingTab(new SettingTab(this.app, this));
···
102
90
}
103
91
104
92
// Our view could not be found in the workspace, create a new leaf
105
105
-
// in the right sidebar for it
106
106
-
leaf = workspace.getRightLeaf(false);
107
107
-
// leaf = workspace.getMostRecentLeaf()
93
93
+
leaf = workspace.getMostRecentLeaf()
108
94
await leaf?.setViewState({ type: v, active: true });
109
95
110
96
// "Reveal" the leaf in case it is in a collapsed sidebar
111
97
if (leaf) {
112
98
void workspace.revealLeaf(leaf);
113
99
}
114
114
-
}
115
115
-
116
116
-
async openCollection(uri: string, name: string) {
117
117
-
const { workspace } = this.app;
118
118
-
const leaf = workspace.getLeaf("tab");
119
119
-
await leaf.setViewState({ type: VIEW_TYPE_SEMBLE_CARDS, active: true });
120
120
-
121
121
-
const view = leaf.view as SembleCardsView;
122
122
-
view.setCollection(uri, name);
123
123
-
124
124
-
void workspace.revealLeaf(leaf);
125
100
}
126
101
127
102
async loadSettings() {
+216
src/sources/bookmark.ts
···
1
1
+
import type { Client } from "@atcute/client";
2
2
+
import type ATmarkPlugin from "../main";
3
3
+
import { getBookmarks } from "../lib";
4
4
+
import type { ATmarkItem, DataSource, SourceFilter } from "./types";
5
5
+
6
6
+
class BookmarkItem implements ATmarkItem {
7
7
+
private record: any;
8
8
+
private plugin: ATmarkPlugin;
9
9
+
10
10
+
constructor(record: any, plugin: ATmarkPlugin) {
11
11
+
this.record = record;
12
12
+
this.plugin = plugin;
13
13
+
}
14
14
+
15
15
+
getUri(): string {
16
16
+
return this.record.uri;
17
17
+
}
18
18
+
19
19
+
getCid(): string {
20
20
+
return this.record.cid;
21
21
+
}
22
22
+
23
23
+
getCreatedAt(): string {
24
24
+
return this.record.value.createdAt;
25
25
+
}
26
26
+
27
27
+
getSource(): "bookmark" {
28
28
+
return "bookmark";
29
29
+
}
30
30
+
31
31
+
canAddNotes(): boolean {
32
32
+
return false;
33
33
+
}
34
34
+
35
35
+
render(container: HTMLElement): void {
36
36
+
const el = container.createEl("div", { cls: "semble-card-content" });
37
37
+
const bookmark = this.record.value;
38
38
+
const enriched = bookmark.enriched;
39
39
+
40
40
+
// Display tags
41
41
+
if (bookmark.tags && bookmark.tags.length > 0) {
42
42
+
const tagsContainer = el.createEl("div", { cls: "semble-card-tags" });
43
43
+
for (const tag of bookmark.tags) {
44
44
+
tagsContainer.createEl("span", { text: tag, cls: "semble-tag" });
45
45
+
}
46
46
+
}
47
47
+
48
48
+
const title = enriched?.title || bookmark.title;
49
49
+
if (title) {
50
50
+
el.createEl("div", { text: title, cls: "semble-card-title" });
51
51
+
}
52
52
+
53
53
+
const imageUrl = enriched?.image || enriched?.thumb;
54
54
+
if (imageUrl) {
55
55
+
const img = el.createEl("img", { cls: "semble-card-image" });
56
56
+
img.src = imageUrl;
57
57
+
img.alt = title || "Image";
58
58
+
}
59
59
+
60
60
+
const description = enriched?.description || bookmark.description;
61
61
+
if (description) {
62
62
+
const desc = description.length > 200
63
63
+
? description.slice(0, 200) + "…"
64
64
+
: description;
65
65
+
el.createEl("p", { text: desc, cls: "semble-card-desc" });
66
66
+
}
67
67
+
68
68
+
if (enriched?.siteName) {
69
69
+
el.createEl("span", { text: enriched.siteName, cls: "semble-card-site" });
70
70
+
}
71
71
+
72
72
+
const link = el.createEl("a", {
73
73
+
text: bookmark.subject,
74
74
+
href: bookmark.subject,
75
75
+
cls: "semble-card-url",
76
76
+
});
77
77
+
link.setAttr("target", "_blank");
78
78
+
}
79
79
+
80
80
+
renderDetail(container: HTMLElement): void {
81
81
+
const body = container.createEl("div", { cls: "semble-detail-body" });
82
82
+
const bookmark = this.record.value;
83
83
+
const enriched = bookmark.enriched;
84
84
+
85
85
+
const title = enriched?.title || bookmark.title;
86
86
+
if (title) {
87
87
+
body.createEl("h2", { text: title, cls: "semble-detail-title" });
88
88
+
}
89
89
+
90
90
+
const imageUrl = enriched?.image || enriched?.thumb;
91
91
+
if (imageUrl) {
92
92
+
const img = body.createEl("img", { cls: "semble-detail-image" });
93
93
+
img.src = imageUrl;
94
94
+
img.alt = title || "Image";
95
95
+
}
96
96
+
97
97
+
const description = enriched?.description || bookmark.description;
98
98
+
if (description) {
99
99
+
body.createEl("p", { text: description, cls: "semble-detail-description" });
100
100
+
}
101
101
+
102
102
+
if (enriched?.siteName) {
103
103
+
const metaGrid = body.createEl("div", { cls: "semble-detail-meta" });
104
104
+
const item = metaGrid.createEl("div", { cls: "semble-detail-meta-item" });
105
105
+
item.createEl("span", { text: "Site", cls: "semble-detail-meta-label" });
106
106
+
item.createEl("span", { text: enriched.siteName, cls: "semble-detail-meta-value" });
107
107
+
}
108
108
+
109
109
+
const linkWrapper = body.createEl("div", { cls: "semble-detail-link-wrapper" });
110
110
+
const link = linkWrapper.createEl("a", {
111
111
+
text: bookmark.subject,
112
112
+
href: bookmark.subject,
113
113
+
cls: "semble-detail-link",
114
114
+
});
115
115
+
link.setAttr("target", "_blank");
116
116
+
117
117
+
// Tags section
118
118
+
if (bookmark.tags && bookmark.tags.length > 0) {
119
119
+
const tagsSection = container.createEl("div", { cls: "semble-detail-tags-section" });
120
120
+
tagsSection.createEl("h3", { text: "Tags", cls: "semble-detail-section-title" });
121
121
+
const tagsContainer = tagsSection.createEl("div", { cls: "semble-card-tags" });
122
122
+
for (const tag of bookmark.tags) {
123
123
+
tagsContainer.createEl("span", { text: tag, cls: "semble-tag" });
124
124
+
}
125
125
+
}
126
126
+
}
127
127
+
128
128
+
getTags() {
129
129
+
return this.record.value.tags || [];
130
130
+
}
131
131
+
132
132
+
getRecord() {
133
133
+
return this.record;
134
134
+
}
135
135
+
}
136
136
+
137
137
+
export class BookmarkSource implements DataSource {
138
138
+
readonly name = "bookmark" as const;
139
139
+
private client: Client;
140
140
+
private repo: string;
141
141
+
142
142
+
constructor(client: Client, repo: string) {
143
143
+
this.client = client;
144
144
+
this.repo = repo;
145
145
+
}
146
146
+
147
147
+
async fetchItems(filters: SourceFilter[], plugin: ATmarkPlugin): Promise<ATmarkItem[]> {
148
148
+
const bookmarksResp = await getBookmarks(this.client, this.repo);
149
149
+
if (!bookmarksResp.ok) return [];
150
150
+
151
151
+
let bookmarks = bookmarksResp.data.records;
152
152
+
153
153
+
// Apply tag filter if specified
154
154
+
const tagFilter = filters.find(f => f.type === "bookmarkTag");
155
155
+
if (tagFilter && tagFilter.value) {
156
156
+
bookmarks = bookmarks.filter((record: any) =>
157
157
+
record.value.tags?.includes(tagFilter.value)
158
158
+
);
159
159
+
}
160
160
+
161
161
+
return bookmarks.map((record: any) => new BookmarkItem(record, plugin));
162
162
+
}
163
163
+
164
164
+
async getAvailableFilters(): Promise<SourceFilter[]> {
165
165
+
const bookmarksResp = await getBookmarks(this.client, this.repo);
166
166
+
if (!bookmarksResp.ok) return [];
167
167
+
168
168
+
// Extract unique tags
169
169
+
const tagSet = new Set<string>();
170
170
+
const records = bookmarksResp.data.records as any[];
171
171
+
for (const record of records) {
172
172
+
if (record.value?.tags) {
173
173
+
for (const tag of record.value.tags) {
174
174
+
tagSet.add(tag);
175
175
+
}
176
176
+
}
177
177
+
}
178
178
+
179
179
+
return Array.from(tagSet).map(tag => ({
180
180
+
type: "bookmarkTag",
181
181
+
value: tag,
182
182
+
label: tag,
183
183
+
}));
184
184
+
}
185
185
+
186
186
+
renderFilterUI(container: HTMLElement, activeFilters: Map<string, any>, onChange: () => void): void {
187
187
+
const section = container.createEl("div", { cls: "atmark-filter-section" });
188
188
+
section.createEl("h3", { text: "Tags", cls: "atmark-filter-title" });
189
189
+
190
190
+
const chips = section.createEl("div", { cls: "atmark-filter-chips" });
191
191
+
192
192
+
// All chip
193
193
+
const allChip = chips.createEl("button", {
194
194
+
text: "All",
195
195
+
cls: `atmark-chip ${!activeFilters.has("bookmarkTag") ? "atmark-chip-active" : ""}`,
196
196
+
});
197
197
+
allChip.addEventListener("click", () => {
198
198
+
activeFilters.delete("bookmarkTag");
199
199
+
onChange();
200
200
+
});
201
201
+
202
202
+
// Get tags and render chips
203
203
+
void this.getAvailableFilters().then(tags => {
204
204
+
for (const tag of tags) {
205
205
+
const chip = chips.createEl("button", {
206
206
+
text: (tag as any).label,
207
207
+
cls: `atmark-chip ${activeFilters.get("bookmarkTag")?.value === tag.value ? "atmark-chip-active" : ""}`,
208
208
+
});
209
209
+
chip.addEventListener("click", () => {
210
210
+
activeFilters.set("bookmarkTag", tag);
211
211
+
onChange();
212
212
+
});
213
213
+
}
214
214
+
});
215
215
+
}
216
216
+
}
+257
src/sources/semble.ts
···
1
1
+
import type { Client } from "@atcute/client";
2
2
+
import { setIcon } from "obsidian";
3
3
+
import type ATmarkPlugin from "../main";
4
4
+
import { getCards, getCollections, getCollectionLinks } from "../lib";
5
5
+
import type { NoteContent, UrlContent } from "../lexicons/types/network/cosmik/card";
6
6
+
import type { ATmarkItem, DataSource, SourceFilter } from "./types";
7
7
+
8
8
+
class SembleItem implements ATmarkItem {
9
9
+
private record: any;
10
10
+
private attachedNotes: Array<{ uri: string; text: string }>;
11
11
+
private plugin: ATmarkPlugin;
12
12
+
13
13
+
constructor(record: any, attachedNotes: Array<{ uri: string; text: string }>, plugin: ATmarkPlugin) {
14
14
+
this.record = record;
15
15
+
this.attachedNotes = attachedNotes;
16
16
+
this.plugin = plugin;
17
17
+
}
18
18
+
19
19
+
getUri(): string {
20
20
+
return this.record.uri;
21
21
+
}
22
22
+
23
23
+
getCid(): string {
24
24
+
return this.record.cid;
25
25
+
}
26
26
+
27
27
+
getCreatedAt(): string {
28
28
+
return this.record.value.createdAt;
29
29
+
}
30
30
+
31
31
+
getSource(): "semble" {
32
32
+
return "semble";
33
33
+
}
34
34
+
35
35
+
canAddNotes(): boolean {
36
36
+
return true;
37
37
+
}
38
38
+
39
39
+
render(container: HTMLElement): void {
40
40
+
const el = container.createEl("div", { cls: "semble-card-content" });
41
41
+
42
42
+
// Display attached notes
43
43
+
if (this.attachedNotes.length > 0) {
44
44
+
for (const note of this.attachedNotes) {
45
45
+
el.createEl("p", { text: note.text, cls: "semble-card-note" });
46
46
+
}
47
47
+
}
48
48
+
49
49
+
const card = this.record.value;
50
50
+
51
51
+
if (card.type === "NOTE") {
52
52
+
const content = card.content as NoteContent;
53
53
+
el.createEl("p", { text: content.text, cls: "semble-card-text" });
54
54
+
} else if (card.type === "URL") {
55
55
+
const content = card.content as UrlContent;
56
56
+
const meta = content.metadata;
57
57
+
58
58
+
if (meta?.title) {
59
59
+
el.createEl("div", { text: meta.title, cls: "semble-card-title" });
60
60
+
}
61
61
+
62
62
+
if (meta?.imageUrl) {
63
63
+
const img = el.createEl("img", { cls: "semble-card-image" });
64
64
+
img.src = meta.imageUrl;
65
65
+
img.alt = meta.title || "Image";
66
66
+
}
67
67
+
68
68
+
if (meta?.description) {
69
69
+
const desc = meta.description.length > 200
70
70
+
? meta.description.slice(0, 200) + "…"
71
71
+
: meta.description;
72
72
+
el.createEl("p", { text: desc, cls: "semble-card-desc" });
73
73
+
}
74
74
+
75
75
+
if (meta?.siteName) {
76
76
+
el.createEl("span", { text: meta.siteName, cls: "semble-card-site" });
77
77
+
}
78
78
+
79
79
+
const link = el.createEl("a", {
80
80
+
text: content.url,
81
81
+
href: content.url,
82
82
+
cls: "semble-card-url",
83
83
+
});
84
84
+
link.setAttr("target", "_blank");
85
85
+
}
86
86
+
}
87
87
+
88
88
+
renderDetail(container: HTMLElement): void {
89
89
+
const body = container.createEl("div", { cls: "semble-detail-body" });
90
90
+
const card = this.record.value;
91
91
+
92
92
+
if (card.type === "NOTE") {
93
93
+
const content = card.content as NoteContent;
94
94
+
body.createEl("p", { text: content.text, cls: "semble-detail-text" });
95
95
+
} else if (card.type === "URL") {
96
96
+
const content = card.content as UrlContent;
97
97
+
const meta = content.metadata;
98
98
+
99
99
+
if (meta?.title) {
100
100
+
body.createEl("h2", { text: meta.title, cls: "semble-detail-title" });
101
101
+
}
102
102
+
103
103
+
if (meta?.imageUrl) {
104
104
+
const img = body.createEl("img", { cls: "semble-detail-image" });
105
105
+
img.src = meta.imageUrl;
106
106
+
img.alt = meta.title || "Image";
107
107
+
}
108
108
+
109
109
+
if (meta?.description) {
110
110
+
body.createEl("p", { text: meta.description, cls: "semble-detail-description" });
111
111
+
}
112
112
+
113
113
+
if (meta?.siteName) {
114
114
+
const metaGrid = body.createEl("div", { cls: "semble-detail-meta" });
115
115
+
const item = metaGrid.createEl("div", { cls: "semble-detail-meta-item" });
116
116
+
item.createEl("span", { text: "Site", cls: "semble-detail-meta-label" });
117
117
+
item.createEl("span", { text: meta.siteName, cls: "semble-detail-meta-value" });
118
118
+
}
119
119
+
120
120
+
const linkWrapper = body.createEl("div", { cls: "semble-detail-link-wrapper" });
121
121
+
const link = linkWrapper.createEl("a", {
122
122
+
text: content.url,
123
123
+
href: content.url,
124
124
+
cls: "semble-detail-link",
125
125
+
});
126
126
+
link.setAttr("target", "_blank");
127
127
+
}
128
128
+
129
129
+
// Attached notes section
130
130
+
if (this.attachedNotes.length > 0) {
131
131
+
const notesSection = container.createEl("div", { cls: "semble-detail-notes-section" });
132
132
+
notesSection.createEl("h3", { text: "Notes", cls: "semble-detail-section-title" });
133
133
+
134
134
+
for (const note of this.attachedNotes) {
135
135
+
const noteEl = notesSection.createEl("div", { cls: "semble-detail-note" });
136
136
+
137
137
+
const noteContent = noteEl.createEl("div", { cls: "semble-detail-note-content" });
138
138
+
const noteIcon = noteContent.createEl("span", { cls: "semble-detail-note-icon" });
139
139
+
setIcon(noteIcon, "message-square");
140
140
+
noteContent.createEl("p", { text: note.text, cls: "semble-detail-note-text" });
141
141
+
142
142
+
// Note: delete functionality would need to be handled by the modal
143
143
+
}
144
144
+
}
145
145
+
}
146
146
+
147
147
+
getAttachedNotes() {
148
148
+
return this.attachedNotes;
149
149
+
}
150
150
+
151
151
+
getRecord() {
152
152
+
return this.record;
153
153
+
}
154
154
+
}
155
155
+
156
156
+
export class SembleSource implements DataSource {
157
157
+
readonly name = "semble" as const;
158
158
+
private client: Client;
159
159
+
private repo: string;
160
160
+
161
161
+
constructor(client: Client, repo: string) {
162
162
+
this.client = client;
163
163
+
this.repo = repo;
164
164
+
}
165
165
+
166
166
+
async fetchItems(filters: SourceFilter[], plugin: ATmarkPlugin): Promise<ATmarkItem[]> {
167
167
+
const cardsResp = await getCards(this.client, this.repo);
168
168
+
if (!cardsResp.ok) return [];
169
169
+
170
170
+
const allSembleCards = cardsResp.data.records;
171
171
+
172
172
+
// Build notes map
173
173
+
const notesMap = new Map<string, Array<{ uri: string; text: string }>>();
174
174
+
for (const record of allSembleCards as any[]) {
175
175
+
if (record.value.type === "NOTE") {
176
176
+
const parentUri = record.value.originalCard?.uri || record.value.parentCard?.uri;
177
177
+
if (parentUri) {
178
178
+
const noteContent = record.value.content as NoteContent;
179
179
+
const existing = notesMap.get(parentUri) || [];
180
180
+
existing.push({ uri: record.uri, text: noteContent.text });
181
181
+
notesMap.set(parentUri, existing);
182
182
+
}
183
183
+
}
184
184
+
}
185
185
+
186
186
+
// Filter out NOTE cards that are attached to other cards
187
187
+
let sembleCards = allSembleCards.filter((record: any) => {
188
188
+
if (record.value.type === "NOTE") {
189
189
+
const hasParent = record.value.originalCard?.uri || record.value.parentCard?.uri;
190
190
+
return !hasParent;
191
191
+
}
192
192
+
return true;
193
193
+
});
194
194
+
195
195
+
// Apply collection filter if specified
196
196
+
const collectionFilter = filters.find(f => f.type === "sembleCollection");
197
197
+
if (collectionFilter && collectionFilter.value) {
198
198
+
const linksResp = await getCollectionLinks(this.client, this.repo);
199
199
+
if (linksResp.ok) {
200
200
+
const links = linksResp.data.records.filter((link: any) =>
201
201
+
link.value.collection.uri === collectionFilter.value
202
202
+
);
203
203
+
const cardUris = new Set(links.map((link: any) => link.value.card.uri));
204
204
+
sembleCards = sembleCards.filter((card: any) => cardUris.has(card.uri));
205
205
+
}
206
206
+
}
207
207
+
208
208
+
// Create SembleItem objects
209
209
+
return sembleCards.map((record: any) =>
210
210
+
new SembleItem(record, notesMap.get(record.uri) || [], plugin)
211
211
+
);
212
212
+
}
213
213
+
214
214
+
async getAvailableFilters(): Promise<SourceFilter[]> {
215
215
+
const collectionsResp = await getCollections(this.client, this.repo);
216
216
+
if (!collectionsResp.ok) return [];
217
217
+
218
218
+
const collections = collectionsResp.data.records;
219
219
+
return collections.map((c: any) => ({
220
220
+
type: "sembleCollection",
221
221
+
value: c.uri,
222
222
+
label: c.value.name,
223
223
+
}));
224
224
+
}
225
225
+
226
226
+
renderFilterUI(container: HTMLElement, activeFilters: Map<string, any>, onChange: () => void): void {
227
227
+
const section = container.createEl("div", { cls: "atmark-filter-section" });
228
228
+
section.createEl("h3", { text: "Semble Collections", cls: "atmark-filter-title" });
229
229
+
230
230
+
const chips = section.createEl("div", { cls: "atmark-filter-chips" });
231
231
+
232
232
+
// All chip
233
233
+
const allChip = chips.createEl("button", {
234
234
+
text: "All",
235
235
+
cls: `atmark-chip ${!activeFilters.has("sembleCollection") ? "atmark-chip-active" : ""}`,
236
236
+
});
237
237
+
allChip.addEventListener("click", () => {
238
238
+
activeFilters.delete("sembleCollection");
239
239
+
onChange();
240
240
+
});
241
241
+
242
242
+
// Get collections synchronously - note: this is a limitation
243
243
+
// In a real app, we'd want to cache these or handle async properly
244
244
+
void this.getAvailableFilters().then(collections => {
245
245
+
for (const collection of collections) {
246
246
+
const chip = chips.createEl("button", {
247
247
+
text: (collection as any).label,
248
248
+
cls: `atmark-chip ${activeFilters.get("sembleCollection") === collection.value ? "atmark-chip-active" : ""}`,
249
249
+
});
250
250
+
chip.addEventListener("click", () => {
251
251
+
activeFilters.set("sembleCollection", collection);
252
252
+
onChange();
253
253
+
});
254
254
+
}
255
255
+
});
256
256
+
}
257
257
+
}
+23
src/sources/types.ts
···
1
1
+
import type ATmarkPlugin from "../main";
2
2
+
3
3
+
export interface ATmarkItem {
4
4
+
render(container: HTMLElement): void;
5
5
+
renderDetail(container: HTMLElement): void;
6
6
+
canAddNotes(): boolean;
7
7
+
getUri(): string;
8
8
+
getCid(): string;
9
9
+
getCreatedAt(): string;
10
10
+
getSource(): "semble" | "bookmark";
11
11
+
}
12
12
+
13
13
+
export interface SourceFilter {
14
14
+
type: string;
15
15
+
value: any;
16
16
+
}
17
17
+
18
18
+
export interface DataSource {
19
19
+
readonly name: "semble" | "bookmark";
20
20
+
fetchItems(filters: SourceFilter[], plugin: ATmarkPlugin): Promise<ATmarkItem[]>;
21
21
+
getAvailableFilters(): Promise<SourceFilter[]>;
22
22
+
renderFilterUI(container: HTMLElement, activeFilters: Map<string, any>, onChange: () => void): void;
23
23
+
}
+172
src/views/atmark.ts
···
1
1
+
import { ItemView, WorkspaceLeaf, setIcon } from "obsidian";
2
2
+
import type ATmarkPlugin from "../main";
3
3
+
import { renderProfileIcon } from "../components/profileIcon";
4
4
+
import { CardDetailModal } from "../components/cardDetailModal";
5
5
+
import type { ATmarkItem } from "../sources/types";
6
6
+
import { SembleSource } from "../sources/semble";
7
7
+
import { BookmarkSource } from "../sources/bookmark";
8
8
+
9
9
+
export const VIEW_TYPE_ATMARK = "atmark-view";
10
10
+
11
11
+
type SourceType = "semble" | "bookmark";
12
12
+
13
13
+
export class ATmarkView extends ItemView {
14
14
+
plugin: ATmarkPlugin;
15
15
+
activeSource: SourceType = "semble";
16
16
+
sources: Map<SourceType, { source: any; filters: Map<string, any> }> = new Map();
17
17
+
18
18
+
constructor(leaf: WorkspaceLeaf, plugin: ATmarkPlugin) {
19
19
+
super(leaf);
20
20
+
this.plugin = plugin;
21
21
+
22
22
+
// Initialize sources
23
23
+
if (this.plugin.client) {
24
24
+
const repo = this.plugin.settings.identifier;
25
25
+
this.sources.set("semble", {
26
26
+
source: new SembleSource(this.plugin.client, repo),
27
27
+
filters: new Map()
28
28
+
});
29
29
+
this.sources.set("bookmark", {
30
30
+
source: new BookmarkSource(this.plugin.client, repo),
31
31
+
filters: new Map()
32
32
+
});
33
33
+
}
34
34
+
}
35
35
+
36
36
+
getViewType() {
37
37
+
return VIEW_TYPE_ATMARK;
38
38
+
}
39
39
+
40
40
+
getDisplayText() {
41
41
+
return "ATmark";
42
42
+
}
43
43
+
44
44
+
getIcon() {
45
45
+
return "bookmark";
46
46
+
}
47
47
+
48
48
+
async onOpen() {
49
49
+
await this.render();
50
50
+
}
51
51
+
52
52
+
async fetchItems(): Promise<ATmarkItem[]> {
53
53
+
if (!this.plugin.client) return [];
54
54
+
55
55
+
const sourceData = this.sources.get(this.activeSource);
56
56
+
if (!sourceData) return [];
57
57
+
58
58
+
const filters = Array.from(sourceData.filters.values());
59
59
+
return await sourceData.source.fetchItems(filters, this.plugin);
60
60
+
}
61
61
+
62
62
+
async render() {
63
63
+
const container = this.contentEl;
64
64
+
container.empty();
65
65
+
container.addClass("atmark-view");
66
66
+
67
67
+
if (!this.plugin.client) {
68
68
+
container.createEl("p", { text: "Not connected." });
69
69
+
return;
70
70
+
}
71
71
+
72
72
+
const loading = container.createEl("p", { text: "Loading..." });
73
73
+
74
74
+
try {
75
75
+
const items = await this.fetchItems();
76
76
+
loading.remove();
77
77
+
78
78
+
this.renderHeader(container);
79
79
+
80
80
+
if (items.length === 0) {
81
81
+
container.createEl("p", { text: "No items found." });
82
82
+
return;
83
83
+
}
84
84
+
85
85
+
const grid = container.createEl("div", { cls: "atmark-grid" });
86
86
+
for (const item of items) {
87
87
+
try {
88
88
+
this.renderItem(grid, item);
89
89
+
} catch (err) {
90
90
+
const message = err instanceof Error ? err.message : String(err);
91
91
+
console.error(`Failed to render item ${item.getUri()}: ${message}`);
92
92
+
}
93
93
+
}
94
94
+
} catch (err) {
95
95
+
loading.remove();
96
96
+
const message = err instanceof Error ? err.message : String(err);
97
97
+
container.createEl("p", { text: `Failed to load: ${message}`, cls: "atmark-error" });
98
98
+
}
99
99
+
}
100
100
+
101
101
+
private renderHeader(container: HTMLElement) {
102
102
+
const header = container.createEl("div", { cls: "atmark-header" });
103
103
+
const nav = header.createEl("div", { cls: "atmark-nav" });
104
104
+
105
105
+
// eslint-disable-next-line obsidianmd/ui/sentence-case
106
106
+
nav.createEl("h1", { text: "ATmark", cls: "atmark-title" });
107
107
+
108
108
+
// Source selector in the center
109
109
+
const sourceSelector = nav.createEl("div", { cls: "atmark-source-selector" });
110
110
+
const sources: SourceType[] = ["semble", "bookmark"];
111
111
+
112
112
+
for (const source of sources) {
113
113
+
const label = sourceSelector.createEl("label", { cls: "atmark-source-option" });
114
114
+
115
115
+
const radio = label.createEl("input", {
116
116
+
type: "radio",
117
117
+
cls: "atmark-source-radio",
118
118
+
});
119
119
+
radio.name = "atmark-source";
120
120
+
radio.checked = this.activeSource === source;
121
121
+
radio.addEventListener("change", () => {
122
122
+
this.activeSource = source;
123
123
+
void this.render();
124
124
+
});
125
125
+
126
126
+
label.createEl("span", {
127
127
+
text: source.charAt(0).toUpperCase() + source.slice(1),
128
128
+
cls: "atmark-source-text",
129
129
+
});
130
130
+
}
131
131
+
132
132
+
renderProfileIcon(nav, this.plugin.profile);
133
133
+
134
134
+
// Let the active source render its filters
135
135
+
const filtersContainer = container.createEl("div", { cls: "atmark-filters" });
136
136
+
const sourceData = this.sources.get(this.activeSource);
137
137
+
if (sourceData) {
138
138
+
sourceData.source.renderFilterUI(
139
139
+
filtersContainer,
140
140
+
sourceData.filters,
141
141
+
() => void this.render()
142
142
+
);
143
143
+
}
144
144
+
}
145
145
+
146
146
+
private renderItem(container: HTMLElement, item: ATmarkItem) {
147
147
+
const el = container.createEl("div", { cls: "atmark-item" });
148
148
+
149
149
+
el.addEventListener("click", () => {
150
150
+
new CardDetailModal(this.plugin, item, () => {
151
151
+
void this.render();
152
152
+
}).open();
153
153
+
});
154
154
+
155
155
+
const header = el.createEl("div", { cls: "atmark-item-header" });
156
156
+
const source = item.getSource();
157
157
+
header.createEl("span", {
158
158
+
text: source,
159
159
+
cls: `atmark-badge atmark-badge-${source}`,
160
160
+
});
161
161
+
162
162
+
item.render(el);
163
163
+
164
164
+
const footer = el.createEl("div", { cls: "atmark-item-footer" });
165
165
+
footer.createEl("span", {
166
166
+
text: new Date(item.getCreatedAt()).toLocaleDateString(),
167
167
+
cls: "atmark-date",
168
168
+
});
169
169
+
}
170
170
+
171
171
+
async onClose() { }
172
172
+
}
-326
src/views/cards.ts
···
1
1
-
import { ItemView, WorkspaceLeaf, setIcon } from "obsidian";
2
2
-
import type ATmarkPlugin from "../main";
3
3
-
import { getCollections, getCollectionLinks, getCards } from "../lib";
4
4
-
import type { Main as Card, NoteContent, UrlContent } from "../lexicons/types/network/cosmik/card";
5
5
-
import type { Main as CollectionLink } from "../lexicons/types/network/cosmik/collectionLink";
6
6
-
import type { Main as Collection } from "../lexicons/types/network/cosmik/collection";
7
7
-
import { VIEW_TYPE_SEMBLE_COLLECTIONS } from "./collections";
8
8
-
import { renderProfileIcon } from "../components/profileIcon";
9
9
-
import { EditCardModal } from "../components/editCardModal";
10
10
-
import { CardDetailModal } from "../components/cardDetailModal";
11
11
-
12
12
-
export const VIEW_TYPE_SEMBLE_CARDS = "semble-cards-view";
13
13
-
14
14
-
interface CardRecord {
15
15
-
uri: string;
16
16
-
cid: string;
17
17
-
value: Card;
18
18
-
}
19
19
-
20
20
-
export interface AttachedNote {
21
21
-
uri: string;
22
22
-
text: string;
23
23
-
}
24
24
-
25
25
-
export interface CardWithNotes extends CardRecord {
26
26
-
attachedNotes: AttachedNote[];
27
27
-
}
28
28
-
29
29
-
interface CollectionLinkRecord {
30
30
-
uri: string;
31
31
-
value: CollectionLink;
32
32
-
}
33
33
-
34
34
-
interface CollectionRecord {
35
35
-
uri: string;
36
36
-
value: Collection;
37
37
-
}
38
38
-
39
39
-
export class SembleCardsView extends ItemView {
40
40
-
plugin: ATmarkPlugin;
41
41
-
collectionUri: string | null = null;
42
42
-
collectionName: string = "All Cards";
43
43
-
44
44
-
constructor(leaf: WorkspaceLeaf, plugin: ATmarkPlugin) {
45
45
-
super(leaf);
46
46
-
this.plugin = plugin;
47
47
-
}
48
48
-
49
49
-
getViewType() {
50
50
-
return VIEW_TYPE_SEMBLE_CARDS;
51
51
-
}
52
52
-
53
53
-
getDisplayText() {
54
54
-
return this.collectionName;
55
55
-
}
56
56
-
57
57
-
getIcon() {
58
58
-
return "layers";
59
59
-
}
60
60
-
61
61
-
setCollection(uri: string | null, name: string) {
62
62
-
this.collectionUri = uri;
63
63
-
this.collectionName = name;
64
64
-
void this.render();
65
65
-
}
66
66
-
67
67
-
async onOpen() {
68
68
-
await this.render();
69
69
-
}
70
70
-
71
71
-
/**
72
72
-
* Process cards to attach notes to their parent cards and filter out attached notes.
73
73
-
* Notes with originalCard or parentCard references are attached to those cards
74
74
-
* instead of being shown as separate cards.
75
75
-
*/
76
76
-
private processCardsWithNotes(cards: CardRecord[]): CardWithNotes[] {
77
77
-
// Build a map of card URI -> attached notes with their URIs
78
78
-
const notesMap = new Map<string, AttachedNote[]>();
79
79
-
80
80
-
// Find all NOTE cards that reference other cards
81
81
-
for (const record of cards) {
82
82
-
if (record.value.type === "NOTE") {
83
83
-
const parentUri = record.value.originalCard?.uri || record.value.parentCard?.uri;
84
84
-
if (parentUri) {
85
85
-
const noteContent = record.value.content as NoteContent;
86
86
-
const existing = notesMap.get(parentUri) || [];
87
87
-
existing.push({ uri: record.uri, text: noteContent.text });
88
88
-
notesMap.set(parentUri, existing);
89
89
-
}
90
90
-
}
91
91
-
}
92
92
-
93
93
-
// Filter out NOTE cards that are attached to other cards
94
94
-
const filteredCards = cards.filter((record) => {
95
95
-
if (record.value.type === "NOTE") {
96
96
-
const hasParent = record.value.originalCard?.uri || record.value.parentCard?.uri;
97
97
-
return !hasParent; // Only keep standalone notes
98
98
-
}
99
99
-
return true;
100
100
-
});
101
101
-
102
102
-
// Add attached notes to each card
103
103
-
return filteredCards.map((record) => ({
104
104
-
...record,
105
105
-
attachedNotes: notesMap.get(record.uri) || [],
106
106
-
}));
107
107
-
}
108
108
-
109
109
-
async getAllCards(): Promise<CardWithNotes[]> {
110
110
-
if (!this.plugin.client) return [];
111
111
-
112
112
-
const repo = this.plugin.settings.identifier;
113
113
-
const cardsResp = await getCards(this.plugin.client, repo);
114
114
-
if (!cardsResp.ok) return [];
115
115
-
const cards = cardsResp.data.records as unknown as CardRecord[];
116
116
-
return this.processCardsWithNotes(cards);
117
117
-
}
118
118
-
119
119
-
async getCardsInCollection(collectionUri: string): Promise<CardWithNotes[]> {
120
120
-
if (!this.plugin.client) return [];
121
121
-
122
122
-
const repo = this.plugin.settings.identifier;
123
123
-
const [linksResp, cardsResp] = await Promise.all([
124
124
-
getCollectionLinks(this.plugin.client, repo),
125
125
-
getCards(this.plugin.client, repo),
126
126
-
]);
127
127
-
128
128
-
if (!linksResp.ok || !cardsResp.ok) return [];
129
129
-
const allLinks = linksResp.data.records as unknown as CollectionLinkRecord[];
130
130
-
const allCards = cardsResp.data.records as unknown as CardRecord[];
131
131
-
132
132
-
// Filter links by collection
133
133
-
const links = allLinks.filter((link) => link.value.collection.uri === collectionUri);
134
134
-
135
135
-
// Get cards in collection
136
136
-
const cardUris = new Set(links.map((link) => String(link.value.card.uri)));
137
137
-
const cards = allCards.filter((card) => cardUris.has(String(card.uri)));
138
138
-
139
139
-
return this.processCardsWithNotes(cards);
140
140
-
}
141
141
-
142
142
-
async render() {
143
143
-
const container = this.contentEl;
144
144
-
container.empty();
145
145
-
container.addClass("semble-cards-view");
146
146
-
147
147
-
if (!this.plugin.client) {
148
148
-
container.createEl("p", { text: "Not connected." });
149
149
-
return;
150
150
-
}
151
151
-
152
152
-
const loading = container.createEl("p", { text: "Loading..." });
153
153
-
154
154
-
try {
155
155
-
156
156
-
let cards: CardWithNotes[] = [];
157
157
-
try {
158
158
-
if (this.collectionUri) {
159
159
-
cards = await this.getCardsInCollection(this.collectionUri);
160
160
-
} else {
161
161
-
cards = await this.getAllCards();
162
162
-
}
163
163
-
} catch (err) {
164
164
-
loading.remove();
165
165
-
const message = err instanceof Error ? err.message : String(err);
166
166
-
container.createEl("p", { text: `Failed to load cards: ${message}`, cls: "semble-error" });
167
167
-
return;
168
168
-
}
169
169
-
170
170
-
const collectionsResp = await getCollections(this.plugin.client, this.plugin.settings.identifier);
171
171
-
if (!collectionsResp.ok) {
172
172
-
loading.remove();
173
173
-
const errorMsg = collectionsResp.data?.error ? String(collectionsResp.data.error) : "Unknown error";
174
174
-
container.createEl("p", { text: `Failed to load collections: ${errorMsg}`, cls: "semble-error" });
175
175
-
return;
176
176
-
}
177
177
-
const collections = collectionsResp.data?.records as unknown as CollectionRecord[];
178
178
-
179
179
-
loading.remove();
180
180
-
181
181
-
// Render header with back button and filters
182
182
-
this.renderHeader(container, collections);
183
183
-
184
184
-
if (cards.length === 0) {
185
185
-
container.createEl("p", { text: "No cards found." });
186
186
-
return;
187
187
-
}
188
188
-
189
189
-
const grid = container.createEl("div", { cls: "semble-card-grid" });
190
190
-
for (const record of cards) {
191
191
-
try {
192
192
-
this.renderCard(grid, record);
193
193
-
} catch (err) {
194
194
-
const message = err instanceof Error ? err.message : String(err);
195
195
-
console.error(`Failed to render card ${record.uri}: ${message}`);
196
196
-
}
197
197
-
}
198
198
-
} catch (err) {
199
199
-
loading.remove();
200
200
-
const message = err instanceof Error ? err.message : String(err);
201
201
-
container.createEl("p", { text: `Failed to load: ${message}`, cls: "semble-error" });
202
202
-
}
203
203
-
}
204
204
-
205
205
-
private renderHeader(container: HTMLElement, collections: CollectionRecord[]) {
206
206
-
const header = container.createEl("div", { cls: "semble-page-header" });
207
207
-
208
208
-
const nav = header.createEl("div", { cls: "semble-nav-row" });
209
209
-
210
210
-
// Back button
211
211
-
const backBtn = nav.createEl("button", { cls: "semble-back-btn" });
212
212
-
setIcon(backBtn, "arrow-left");
213
213
-
backBtn.addEventListener("click", () => {
214
214
-
void this.plugin.activateView(VIEW_TYPE_SEMBLE_COLLECTIONS);
215
215
-
});
216
216
-
217
217
-
nav.createEl("span", { text: "Semble", cls: "semble-brand" });
218
218
-
219
219
-
renderProfileIcon(nav, this.plugin.profile);
220
220
-
221
221
-
header.createEl("h2", { text: this.collectionName, cls: "semble-page-title" });
222
222
-
223
223
-
// Filter chips
224
224
-
const filters = container.createEl("div", { cls: "semble-filter-chips" });
225
225
-
226
226
-
// All chip
227
227
-
const allChip = filters.createEl("button", {
228
228
-
text: "All",
229
229
-
cls: `semble-chip ${!this.collectionUri ? "semble-chip-active" : ""}`,
230
230
-
});
231
231
-
allChip.addEventListener("click", () => {
232
232
-
this.setCollection(null, "All Cards");
233
233
-
});
234
234
-
235
235
-
// Collection chips
236
236
-
for (const record of collections) {
237
237
-
const chip = filters.createEl("button", {
238
238
-
text: record.value.name,
239
239
-
cls: `semble-chip ${this.collectionUri === record.uri ? "semble-chip-active" : ""}`,
240
240
-
});
241
241
-
chip.addEventListener("click", () => {
242
242
-
this.setCollection(record.uri, record.value.name);
243
243
-
});
244
244
-
}
245
245
-
}
246
246
-
247
247
-
private renderCard(container: HTMLElement, record: CardWithNotes) {
248
248
-
const card = record.value;
249
249
-
const el = container.createEl("div", { cls: "semble-card" });
250
250
-
251
251
-
// Open detail modal on click
252
252
-
el.addEventListener("click", () => {
253
253
-
new CardDetailModal(this.plugin, record, () => {
254
254
-
void this.render();
255
255
-
}).open();
256
256
-
});
257
257
-
258
258
-
const header = el.createEl("div", { cls: "semble-card-header" });
259
259
-
header.createEl("span", {
260
260
-
text: card.type,
261
261
-
cls: `semble-badge semble-badge-${card.type?.toLowerCase() || "unknown"}`,
262
262
-
});
263
263
-
264
264
-
const addBtn = header.createEl("button", { cls: "semble-card-menu-btn" });
265
265
-
setIcon(addBtn, "more-vertical");
266
266
-
addBtn.setAttribute("aria-label", "Manage collections");
267
267
-
addBtn.addEventListener("click", (e) => {
268
268
-
e.stopPropagation();
269
269
-
new EditCardModal(this.plugin, record.uri, record.cid, () => {
270
270
-
void this.render();
271
271
-
}).open();
272
272
-
});
273
273
-
274
274
-
// Display attached notes at the top of the card
275
275
-
if (record.attachedNotes.length > 0) {
276
276
-
for (const note of record.attachedNotes) {
277
277
-
el.createEl("p", { text: note.text, cls: "semble-card-note" });
278
278
-
}
279
279
-
}
280
280
-
281
281
-
if (card.type === "NOTE") {
282
282
-
const content = card.content as NoteContent;
283
283
-
el.createEl("p", { text: content.text, cls: "semble-card-text" });
284
284
-
} else if (card.type === "URL") {
285
285
-
const content = card.content as UrlContent;
286
286
-
const meta = content.metadata;
287
287
-
288
288
-
if (meta?.title) {
289
289
-
el.createEl("div", { text: meta.title, cls: "semble-card-title" });
290
290
-
}
291
291
-
292
292
-
if (meta?.imageUrl) {
293
293
-
const img = el.createEl("img", { cls: "semble-card-image" });
294
294
-
img.src = meta.imageUrl;
295
295
-
img.alt = meta.title || "Image for " + content.url;
296
296
-
}
297
297
-
298
298
-
if (meta?.description) {
299
299
-
const desc = meta.description.length > 200
300
300
-
? meta.description.slice(0, 200) + "…"
301
301
-
: meta.description;
302
302
-
el.createEl("p", { text: desc, cls: "semble-card-desc" });
303
303
-
}
304
304
-
if (meta?.siteName) {
305
305
-
el.createEl("span", { text: meta.siteName, cls: "semble-card-site" });
306
306
-
}
307
307
-
308
308
-
const link = el.createEl("a", {
309
309
-
text: content.url,
310
310
-
href: content.url,
311
311
-
cls: "semble-card-url",
312
312
-
});
313
313
-
link.setAttr("target", "_blank");
314
314
-
}
315
315
-
316
316
-
const footer = el.createEl("div", { cls: "semble-card-footer" });
317
317
-
if (card.createdAt) {
318
318
-
footer.createEl("span", {
319
319
-
text: new Date(card.createdAt).toLocaleDateString(),
320
320
-
cls: "semble-card-date",
321
321
-
});
322
322
-
}
323
323
-
}
324
324
-
325
325
-
async onClose() { }
326
326
-
}
-155
src/views/collections.ts
···
1
1
-
import { ItemView, WorkspaceLeaf, setIcon } from "obsidian";
2
2
-
import type ATmarkPlugin from "../main";
3
3
-
import { getCollections } from "../lib";
4
4
-
import type { Main as Collection } from "../lexicons/types/network/cosmik/collection";
5
5
-
import { SembleCardsView, VIEW_TYPE_SEMBLE_CARDS } from "./cards";
6
6
-
import { renderProfileIcon } from "../components/profileIcon";
7
7
-
import { CreateCollectionModal } from "../components/createCollectionModal";
8
8
-
9
9
-
export const VIEW_TYPE_SEMBLE_COLLECTIONS = "semble-collections-view";
10
10
-
11
11
-
interface CollectionRecord {
12
12
-
uri: string;
13
13
-
value: Collection;
14
14
-
}
15
15
-
16
16
-
export class SembleCollectionsView extends ItemView {
17
17
-
plugin: ATmarkPlugin;
18
18
-
19
19
-
constructor(leaf: WorkspaceLeaf, plugin: ATmarkPlugin) {
20
20
-
super(leaf);
21
21
-
this.plugin = plugin;
22
22
-
}
23
23
-
24
24
-
getViewType() {
25
25
-
return VIEW_TYPE_SEMBLE_COLLECTIONS;
26
26
-
}
27
27
-
28
28
-
getDisplayText() {
29
29
-
return "Semble collections";
30
30
-
}
31
31
-
32
32
-
getIcon() {
33
33
-
return "layout-grid";
34
34
-
}
35
35
-
36
36
-
async onOpen() {
37
37
-
await this.render();
38
38
-
}
39
39
-
40
40
-
async openCollection(uri: string, name: string) {
41
41
-
const { workspace } = this.app;
42
42
-
const leaf = workspace.getLeaf("tab");
43
43
-
await leaf.setViewState({ type: VIEW_TYPE_SEMBLE_CARDS, active: true });
44
44
-
45
45
-
const view = leaf.view as SembleCardsView;
46
46
-
view.setCollection(uri, name);
47
47
-
48
48
-
void workspace.revealLeaf(leaf);
49
49
-
}
50
50
-
51
51
-
async render() {
52
52
-
const container = this.contentEl;
53
53
-
container.empty();
54
54
-
container.addClass("semble-collections-view");
55
55
-
56
56
-
const header = container.createEl("div", { cls: "semble-page-header" });
57
57
-
const nav = header.createEl("div", { cls: "semble-nav-row" });
58
58
-
nav.createEl("span", { text: "Semble", cls: "semble-brand" });
59
59
-
60
60
-
renderProfileIcon(nav, this.plugin.profile);
61
61
-
62
62
-
header.createEl("h2", { text: "Collections", cls: "semble-page-title" });
63
63
-
64
64
-
if (!this.plugin.client) {
65
65
-
container.createEl("p", { text: "Not connected. Configure credentials in settings." });
66
66
-
return;
67
67
-
}
68
68
-
69
69
-
const repo = this.plugin.settings.identifier;
70
70
-
if (!repo) {
71
71
-
container.createEl("p", { text: "No identifier configured in settings." });
72
72
-
return;
73
73
-
}
74
74
-
75
75
-
const toolbar = container.createEl("div", { cls: "semble-toolbar" });
76
76
-
77
77
-
const createBtn = toolbar.createEl("button", { cls: "semble-create-btn" });
78
78
-
setIcon(createBtn, "plus");
79
79
-
createBtn.createEl("span", { text: "New collection" });
80
80
-
createBtn.addEventListener("click", () => {
81
81
-
new CreateCollectionModal(this.plugin, () => { void this.render(); }).open();
82
82
-
});
83
83
-
84
84
-
const allCardsBtn = toolbar.createEl("button", { cls: "semble-toolbar-btn" });
85
85
-
setIcon(allCardsBtn, "layers");
86
86
-
allCardsBtn.createEl("span", { text: "All cards" });
87
87
-
allCardsBtn.addEventListener("click", () => {
88
88
-
void this.plugin.activateView(VIEW_TYPE_SEMBLE_CARDS);
89
89
-
});
90
90
-
91
91
-
const loading = container.createEl("p", { text: "Loading..." });
92
92
-
93
93
-
try {
94
94
-
const resp = await getCollections(this.plugin.client, repo);
95
95
-
loading.remove();
96
96
-
97
97
-
if (!resp.ok) {
98
98
-
const errorMsg = resp.data?.error ? String(resp.data.error) : "Unknown error";
99
99
-
container.createEl("p", { text: `Error: ${errorMsg}`, cls: "semble-error" });
100
100
-
return;
101
101
-
}
102
102
-
103
103
-
const records = resp.data.records as unknown as CollectionRecord[];
104
104
-
105
105
-
if (records.length === 0) {
106
106
-
container.createEl("p", { text: "No collections found." });
107
107
-
return;
108
108
-
}
109
109
-
110
110
-
const grid = container.createEl("div", { cls: "semble-card-grid" });
111
111
-
112
112
-
for (const record of records) {
113
113
-
const col = record.value;
114
114
-
const card = grid.createEl("div", { cls: "semble-card" });
115
115
-
116
116
-
card.addEventListener("click", () => {
117
117
-
void this.plugin.openCollection(record.uri, col.name);
118
118
-
});
119
119
-
120
120
-
const cardHeader = card.createEl("div", { cls: "semble-card-header" });
121
121
-
cardHeader.createEl("span", { text: col.name, cls: "semble-card-title" });
122
122
-
123
123
-
const accessIcon = cardHeader.createEl("span", {
124
124
-
cls: `semble-access-icon semble-access-${col.accessType.toLowerCase()}`,
125
125
-
attr: { "aria-label": col.accessType },
126
126
-
});
127
127
-
setIcon(accessIcon, col.accessType === "OPEN" ? "globe" : "lock");
128
128
-
129
129
-
if (col.description) {
130
130
-
card.createEl("p", { text: col.description, cls: "semble-card-desc" });
131
131
-
}
132
132
-
133
133
-
const footer = card.createEl("div", { cls: "semble-card-footer" });
134
134
-
if (col.createdAt) {
135
135
-
footer.createEl("span", {
136
136
-
text: new Date(col.createdAt).toLocaleDateString(),
137
137
-
cls: "semble-card-date",
138
138
-
});
139
139
-
}
140
140
-
if (col.collaborators?.length) {
141
141
-
footer.createEl("span", {
142
142
-
text: `${col.collaborators.length} collaborators`,
143
143
-
cls: "semble-card-collabs",
144
144
-
});
145
145
-
}
146
146
-
}
147
147
-
} catch (err) {
148
148
-
loading.remove();
149
149
-
const message = err instanceof Error ? err.message : String(err);
150
150
-
container.createEl("p", { text: `Failed to load: ${message}`, cls: "semble-error" });
151
151
-
}
152
152
-
}
153
153
-
154
154
-
async onClose() {}
155
155
-
}
+266
styles.css
···
1
1
+
/* ATmark View */
2
2
+
.atmark-view {
3
3
+
padding: 20px;
4
4
+
}
5
5
+
6
6
+
.atmark-header {
7
7
+
margin-bottom: 24px;
8
8
+
padding-bottom: 16px;
9
9
+
border-bottom: 1px solid var(--background-modifier-border);
10
10
+
}
11
11
+
12
12
+
.atmark-nav {
13
13
+
position: relative;
14
14
+
display: flex;
15
15
+
align-items: center;
16
16
+
justify-content: space-between;
17
17
+
margin-bottom: 16px;
18
18
+
}
19
19
+
20
20
+
.atmark-title {
21
21
+
margin: 0;
22
22
+
font-size: var(--h1-size);
23
23
+
font-weight: var(--font-bold);
24
24
+
color: var(--text-accent);
25
25
+
}
26
26
+
27
27
+
.atmark-source-selector {
28
28
+
position: absolute;
29
29
+
left: 50%;
30
30
+
transform: translateX(-50%);
31
31
+
display: flex;
32
32
+
align-items: center;
33
33
+
gap: 8px;
34
34
+
}
35
35
+
36
36
+
.atmark-source-option {
37
37
+
display: flex;
38
38
+
align-items: center;
39
39
+
justify-content: center;
40
40
+
gap: 6px;
41
41
+
padding: 8px 20px;
42
42
+
cursor: pointer;
43
43
+
user-select: none;
44
44
+
border-radius: var(--radius-m);
45
45
+
border: 1px solid var(--background-modifier-border);
46
46
+
background: var(--background-secondary);
47
47
+
transition: all 0.15s ease;
48
48
+
}
49
49
+
50
50
+
.atmark-source-option:hover {
51
51
+
background: var(--background-modifier-hover);
52
52
+
border-color: var(--background-modifier-border-hover);
53
53
+
}
54
54
+
55
55
+
.atmark-source-option:has(input:checked) {
56
56
+
background: var(--interactive-accent);
57
57
+
border-color: var(--interactive-accent);
58
58
+
}
59
59
+
60
60
+
.atmark-source-option:has(input:checked) .atmark-source-text {
61
61
+
color: var(--text-on-accent);
62
62
+
}
63
63
+
64
64
+
.atmark-source-radio {
65
65
+
display: none;
66
66
+
}
67
67
+
68
68
+
.atmark-source-text {
69
69
+
font-size: var(--font-small);
70
70
+
font-weight: var(--font-medium);
71
71
+
color: var(--text-normal);
72
72
+
}
73
73
+
74
74
+
.atmark-filters {
75
75
+
display: flex;
76
76
+
flex-direction: column;
77
77
+
gap: 12px;
78
78
+
margin-bottom: 16px;
79
79
+
}
80
80
+
81
81
+
.atmark-filter-section {
82
82
+
display: flex;
83
83
+
flex-direction: column;
84
84
+
gap: 8px;
85
85
+
}
86
86
+
87
87
+
.atmark-filter-title {
88
88
+
margin: 0;
89
89
+
font-size: var(--font-small);
90
90
+
font-weight: var(--font-semibold);
91
91
+
color: var(--text-muted);
92
92
+
text-transform: uppercase;
93
93
+
letter-spacing: 0.5px;
94
94
+
}
95
95
+
96
96
+
.atmark-filter-chips {
97
97
+
display: flex;
98
98
+
flex-wrap: wrap;
99
99
+
gap: 8px;
100
100
+
}
101
101
+
102
102
+
.atmark-chip {
103
103
+
padding: 6px 14px;
104
104
+
border-radius: var(--radius-full);
105
105
+
border: 1px solid var(--background-modifier-border);
106
106
+
background: var(--background-secondary);
107
107
+
color: var(--text-muted);
108
108
+
font-size: var(--font-small);
109
109
+
cursor: pointer;
110
110
+
transition: all 0.15s ease;
111
111
+
}
112
112
+
113
113
+
.atmark-chip:hover {
114
114
+
background: var(--background-modifier-hover);
115
115
+
color: var(--text-normal);
116
116
+
}
117
117
+
118
118
+
.atmark-chip-active {
119
119
+
background: var(--interactive-accent);
120
120
+
color: var(--text-on-accent);
121
121
+
border-color: var(--interactive-accent);
122
122
+
}
123
123
+
124
124
+
.atmark-chip-active:hover {
125
125
+
background: var(--interactive-accent-hover);
126
126
+
}
127
127
+
128
128
+
.atmark-grid {
129
129
+
display: grid;
130
130
+
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
131
131
+
gap: 16px;
132
132
+
padding: 8px 0;
133
133
+
}
134
134
+
135
135
+
.atmark-item {
136
136
+
background: var(--background-secondary);
137
137
+
border: 1px solid var(--background-modifier-border);
138
138
+
border-radius: var(--radius-m);
139
139
+
padding: 16px;
140
140
+
display: flex;
141
141
+
flex-direction: column;
142
142
+
gap: 8px;
143
143
+
transition: box-shadow 0.15s ease, border-color 0.15s ease;
144
144
+
cursor: pointer;
145
145
+
}
146
146
+
147
147
+
.atmark-item:hover {
148
148
+
box-shadow: var(--shadow-s);
149
149
+
border-color: var(--background-modifier-border-hover);
150
150
+
}
151
151
+
152
152
+
.atmark-item-header {
153
153
+
display: flex;
154
154
+
justify-content: space-between;
155
155
+
align-items: flex-start;
156
156
+
gap: 8px;
157
157
+
}
158
158
+
159
159
+
.atmark-badge {
160
160
+
font-size: var(--font-smallest);
161
161
+
padding: 2px 8px;
162
162
+
border-radius: var(--radius-s);
163
163
+
text-transform: uppercase;
164
164
+
font-weight: var(--font-medium);
165
165
+
flex-shrink: 0;
166
166
+
opacity: 0.8;
167
167
+
}
168
168
+
169
169
+
.atmark-badge-semble {
170
170
+
background: var(--color-green);
171
171
+
color: var(--text-on-accent);
172
172
+
}
173
173
+
174
174
+
.atmark-badge-bookmark {
175
175
+
background: var(--color-cyan);
176
176
+
color: var(--text-on-accent);
177
177
+
}
178
178
+
179
179
+
.atmark-item-footer {
180
180
+
display: flex;
181
181
+
justify-content: space-between;
182
182
+
font-size: var(--font-smallest);
183
183
+
color: var(--text-faint);
184
184
+
margin-top: auto;
185
185
+
padding-top: 8px;
186
186
+
border-top: 1px solid var(--background-modifier-border);
187
187
+
}
188
188
+
189
189
+
.atmark-date {
190
190
+
font-size: var(--font-smallest);
191
191
+
color: var(--text-faint);
192
192
+
}
193
193
+
194
194
+
.atmark-error {
195
195
+
color: var(--text-error);
196
196
+
}
197
197
+
198
198
+
/* Legacy Semble classes for backwards compatibility */
1
199
.semble-card-grid {
2
200
display: grid;
3
201
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
···
107
305
color: var(--text-on-accent);
108
306
}
109
307
308
308
+
.semble-badge-bookmark {
309
309
+
background: var(--color-cyan);
310
310
+
color: var(--text-on-accent);
311
311
+
}
312
312
+
313
313
+
.semble-badge-source {
314
314
+
font-size: var(--font-smallest);
315
315
+
opacity: 0.8;
316
316
+
}
317
317
+
318
318
+
.semble-badge-semble {
319
319
+
background: var(--color-green);
320
320
+
color: var(--text-on-accent);
321
321
+
}
322
322
+
323
323
+
/* Tags */
324
324
+
.semble-card-tags {
325
325
+
display: flex;
326
326
+
flex-wrap: wrap;
327
327
+
gap: 6px;
328
328
+
margin-bottom: 8px;
329
329
+
}
330
330
+
331
331
+
.semble-tag {
332
332
+
font-size: var(--font-smallest);
333
333
+
padding: 2px 8px;
334
334
+
border-radius: var(--radius-s);
335
335
+
background: var(--background-modifier-border);
336
336
+
color: var(--text-muted);
337
337
+
border: 1px solid var(--background-modifier-border-hover);
338
338
+
}
339
339
+
340
340
+
.semble-detail-tags-section {
341
341
+
margin-top: 20px;
342
342
+
padding-top: 20px;
343
343
+
border-top: 1px solid var(--background-modifier-border);
344
344
+
}
345
345
+
110
346
/* Page header */
111
347
.semble-page-header {
112
348
margin-bottom: 16px;
···
197
433
198
434
.semble-back-btn:hover {
199
435
background: var(--background-modifier-hover);
436
436
+
color: var(--text-normal);
437
437
+
}
438
438
+
439
439
+
/* Source filter toggles */
440
440
+
.semble-source-filters {
441
441
+
display: flex;
442
442
+
align-items: center;
443
443
+
gap: 16px;
444
444
+
padding: 12px 0;
445
445
+
margin-bottom: 12px;
446
446
+
}
447
447
+
448
448
+
.semble-source-filter-label {
449
449
+
display: flex;
450
450
+
align-items: center;
451
451
+
gap: 8px;
452
452
+
cursor: pointer;
453
453
+
user-select: none;
454
454
+
}
455
455
+
456
456
+
.semble-source-filter-checkbox {
457
457
+
width: 16px;
458
458
+
height: 16px;
459
459
+
cursor: pointer;
460
460
+
accent-color: var(--interactive-accent);
461
461
+
}
462
462
+
463
463
+
.semble-source-filter-text {
464
464
+
font-size: var(--font-small);
465
465
+
font-weight: var(--font-medium);
200
466
color: var(--text-normal);
201
467
}
202
468