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
dit modal for cards / add collections
treethought
2 months ago
35a8434a
4ea366e9
+906
-35
8 changed files
expand all
collapse all
unified
split
src
components
createCollectionModal.ts
editCardModal.ts
profileIcon.ts
lib.ts
main.ts
views
cards.ts
collections.ts
styles.css
+99
src/components/createCollectionModal.ts
···
1
1
+
import { Modal, Notice } from "obsidian";
2
2
+
import type ATmarkPlugin from "../main";
3
3
+
import { createCollection } from "../lib";
4
4
+
5
5
+
export class CreateCollectionModal extends Modal {
6
6
+
plugin: ATmarkPlugin;
7
7
+
onSuccess?: () => void;
8
8
+
9
9
+
constructor(plugin: ATmarkPlugin, onSuccess?: () => void) {
10
10
+
super(plugin.app);
11
11
+
this.plugin = plugin;
12
12
+
this.onSuccess = onSuccess;
13
13
+
}
14
14
+
15
15
+
onOpen() {
16
16
+
const { contentEl } = this;
17
17
+
contentEl.empty();
18
18
+
contentEl.addClass("semble-collection-modal");
19
19
+
20
20
+
contentEl.createEl("h2", { text: "New Collection" });
21
21
+
22
22
+
if (!this.plugin.client) {
23
23
+
contentEl.createEl("p", { text: "Not connected." });
24
24
+
return;
25
25
+
}
26
26
+
27
27
+
const form = contentEl.createEl("form", { cls: "semble-form" });
28
28
+
29
29
+
// Name field
30
30
+
const nameGroup = form.createEl("div", { cls: "semble-form-group" });
31
31
+
nameGroup.createEl("label", { text: "Name", attr: { for: "collection-name" } });
32
32
+
const nameInput = nameGroup.createEl("input", {
33
33
+
type: "text",
34
34
+
cls: "semble-input",
35
35
+
attr: { id: "collection-name", placeholder: "Collection name", required: "true" },
36
36
+
});
37
37
+
38
38
+
// Description field
39
39
+
const descGroup = form.createEl("div", { cls: "semble-form-group" });
40
40
+
descGroup.createEl("label", { text: "Description", attr: { for: "collection-desc" } });
41
41
+
const descInput = descGroup.createEl("textarea", {
42
42
+
cls: "semble-textarea",
43
43
+
attr: { id: "collection-desc", placeholder: "Optional description", rows: "3" },
44
44
+
});
45
45
+
46
46
+
// Action buttons
47
47
+
const actions = form.createEl("div", { cls: "semble-modal-actions" });
48
48
+
49
49
+
const cancelBtn = actions.createEl("button", {
50
50
+
text: "Cancel",
51
51
+
cls: "semble-btn semble-btn-secondary",
52
52
+
type: "button",
53
53
+
});
54
54
+
cancelBtn.addEventListener("click", () => this.close());
55
55
+
56
56
+
const createBtn = actions.createEl("button", {
57
57
+
text: "Create",
58
58
+
cls: "semble-btn semble-btn-primary",
59
59
+
type: "submit",
60
60
+
});
61
61
+
62
62
+
form.addEventListener("submit", async (e) => {
63
63
+
e.preventDefault();
64
64
+
65
65
+
const name = nameInput.value.trim();
66
66
+
if (!name) {
67
67
+
new Notice("Please enter a collection name");
68
68
+
return;
69
69
+
}
70
70
+
71
71
+
createBtn.disabled = true;
72
72
+
createBtn.textContent = "Creating...";
73
73
+
74
74
+
try {
75
75
+
await createCollection(
76
76
+
this.plugin.client!,
77
77
+
this.plugin.settings.identifier,
78
78
+
name,
79
79
+
descInput.value.trim()
80
80
+
);
81
81
+
82
82
+
new Notice(`Created collection "${name}"`);
83
83
+
this.close();
84
84
+
this.onSuccess?.();
85
85
+
} catch (e) {
86
86
+
new Notice(`Failed to create collection: ${e}`);
87
87
+
createBtn.disabled = false;
88
88
+
createBtn.textContent = "Create";
89
89
+
}
90
90
+
});
91
91
+
92
92
+
// Focus name input
93
93
+
nameInput.focus();
94
94
+
}
95
95
+
96
96
+
onClose() {
97
97
+
this.contentEl.empty();
98
98
+
}
99
99
+
}
+263
src/components/editCardModal.ts
···
1
1
+
import { Modal, Notice } from "obsidian";
2
2
+
import type ATmarkPlugin from "../main";
3
3
+
import { getCollections, getCollectionLinks, createCollectionLink, getRecord, deleteRecord } from "../lib";
4
4
+
import type { Main as Collection } from "../lexicons/types/network/cosmik/collection";
5
5
+
import type { Main as CollectionLink } from "../lexicons/types/network/cosmik/collectionLink";
6
6
+
7
7
+
interface CollectionRecord {
8
8
+
uri: string;
9
9
+
cid: string;
10
10
+
value: Collection;
11
11
+
}
12
12
+
13
13
+
interface CollectionLinkRecord {
14
14
+
uri: string;
15
15
+
value: CollectionLink;
16
16
+
}
17
17
+
18
18
+
interface CollectionState {
19
19
+
collection: CollectionRecord;
20
20
+
isSelected: boolean;
21
21
+
wasSelected: boolean; // Original state to track changes
22
22
+
linkUri?: string; // URI of existing link (for deletion)
23
23
+
}
24
24
+
25
25
+
export class EditCardModal extends Modal {
26
26
+
plugin: ATmarkPlugin;
27
27
+
cardUri: string;
28
28
+
cardCid: string;
29
29
+
onSuccess?: () => void;
30
30
+
collectionStates: CollectionState[] = [];
31
31
+
32
32
+
constructor(plugin: ATmarkPlugin, cardUri: string, cardCid: string, onSuccess?: () => void) {
33
33
+
super(plugin.app);
34
34
+
this.plugin = plugin;
35
35
+
this.cardUri = cardUri;
36
36
+
this.cardCid = cardCid;
37
37
+
this.onSuccess = onSuccess;
38
38
+
}
39
39
+
40
40
+
async onOpen() {
41
41
+
const { contentEl } = this;
42
42
+
contentEl.empty();
43
43
+
contentEl.addClass("semble-collection-modal");
44
44
+
45
45
+
contentEl.createEl("h2", { text: "Edit Collections" });
46
46
+
47
47
+
if (!this.plugin.client) {
48
48
+
contentEl.createEl("p", { text: "Not connected." });
49
49
+
return;
50
50
+
}
51
51
+
52
52
+
const loading = contentEl.createEl("p", { text: "Loading..." });
53
53
+
54
54
+
try {
55
55
+
// Fetch collections and existing links in parallel
56
56
+
const [collectionsResp, linksResp] = await Promise.all([
57
57
+
getCollections(this.plugin.client, this.plugin.settings.identifier),
58
58
+
getCollectionLinks(this.plugin.client, this.plugin.settings.identifier),
59
59
+
]);
60
60
+
61
61
+
loading.remove();
62
62
+
63
63
+
if (!collectionsResp.ok) {
64
64
+
contentEl.createEl("p", { text: "Failed to load collections.", cls: "semble-error" });
65
65
+
return;
66
66
+
}
67
67
+
68
68
+
const collections = collectionsResp.data.records as unknown as CollectionRecord[];
69
69
+
const links = (linksResp.ok ? linksResp.data.records : []) as unknown as CollectionLinkRecord[];
70
70
+
71
71
+
if (collections.length === 0) {
72
72
+
contentEl.createEl("p", { text: "No collections found. Create a collection first." });
73
73
+
return;
74
74
+
}
75
75
+
76
76
+
// Find which collections this card is already in
77
77
+
const cardLinks = links.filter(link => link.value.card.uri === this.cardUri);
78
78
+
const linkedCollectionUris = new Map<string, string>();
79
79
+
for (const link of cardLinks) {
80
80
+
linkedCollectionUris.set(link.value.collection.uri, link.uri);
81
81
+
}
82
82
+
83
83
+
// Build collection states
84
84
+
this.collectionStates = collections.map(collection => ({
85
85
+
collection,
86
86
+
isSelected: linkedCollectionUris.has(collection.uri),
87
87
+
wasSelected: linkedCollectionUris.has(collection.uri),
88
88
+
linkUri: linkedCollectionUris.get(collection.uri),
89
89
+
}));
90
90
+
91
91
+
this.renderCollectionList(contentEl);
92
92
+
} catch (e) {
93
93
+
loading.remove();
94
94
+
contentEl.createEl("p", { text: `Error: ${e}`, cls: "semble-error" });
95
95
+
}
96
96
+
}
97
97
+
98
98
+
private renderCollectionList(contentEl: HTMLElement) {
99
99
+
const list = contentEl.createEl("div", { cls: "semble-collection-list" });
100
100
+
101
101
+
for (const state of this.collectionStates) {
102
102
+
const item = list.createEl("label", { cls: "semble-collection-item" });
103
103
+
104
104
+
const checkbox = item.createEl("input", { type: "checkbox", cls: "semble-collection-checkbox" });
105
105
+
checkbox.checked = state.isSelected;
106
106
+
checkbox.addEventListener("change", () => {
107
107
+
state.isSelected = checkbox.checked;
108
108
+
this.updateSaveButton();
109
109
+
});
110
110
+
111
111
+
const info = item.createEl("div", { cls: "semble-collection-item-info" });
112
112
+
info.createEl("span", { text: state.collection.value.name, cls: "semble-collection-item-name" });
113
113
+
if (state.collection.value.description) {
114
114
+
info.createEl("span", { text: state.collection.value.description, cls: "semble-collection-item-desc" });
115
115
+
}
116
116
+
}
117
117
+
118
118
+
// Action buttons
119
119
+
const actions = contentEl.createEl("div", { cls: "semble-modal-actions" });
120
120
+
121
121
+
const deleteBtn = actions.createEl("button", { text: "Delete", cls: "semble-btn semble-btn-danger" });
122
122
+
deleteBtn.addEventListener("click", () => this.confirmDelete(contentEl));
123
123
+
124
124
+
const spacer = actions.createEl("div", { cls: "semble-spacer" });
125
125
+
126
126
+
const cancelBtn = actions.createEl("button", { text: "Cancel", cls: "semble-btn semble-btn-secondary" });
127
127
+
cancelBtn.addEventListener("click", () => this.close());
128
128
+
129
129
+
const saveBtn = actions.createEl("button", { text: "Save", cls: "semble-btn semble-btn-primary" });
130
130
+
saveBtn.id = "semble-save-btn";
131
131
+
saveBtn.disabled = true;
132
132
+
saveBtn.addEventListener("click", () => this.saveChanges());
133
133
+
}
134
134
+
135
135
+
private confirmDelete(contentEl: HTMLElement) {
136
136
+
contentEl.empty();
137
137
+
contentEl.createEl("h2", { text: "Delete Card" });
138
138
+
contentEl.createEl("p", { text: "Delete this card?", cls: "semble-warning-text" });
139
139
+
140
140
+
const actions = contentEl.createEl("div", { cls: "semble-modal-actions" });
141
141
+
142
142
+
const cancelBtn = actions.createEl("button", { text: "Cancel", cls: "semble-btn semble-btn-secondary" });
143
143
+
cancelBtn.addEventListener("click", () => {
144
144
+
// Re-render the modal
145
145
+
this.onOpen();
146
146
+
});
147
147
+
148
148
+
const confirmBtn = actions.createEl("button", { text: "Delete", cls: "semble-btn semble-btn-danger" });
149
149
+
confirmBtn.addEventListener("click", () => this.deleteCard());
150
150
+
}
151
151
+
152
152
+
private async deleteCard() {
153
153
+
if (!this.plugin.client) return;
154
154
+
155
155
+
const { contentEl } = this;
156
156
+
contentEl.empty();
157
157
+
contentEl.createEl("p", { text: "Deleting card..." });
158
158
+
159
159
+
try {
160
160
+
const rkey = this.cardUri.split("/").pop();
161
161
+
if (!rkey) {
162
162
+
contentEl.empty();
163
163
+
contentEl.createEl("p", { text: "Invalid card URI.", cls: "semble-error" });
164
164
+
return;
165
165
+
}
166
166
+
167
167
+
await deleteRecord(
168
168
+
this.plugin.client,
169
169
+
this.plugin.settings.identifier,
170
170
+
"network.cosmik.card",
171
171
+
rkey
172
172
+
);
173
173
+
174
174
+
new Notice("Card deleted");
175
175
+
this.close();
176
176
+
this.onSuccess?.();
177
177
+
} catch (e) {
178
178
+
contentEl.empty();
179
179
+
contentEl.createEl("p", { text: `Failed to delete: ${e}`, cls: "semble-error" });
180
180
+
}
181
181
+
}
182
182
+
183
183
+
private updateSaveButton() {
184
184
+
const saveBtn = document.getElementById("semble-save-btn") as HTMLButtonElement;
185
185
+
if (!saveBtn) return;
186
186
+
187
187
+
// Check if any changes were made
188
188
+
const hasChanges = this.collectionStates.some(s => s.isSelected !== s.wasSelected);
189
189
+
saveBtn.disabled = !hasChanges;
190
190
+
}
191
191
+
192
192
+
private async saveChanges() {
193
193
+
if (!this.plugin.client) return;
194
194
+
195
195
+
const { contentEl } = this;
196
196
+
contentEl.empty();
197
197
+
contentEl.createEl("p", { text: "Saving changes..." });
198
198
+
199
199
+
try {
200
200
+
const toAdd = this.collectionStates.filter(s => s.isSelected && !s.wasSelected);
201
201
+
const toRemove = this.collectionStates.filter(s => !s.isSelected && s.wasSelected);
202
202
+
203
203
+
// Process removals
204
204
+
for (const state of toRemove) {
205
205
+
if (state.linkUri) {
206
206
+
const rkey = state.linkUri.split("/").pop();
207
207
+
if (rkey) {
208
208
+
await deleteRecord(
209
209
+
this.plugin.client,
210
210
+
this.plugin.settings.identifier,
211
211
+
"network.cosmik.collectionLink",
212
212
+
rkey
213
213
+
);
214
214
+
}
215
215
+
}
216
216
+
}
217
217
+
218
218
+
// Process additions
219
219
+
for (const state of toAdd) {
220
220
+
const collectionRkey = state.collection.uri.split("/").pop();
221
221
+
if (!collectionRkey) continue;
222
222
+
223
223
+
const collectionResp = await getRecord(
224
224
+
this.plugin.client,
225
225
+
this.plugin.settings.identifier,
226
226
+
"network.cosmik.collection",
227
227
+
collectionRkey
228
228
+
);
229
229
+
230
230
+
if (!collectionResp.ok || !collectionResp.data.cid) continue;
231
231
+
232
232
+
await createCollectionLink(
233
233
+
this.plugin.client,
234
234
+
this.plugin.settings.identifier,
235
235
+
this.cardUri,
236
236
+
this.cardCid,
237
237
+
state.collection.uri,
238
238
+
collectionResp.data.cid as string
239
239
+
);
240
240
+
}
241
241
+
242
242
+
const addedCount = toAdd.length;
243
243
+
const removedCount = toRemove.length;
244
244
+
const messages: string[] = [];
245
245
+
if (addedCount > 0) messages.push(`Added to ${addedCount} collection${addedCount > 1 ? "s" : ""}`);
246
246
+
if (removedCount > 0) messages.push(`Removed from ${removedCount} collection${removedCount > 1 ? "s" : ""}`);
247
247
+
248
248
+
if (messages.length > 0) {
249
249
+
new Notice(messages.join(". "));
250
250
+
}
251
251
+
252
252
+
this.close();
253
253
+
this.onSuccess?.();
254
254
+
} catch (e) {
255
255
+
contentEl.empty();
256
256
+
contentEl.createEl("p", { text: `Failed to save: ${e}`, cls: "semble-error" });
257
257
+
}
258
258
+
}
259
259
+
260
260
+
onClose() {
261
261
+
this.contentEl.empty();
262
262
+
}
263
263
+
}
+54
src/components/profileIcon.ts
···
1
1
+
export interface ProfileData {
2
2
+
did: string;
3
3
+
handle: string;
4
4
+
displayName?: string;
5
5
+
avatar?: string;
6
6
+
}
7
7
+
8
8
+
export function renderProfileIcon(
9
9
+
container: HTMLElement,
10
10
+
profile: ProfileData | null,
11
11
+
onClick?: () => void
12
12
+
): HTMLElement {
13
13
+
const wrapper = container.createEl("div", { cls: "semble-profile-icon" });
14
14
+
15
15
+
if (!profile) {
16
16
+
// Fallback when no profile data
17
17
+
const placeholder = wrapper.createEl("div", { cls: "semble-avatar-placeholder" });
18
18
+
placeholder.createEl("span", { text: "?" });
19
19
+
return wrapper;
20
20
+
}
21
21
+
22
22
+
// Avatar button
23
23
+
const avatarBtn = wrapper.createEl("button", { cls: "semble-avatar-btn" });
24
24
+
25
25
+
if (profile.avatar) {
26
26
+
const img = avatarBtn.createEl("img", { cls: "semble-avatar-img" });
27
27
+
img.src = profile.avatar;
28
28
+
img.alt = profile.displayName || profile.handle;
29
29
+
} else {
30
30
+
// Fallback initials
31
31
+
const initials = (profile.displayName || profile.handle)
32
32
+
.split(" ")
33
33
+
.map(w => w[0])
34
34
+
.slice(0, 2)
35
35
+
.join("")
36
36
+
.toUpperCase();
37
37
+
avatarBtn.createEl("span", { text: initials, cls: "semble-avatar-initials" });
38
38
+
}
39
39
+
40
40
+
// User info (display name and handle)
41
41
+
const info = wrapper.createEl("div", { cls: "semble-profile-info" });
42
42
+
43
43
+
if (profile.displayName) {
44
44
+
info.createEl("span", { text: profile.displayName, cls: "semble-profile-name" });
45
45
+
}
46
46
+
47
47
+
info.createEl("span", { text: `@${profile.handle}`, cls: "semble-profile-handle" });
48
48
+
49
49
+
if (onClick) {
50
50
+
avatarBtn.addEventListener("click", onClick);
51
51
+
}
52
52
+
53
53
+
return wrapper;
54
54
+
}
+58
src/lib.ts
···
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
+
20
38
export async function getCards(client: Client, repo: string) {
21
39
return await client.get("com.atproto.repo.listRecords", {
22
40
params: {
···
37
55
});
38
56
}
39
57
58
58
+
export async function createCollectionLink(
59
59
+
client: Client,
60
60
+
repo: string,
61
61
+
cardUri: string,
62
62
+
cardCid: string,
63
63
+
collectionUri: string,
64
64
+
collectionCid: 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.collectionLink" as Nsid,
70
70
+
record: {
71
71
+
$type: "network.cosmik.collectionLink",
72
72
+
card: {
73
73
+
uri: cardUri,
74
74
+
cid: cardCid,
75
75
+
},
76
76
+
collection: {
77
77
+
uri: collectionUri,
78
78
+
cid: collectionCid,
79
79
+
},
80
80
+
addedAt: new Date().toISOString(),
81
81
+
addedBy: repo,
82
82
+
createdAt: new Date().toISOString(),
83
83
+
},
84
84
+
},
85
85
+
});
86
86
+
}
87
87
+
40
88
export async function getRecord(client: Client, repo: string, collection: string, rkey: string) {
41
89
return await client.get("com.atproto.repo.getRecord", {
42
90
params: {
···
46
94
},
47
95
});
48
96
}
97
97
+
98
98
+
export async function deleteRecord(client: Client, repo: string, collection: string, rkey: string) {
99
99
+
return await client.post("com.atproto.repo.deleteRecord", {
100
100
+
input: {
101
101
+
repo: repo as ActorIdentifier,
102
102
+
collection: collection as Nsid,
103
103
+
rkey,
104
104
+
},
105
105
+
});
106
106
+
}
+31
-3
src/main.ts
···
2
2
import type { Client } from "@atcute/client";
3
3
import { DEFAULT_SETTINGS, AtProtoSettings, SettingTab } from "./settings";
4
4
import { createAuthenticatedClient, createPublicClient } from "./auth";
5
5
-
import { getCollections } from "./lib";
5
5
+
import { getCollections, 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";
8
8
import { CreateCardModal } from "components/cardForm";
9
9
+
import type { ProfileData } from "components/profileIcon";
9
10
10
11
export default class ATmarkPlugin extends Plugin {
11
12
settings: AtProtoSettings = DEFAULT_SETTINGS;
12
13
client: Client | null = null;
14
14
+
profile: ProfileData | null = null;
13
15
14
16
async onload() {
15
17
await this.loadSettings();
···
62
64
if (identifier && appPassword) {
63
65
try {
64
66
this.client = await createAuthenticatedClient({ identifier, password: appPassword });
67
67
+
await this.fetchProfile();
65
68
new Notice("Connected to Bluesky");
66
69
} catch (e) {
67
70
new Notice(`Auth failed: ${e}`);
68
71
this.client = createPublicClient();
72
72
+
this.profile = null;
69
73
}
70
74
} else {
71
75
this.client = createPublicClient();
76
76
+
this.profile = null;
77
77
+
}
78
78
+
}
79
79
+
80
80
+
private async fetchProfile() {
81
81
+
if (!this.client || !this.settings.identifier) {
82
82
+
this.profile = null;
83
83
+
return;
84
84
+
}
85
85
+
try {
86
86
+
const resp = await getProfile(this.client, this.settings.identifier);
87
87
+
if (resp.ok) {
88
88
+
this.profile = {
89
89
+
did: resp.data.did,
90
90
+
handle: resp.data.handle,
91
91
+
displayName: resp.data.displayName,
92
92
+
avatar: resp.data.avatar,
93
93
+
};
94
94
+
} else {
95
95
+
this.profile = null;
96
96
+
}
97
97
+
} catch (e) {
98
98
+
console.error("Failed to fetch profile:", e);
99
99
+
this.profile = null;
72
100
}
73
101
}
74
102
···
92
120
93
121
// Our view could not be found in the workspace, create a new leaf
94
122
// in the right sidebar for it
95
95
-
// leaf = workspace.getRightLeaf(false);
96
96
-
leaf = workspace.getMostRecentLeaf()
123
123
+
leaf = workspace.getRightLeaf(false);
124
124
+
// leaf = workspace.getMostRecentLeaf()
97
125
await leaf?.setViewState({ type: v, active: true });
98
126
99
127
// "Reveal" the leaf in case it is in a collapsed sidebar
+17
-2
src/views/cards.ts
···
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";
8
10
9
11
export const VIEW_TYPE_SEMBLE_CARDS = "semble-cards-view";
10
12
···
136
138
const grid = container.createEl("div", { cls: "semble-card-grid" });
137
139
for (const record of cards) {
138
140
try {
139
139
-
this.renderCard(grid, record.value);
141
141
+
this.renderCard(grid, record);
140
142
} catch (e) {
141
143
console.log(JSON.stringify(record.value, null, 2));
142
144
console.error(`Failed to render card ${record.uri}: ${e}`);
···
162
164
163
165
nav.createEl("span", { text: "Semble", cls: "semble-brand" });
164
166
167
167
+
renderProfileIcon(nav, this.plugin.profile);
168
168
+
165
169
header.createEl("h2", { text: this.collectionName, cls: "semble-page-title" });
166
170
167
171
// Filter chips
···
188
192
}
189
193
}
190
194
191
191
-
private renderCard(container: HTMLElement, card: Card) {
195
195
+
private renderCard(container: HTMLElement, record: CardRecord) {
196
196
+
const card = record.value;
192
197
const el = container.createEl("div", { cls: "semble-card" });
193
198
194
199
const header = el.createEl("div", { cls: "semble-card-header" });
195
200
header.createEl("span", {
196
201
text: card.type,
197
202
cls: `semble-badge semble-badge-${card.type?.toLowerCase() || "unknown"}`,
203
203
+
});
204
204
+
205
205
+
const addBtn = header.createEl("button", { cls: "semble-card-menu-btn" });
206
206
+
setIcon(addBtn, "more-vertical");
207
207
+
addBtn.setAttribute("aria-label", "Manage collections");
208
208
+
addBtn.addEventListener("click", (e) => {
209
209
+
e.stopPropagation();
210
210
+
new EditCardModal(this.plugin, record.uri, record.cid, () => {
211
211
+
this.render();
212
212
+
}).open();
198
213
});
199
214
200
215
if (card.type === "NOTE") {
+21
src/views/collections.ts
···
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";
6
8
7
9
export const VIEW_TYPE_SEMBLE_COLLECTIONS = "semble-collections-view";
8
10
···
54
56
const header = container.createEl("div", { cls: "semble-page-header" });
55
57
const nav = header.createEl("div", { cls: "semble-nav-row" });
56
58
nav.createEl("span", { text: "Semble", cls: "semble-brand" });
59
59
+
60
60
+
renderProfileIcon(nav, this.plugin.profile);
61
61
+
57
62
header.createEl("h2", { text: "Collections", cls: "semble-page-title" });
58
63
59
64
if (!this.plugin.client) {
···
66
71
container.createEl("p", { text: "No identifier configured in settings." });
67
72
return;
68
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, () => 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
+
this.plugin.activateView(VIEW_TYPE_SEMBLE_CARDS);
89
89
+
});
69
90
70
91
const loading = container.createEl("p", { text: "Loading..." });
71
92
+363
-30
styles.css
···
8
8
.semble-card {
9
9
background: var(--background-secondary);
10
10
border: 1px solid var(--background-modifier-border);
11
11
-
border-radius: 8px;
11
11
+
border-radius: var(--radius-m);
12
12
padding: 16px;
13
13
display: flex;
14
14
flex-direction: column;
15
15
gap: 8px;
16
16
-
transition: box-shadow 0.15s ease;
16
16
+
transition: box-shadow 0.15s ease, border-color 0.15s ease;
17
17
cursor: pointer;
18
18
}
19
19
20
20
.semble-card:hover {
21
21
-
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
22
22
-
border-color: var(--interactive-accent);
21
21
+
box-shadow: var(--shadow-s);
22
22
+
border-color: var(--background-modifier-border-hover);
23
23
}
24
24
25
25
.semble-card-header {
···
30
30
}
31
31
32
32
.semble-card-title {
33
33
-
font-weight: 600;
33
33
+
font-weight: var(--font-semibold);
34
34
font-size: 1.1em;
35
35
color: var(--text-normal);
36
36
}
37
37
38
38
.semble-badge {
39
39
-
font-size: 0.7em;
39
39
+
font-size: var(--font-smallest);
40
40
padding: 2px 8px;
41
41
-
border-radius: 12px;
41
41
+
border-radius: var(--radius-s);
42
42
text-transform: uppercase;
43
43
-
font-weight: 500;
43
43
+
font-weight: var(--font-medium);
44
44
flex-shrink: 0;
45
45
}
46
46
···
59
59
display: flex;
60
60
align-items: center;
61
61
justify-content: center;
62
62
-
width: 24px;
63
63
-
height: 24px;
64
64
-
border-radius: 50%;
65
62
flex-shrink: 0;
66
63
}
67
64
68
65
.semble-access-icon svg {
69
69
-
width: 14px;
70
70
-
height: 14px;
66
66
+
width: 12px;
67
67
+
height: 12px;
71
68
}
72
69
73
70
.semble-access-open {
74
74
-
background: var(--color-green);
75
75
-
color: var(--text-on-accent);
71
71
+
color: var(--color-green);
76
72
}
77
73
78
74
.semble-access-closed {
79
79
-
background: var(--color-orange);
80
80
-
color: var(--text-on-accent);
75
75
+
color: var(--color-orange);
81
76
}
82
77
83
78
.semble-card-desc {
84
79
color: var(--text-muted);
85
85
-
font-size: 0.9em;
80
80
+
font-size: var(--font-small);
86
81
margin: 0;
87
82
flex-grow: 1;
88
83
}
···
90
85
.semble-card-footer {
91
86
display: flex;
92
87
justify-content: space-between;
93
93
-
font-size: 0.8em;
88
88
+
font-size: var(--font-smallest);
94
89
color: var(--text-faint);
95
90
margin-top: auto;
96
91
padding-top: 8px;
···
127
122
}
128
123
129
124
.semble-brand {
130
130
-
font-size: 0.85em;
131
131
-
font-weight: 600;
125
125
+
font-size: var(--font-small);
126
126
+
font-weight: var(--font-semibold);
132
127
color: var(--text-accent);
133
128
text-transform: uppercase;
134
129
letter-spacing: 0.5px;
···
136
131
137
132
.semble-page-title {
138
133
margin: 0;
139
139
-
font-size: 1.8em;
140
140
-
font-weight: 700;
134
134
+
font-size: var(--h1-size);
135
135
+
font-weight: var(--font-bold);
141
136
color: var(--text-normal);
142
137
}
143
138
144
139
.semble-card-text {
145
140
margin: 0;
146
141
white-space: pre-wrap;
147
147
-
line-height: 1.5;
142
142
+
line-height: var(--line-height-normal);
143
143
+
color: var(--text-normal);
148
144
}
149
145
150
146
.semble-card-url {
151
151
-
font-size: 0.85em;
147
147
+
font-size: var(--font-small);
152
148
color: var(--text-accent);
153
149
text-decoration: none;
154
150
word-break: break-all;
···
159
155
}
160
156
161
157
.semble-card-site {
162
162
-
font-size: 0.8em;
158
158
+
font-size: var(--font-smallest);
163
159
color: var(--text-faint);
164
160
}
165
161
···
167
163
width: 100%;
168
164
max-height: 120px;
169
165
object-fit: cover;
170
170
-
border-radius: 4px;
166
166
+
border-radius: var(--radius-s);
171
167
margin: 4px 0;
172
168
}
173
169
···
181
177
padding: 0;
182
178
background: transparent;
183
179
border: 1px solid var(--background-modifier-border);
184
184
-
border-radius: 6px;
180
180
+
border-radius: var(--radius-s);
185
181
cursor: pointer;
186
182
color: var(--text-muted);
187
183
}
···
201
197
202
198
.semble-chip {
203
199
padding: 6px 14px;
204
204
-
border-radius: 16px;
200
200
+
border-radius: var(--radius-full);
205
201
border: 1px solid var(--background-modifier-border);
206
202
background: var(--background-secondary);
207
203
color: var(--text-muted);
208
208
-
font-size: 0.85em;
204
204
+
font-size: var(--font-small);
209
205
cursor: pointer;
210
206
transition: all 0.15s ease;
211
207
}
···
224
220
.semble-chip-active:hover {
225
221
background: var(--interactive-accent-hover);
226
222
}
223
223
+
224
224
+
/* Profile Icon */
225
225
+
.semble-profile-icon {
226
226
+
display: flex;
227
227
+
align-items: center;
228
228
+
gap: 10px;
229
229
+
margin-left: auto;
230
230
+
}
231
231
+
232
232
+
.semble-avatar-btn {
233
233
+
display: flex;
234
234
+
align-items: center;
235
235
+
justify-content: center;
236
236
+
width: 36px;
237
237
+
height: 36px;
238
238
+
padding: 0;
239
239
+
background: var(--background-secondary);
240
240
+
border: 2px solid var(--background-modifier-border);
241
241
+
border-radius: var(--radius-full);
242
242
+
cursor: pointer;
243
243
+
overflow: hidden;
244
244
+
transition: opacity 0.15s ease;
245
245
+
}
246
246
+
247
247
+
.semble-avatar-btn:hover {
248
248
+
opacity: 0.8;
249
249
+
}
250
250
+
251
251
+
.semble-avatar-img {
252
252
+
width: 100%;
253
253
+
height: 100%;
254
254
+
object-fit: cover;
255
255
+
border-radius: var(--radius-full);
256
256
+
}
257
257
+
258
258
+
.semble-avatar-initials {
259
259
+
font-size: var(--font-small);
260
260
+
font-weight: var(--font-semibold);
261
261
+
color: var(--text-muted);
262
262
+
}
263
263
+
264
264
+
.semble-avatar-placeholder {
265
265
+
display: flex;
266
266
+
align-items: center;
267
267
+
justify-content: center;
268
268
+
width: 36px;
269
269
+
height: 36px;
270
270
+
background: var(--background-secondary);
271
271
+
border: 2px solid var(--background-modifier-border);
272
272
+
border-radius: var(--radius-full);
273
273
+
color: var(--text-faint);
274
274
+
font-size: var(--font-small);
275
275
+
}
276
276
+
277
277
+
.semble-profile-info {
278
278
+
display: flex;
279
279
+
flex-direction: column;
280
280
+
align-items: flex-end;
281
281
+
gap: 2px;
282
282
+
}
283
283
+
284
284
+
.semble-profile-name {
285
285
+
font-size: var(--font-small);
286
286
+
font-weight: var(--font-semibold);
287
287
+
color: var(--text-normal);
288
288
+
line-height: 1.2;
289
289
+
}
290
290
+
291
291
+
.semble-profile-handle {
292
292
+
font-size: var(--font-smallest);
293
293
+
color: var(--text-muted);
294
294
+
line-height: 1.2;
295
295
+
}
296
296
+
297
297
+
/* Card Menu Button */
298
298
+
.semble-card-menu-btn {
299
299
+
display: flex;
300
300
+
align-items: center;
301
301
+
justify-content: center;
302
302
+
width: 24px;
303
303
+
height: 24px;
304
304
+
padding: 0;
305
305
+
margin-left: auto;
306
306
+
background: transparent;
307
307
+
border: none;
308
308
+
border-radius: var(--radius-s);
309
309
+
cursor: pointer;
310
310
+
color: var(--text-faint);
311
311
+
opacity: 0.6;
312
312
+
transition: all 0.15s ease;
313
313
+
}
314
314
+
315
315
+
.semble-card:hover .semble-card-menu-btn {
316
316
+
opacity: 1;
317
317
+
}
318
318
+
319
319
+
.semble-card-menu-btn:hover {
320
320
+
background: var(--background-modifier-hover);
321
321
+
color: var(--text-normal);
322
322
+
opacity: 1;
323
323
+
}
324
324
+
325
325
+
.semble-card-menu-btn svg {
326
326
+
width: 14px;
327
327
+
height: 14px;
328
328
+
}
329
329
+
330
330
+
/* Collection Modal */
331
331
+
.semble-collection-modal {
332
332
+
padding: 16px;
333
333
+
}
334
334
+
335
335
+
.semble-collection-modal h2 {
336
336
+
margin: 0 0 16px 0;
337
337
+
font-size: var(--h2-size);
338
338
+
font-weight: var(--font-semibold);
339
339
+
color: var(--text-normal);
340
340
+
}
341
341
+
342
342
+
.semble-collection-list {
343
343
+
display: flex;
344
344
+
flex-direction: column;
345
345
+
gap: 8px;
346
346
+
max-height: 300px;
347
347
+
overflow-y: auto;
348
348
+
margin-bottom: 16px;
349
349
+
}
350
350
+
351
351
+
.semble-collection-item {
352
352
+
display: flex;
353
353
+
align-items: center;
354
354
+
gap: 12px;
355
355
+
padding: 12px 16px;
356
356
+
background: var(--background-secondary);
357
357
+
border: 1px solid var(--background-modifier-border);
358
358
+
border-radius: var(--radius-m);
359
359
+
cursor: pointer;
360
360
+
transition: all 0.15s ease;
361
361
+
}
362
362
+
363
363
+
.semble-collection-item:hover {
364
364
+
background: var(--background-modifier-hover);
365
365
+
border-color: var(--background-modifier-border-hover);
366
366
+
}
367
367
+
368
368
+
.semble-collection-checkbox {
369
369
+
width: 18px;
370
370
+
height: 18px;
371
371
+
margin: 0;
372
372
+
cursor: pointer;
373
373
+
accent-color: var(--interactive-accent);
374
374
+
}
375
375
+
376
376
+
.semble-collection-item-info {
377
377
+
display: flex;
378
378
+
flex-direction: column;
379
379
+
gap: 2px;
380
380
+
flex: 1;
381
381
+
}
382
382
+
383
383
+
.semble-collection-item-name {
384
384
+
font-weight: var(--font-semibold);
385
385
+
color: var(--text-normal);
386
386
+
}
387
387
+
388
388
+
.semble-collection-item-desc {
389
389
+
font-size: var(--font-small);
390
390
+
color: var(--text-muted);
391
391
+
}
392
392
+
393
393
+
/* Modal Actions */
394
394
+
.semble-modal-actions {
395
395
+
display: flex;
396
396
+
align-items: center;
397
397
+
gap: 8px;
398
398
+
padding-top: 16px;
399
399
+
border-top: 1px solid var(--background-modifier-border);
400
400
+
}
401
401
+
402
402
+
.semble-spacer {
403
403
+
flex: 1;
404
404
+
}
405
405
+
406
406
+
.semble-btn {
407
407
+
padding: 8px 16px;
408
408
+
border-radius: var(--radius-s);
409
409
+
font-size: var(--font-small);
410
410
+
font-weight: var(--font-medium);
411
411
+
cursor: pointer;
412
412
+
transition: all 0.15s ease;
413
413
+
}
414
414
+
415
415
+
.semble-btn:disabled {
416
416
+
opacity: 0.5;
417
417
+
cursor: not-allowed;
418
418
+
}
419
419
+
420
420
+
.semble-btn-secondary {
421
421
+
background: var(--background-secondary);
422
422
+
border: 1px solid var(--background-modifier-border);
423
423
+
color: var(--text-normal);
424
424
+
}
425
425
+
426
426
+
.semble-btn-secondary:hover:not(:disabled) {
427
427
+
background: var(--background-modifier-hover);
428
428
+
}
429
429
+
430
430
+
.semble-btn-primary {
431
431
+
background: var(--interactive-accent);
432
432
+
border: 1px solid var(--interactive-accent);
433
433
+
color: var(--text-on-accent);
434
434
+
}
435
435
+
436
436
+
.semble-btn-primary:hover:not(:disabled) {
437
437
+
background: var(--interactive-accent-hover);
438
438
+
}
439
439
+
440
440
+
.semble-btn-danger {
441
441
+
background: color-mix(in srgb, var(--color-red) 15%, transparent);
442
442
+
border: none;
443
443
+
color: var(--color-red);
444
444
+
}
445
445
+
446
446
+
.semble-btn-danger:hover:not(:disabled) {
447
447
+
background: color-mix(in srgb, var(--color-red) 25%, transparent);
448
448
+
}
449
449
+
450
450
+
/* Warning text */
451
451
+
.semble-warning-text {
452
452
+
color: var(--text-muted);
453
453
+
margin-bottom: 16px;
454
454
+
}
455
455
+
456
456
+
/* Toolbar */
457
457
+
.semble-toolbar {
458
458
+
display: flex;
459
459
+
align-items: center;
460
460
+
gap: 8px;
461
461
+
margin-bottom: 16px;
462
462
+
}
463
463
+
464
464
+
.semble-create-btn {
465
465
+
display: inline-flex;
466
466
+
align-items: center;
467
467
+
gap: 6px;
468
468
+
padding: 6px 12px;
469
469
+
background: var(--interactive-accent);
470
470
+
border: none;
471
471
+
border-radius: var(--radius-s);
472
472
+
color: var(--text-on-accent);
473
473
+
font-size: var(--font-small);
474
474
+
font-weight: var(--font-medium);
475
475
+
cursor: pointer;
476
476
+
transition: all 0.15s ease;
477
477
+
}
478
478
+
479
479
+
.semble-create-btn:hover {
480
480
+
background: var(--interactive-accent-hover);
481
481
+
}
482
482
+
483
483
+
.semble-create-btn svg {
484
484
+
width: 14px;
485
485
+
height: 14px;
486
486
+
}
487
487
+
488
488
+
.semble-toolbar-btn {
489
489
+
display: inline-flex;
490
490
+
align-items: center;
491
491
+
gap: 6px;
492
492
+
padding: 6px 12px;
493
493
+
background: var(--background-secondary);
494
494
+
border: 1px solid var(--background-modifier-border);
495
495
+
border-radius: var(--radius-s);
496
496
+
color: var(--text-normal);
497
497
+
font-size: var(--font-small);
498
498
+
font-weight: var(--font-medium);
499
499
+
cursor: pointer;
500
500
+
transition: all 0.15s ease;
501
501
+
}
502
502
+
503
503
+
.semble-toolbar-btn:hover {
504
504
+
background: var(--background-modifier-hover);
505
505
+
border-color: var(--background-modifier-border-hover);
506
506
+
}
507
507
+
508
508
+
.semble-toolbar-btn svg {
509
509
+
width: 14px;
510
510
+
height: 14px;
511
511
+
}
512
512
+
513
513
+
/* Form Elements */
514
514
+
.semble-form {
515
515
+
display: flex;
516
516
+
flex-direction: column;
517
517
+
gap: 16px;
518
518
+
}
519
519
+
520
520
+
.semble-form-group {
521
521
+
display: flex;
522
522
+
flex-direction: column;
523
523
+
gap: 6px;
524
524
+
}
525
525
+
526
526
+
.semble-form-group label {
527
527
+
font-size: var(--font-small);
528
528
+
font-weight: var(--font-medium);
529
529
+
color: var(--text-normal);
530
530
+
}
531
531
+
532
532
+
.semble-input,
533
533
+
.semble-textarea {
534
534
+
padding: 8px 12px;
535
535
+
background: var(--background-primary);
536
536
+
border: 1px solid var(--background-modifier-border);
537
537
+
border-radius: var(--radius-s);
538
538
+
color: var(--text-normal);
539
539
+
font-size: var(--font-ui-medium);
540
540
+
font-family: inherit;
541
541
+
transition: border-color 0.15s ease;
542
542
+
}
543
543
+
544
544
+
.semble-input:focus,
545
545
+
.semble-textarea:focus {
546
546
+
outline: none;
547
547
+
border-color: var(--interactive-accent);
548
548
+
box-shadow: 0 0 0 2px var(--background-modifier-border-focus);
549
549
+
}
550
550
+
551
551
+
.semble-input::placeholder,
552
552
+
.semble-textarea::placeholder {
553
553
+
color: var(--text-faint);
554
554
+
}
555
555
+
556
556
+
.semble-textarea {
557
557
+
resize: vertical;
558
558
+
min-height: 60px;
559
559
+
}