Various AT Protocol integrations with obsidian
1import { Modal, Notice, setIcon } from "obsidian";
2import type AtmospherePlugin from "../main";
3import { createSembleNote, deleteRecord } from "../lib";
4import type { ATBookmarkItem } from "../sources/types";
5import { EditItemModal } from "./editItemModal";
6
7export class CardDetailModal extends Modal {
8 plugin: AtmospherePlugin;
9 item: ATBookmarkItem;
10 onSuccess?: () => void;
11 noteInput: HTMLTextAreaElement | null = null;
12
13 constructor(plugin: AtmospherePlugin, item: ATBookmarkItem, onSuccess?: () => void) {
14 super(plugin.app);
15 this.plugin = plugin;
16 this.item = item;
17 this.onSuccess = onSuccess;
18 }
19
20 onOpen() {
21 const { contentEl } = this;
22 contentEl.empty();
23 contentEl.addClass("atmosphere-detail-modal");
24
25 void this.renderBody(contentEl);
26
27 const collections = this.item.getCollections();
28 if (collections.length > 0) {
29 this.renderCollectionsSection(contentEl, collections);
30 }
31
32 if (this.item.canAddTags()) {
33 this.renderTagsSection(contentEl);
34 }
35
36 if (this.item.canAddNotes() && this.item.getAttachedNotes) {
37 this.renderNotesSection(contentEl);
38 }
39
40 if (this.item.canAddNotes()) {
41 this.renderAddNoteForm(contentEl);
42 }
43
44 const footer = contentEl.createEl("div", { cls: "atmosphere-detail-footer" });
45 const footerLeft = footer.createEl("div", { cls: "atmosphere-detail-footer-left" });
46 const source = this.item.getSource();
47 const sourceBadge = footerLeft.createEl("span", { cls: `atmosphere-badge atmosphere-badge-${source}` });
48 setIcon(sourceBadge, sourceIconId(source));
49 footerLeft.createEl("span", {
50 text: `Created ${new Date(this.item.getCreatedAt()).toLocaleDateString()}`,
51 cls: "atmosphere-detail-date",
52 });
53
54 if (this.item.canEdit()) {
55 const editBtn = footer.createEl("button", { cls: "atmosphere-detail-edit-btn" });
56 setIcon(editBtn, "pencil");
57 editBtn.addEventListener("click", () => {
58 this.close();
59 new EditItemModal(this.plugin, this.item, this.onSuccess).open();
60 });
61 }
62 }
63
64 private async renderBody(contentEl: HTMLElement) {
65 const body = contentEl.createEl("div", { cls: "atmosphere-detail-body" });
66
67 const title = this.item.getTitle();
68 if (title) {
69 body.createEl("h2", { text: title, cls: "atmosphere-detail-title" });
70 }
71
72 const imageUrl = await this.item.getImageUrl();
73 if (imageUrl) {
74 const img = body.createEl("img", { cls: "atmosphere-detail-image" });
75 img.src = imageUrl;
76 img.alt = title || "Image";
77 }
78
79 const description = this.item.getDescription();
80 if (description) {
81 body.createEl("p", { text: description, cls: "atmosphere-detail-description" });
82 }
83
84 const siteName = this.item.getSiteName();
85 if (siteName) {
86 const metaGrid = body.createEl("div", { cls: "atmosphere-detail-meta" });
87 const metaItem = metaGrid.createEl("div", { cls: "atmosphere-detail-meta-item" });
88 metaItem.createEl("span", { text: "Site", cls: "atmosphere-detail-meta-label" });
89 metaItem.createEl("span", { text: siteName, cls: "atmosphere-detail-meta-value" });
90 }
91
92 const url = this.item.getUrl();
93 if (url) {
94 const linkWrapper = body.createEl("div", { cls: "atmosphere-detail-link-wrapper" });
95 const link = linkWrapper.createEl("a", {
96 text: url,
97 href: url,
98 cls: "atmosphere-detail-link",
99 });
100 link.setAttr("target", "_blank");
101 }
102 }
103
104 private renderTagsSection(contentEl: HTMLElement) {
105 const tags = this.item.getTags();
106 if (tags.length === 0) return;
107 const section = contentEl.createEl("div", { cls: "atmosphere-detail-tags" });
108 section.createEl("h3", { text: "Tags", cls: "atmosphere-detail-section-title" });
109 const container = section.createEl("div", { cls: "atmosphere-item-tags" });
110 for (const tag of tags) {
111 container.createEl("span", { text: tag, cls: "atmosphere-tag" });
112 }
113 }
114
115 private renderCollectionsSection(contentEl: HTMLElement, collections: Array<{ uri: string; name: string; source: string }>) {
116 const section = contentEl.createEl("div", { cls: "atmosphere-detail-collections" });
117 section.createEl("span", { text: "In collections", cls: "atmosphere-detail-collections-label" });
118 const badges = section.createEl("div", { cls: "atmosphere-detail-collections-badges" });
119 for (const collection of collections) {
120 const badge = badges.createEl("span", { cls: "atmosphere-collection" });
121 const iconEl = badge.createEl("span", { cls: "atmosphere-collection-source-icon" });
122 setIcon(iconEl, sourceIconId(collection.source as "semble" | "bookmark" | "margin"));
123 badge.createEl("span", { text: collection.name });
124 }
125 }
126
127 private renderNotesSection(contentEl: HTMLElement) {
128 const notes = this.item.getAttachedNotes?.();
129 if (!notes || notes.length === 0) return;
130
131 const notesSection = contentEl.createEl("div", { cls: "atmosphere-semble-detail-notes-section" });
132 notesSection.createEl("h3", { text: "Notes", cls: "atmosphere-detail-section-title" });
133
134 for (const note of notes) {
135 const noteEl = notesSection.createEl("div", { cls: "atmosphere-semble-detail-note" });
136
137 const noteContent = noteEl.createEl("div", { cls: "atmosphere-semble-detail-note-content" });
138 const noteIcon = noteContent.createEl("span", { cls: "atmosphere-semble-detail-note-icon" });
139 setIcon(noteIcon, "message-square");
140 noteContent.createEl("p", { text: note.text, cls: "atmosphere-semble-detail-note-text" });
141
142 const deleteBtn = noteEl.createEl("button", { cls: "atmosphere-semble-note-delete-btn" });
143 setIcon(deleteBtn, "trash-2");
144 deleteBtn.addEventListener("click", () => {
145 void this.handleDeleteNote(note.uri);
146 });
147 }
148 }
149
150 private renderAddNoteForm(contentEl: HTMLElement) {
151 const formSection = contentEl.createEl("div", { cls: "atmosphere-semble-detail-add-note" });
152 formSection.createEl("h3", { text: "Add a note", cls: "atmosphere-detail-section-title" });
153
154 const form = formSection.createEl("div", { cls: "atmosphere-semble-add-note-form" });
155
156 this.noteInput = form.createEl("textarea", {
157 cls: "atmosphere-textarea atmosphere-semble-note-input",
158 attr: { placeholder: "Write a note about this item..." },
159 });
160
161 const addBtn = form.createEl("button", { text: "Add note", cls: "atmosphere-btn atmosphere-btn-primary" });
162 addBtn.addEventListener("click", () => { void this.handleAddNote(); });
163 }
164
165 private async handleAddNote() {
166 if (!this.plugin.client.loggedIn || !this.noteInput) return;
167
168 const text = this.noteInput.value.trim();
169 if (!text) {
170 new Notice("Please enter a note");
171 return;
172 }
173
174 try {
175 await createSembleNote(
176 this.plugin.client,
177 this.plugin.settings.did!,
178 text,
179 { uri: this.item.getUri(), cid: this.item.getCid() }
180 );
181
182 new Notice("Note added");
183 this.close();
184 this.onSuccess?.();
185 } catch (err) {
186 const message = err instanceof Error ? err.message : String(err);
187 new Notice(`Failed to add note: ${message}`);
188 }
189 }
190
191 private async handleDeleteNote(noteUri: string) {
192 if (!this.plugin.client.loggedIn) return;
193
194 const rkey = noteUri.split("/").pop();
195 if (!rkey) {
196 new Notice("Invalid note uri");
197 return;
198 }
199
200 try {
201 await deleteRecord(
202 this.plugin.client,
203 this.plugin.settings.did!,
204 "network.cosmik.card",
205 rkey
206 );
207
208 new Notice("Note deleted");
209 this.close();
210 this.onSuccess?.();
211 } catch (err) {
212 const message = err instanceof Error ? err.message : String(err);
213 new Notice(`Failed to delete note: ${message}`);
214 }
215 }
216
217 onClose() {
218 this.contentEl.empty();
219 }
220}
221
222function sourceIconId(source: "semble" | "bookmark" | "margin"): string {
223 if (source === "semble") return "atmosphere-semble";
224 if (source === "margin") return "atmosphere-margin";
225 return "bookmark";
226}