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
show and add card notes
treethought
2 months ago
2b0df591
8d48f42c
+578
-23
7 changed files
expand all
collapse all
unified
split
eslint.config.mts
src
components
cardDetailModal.ts
lib.ts
main.ts
settings.ts
views
cards.ts
styles.css
+2
eslint.config.mts
···
30
30
"version-bump.mjs",
31
31
"versions.json",
32
32
"main.js",
33
33
+
"lex.config.js",
34
34
+
"src/lexicons",
33
35
]),
34
36
);
+228
src/components/cardDetailModal.ts
···
1
1
+
import { Modal, Notice, setIcon } 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
4
+
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
+
}
20
20
+
21
21
+
export class CardDetailModal extends Modal {
22
22
+
plugin: ATmarkPlugin;
23
23
+
card: CardWithNotes;
24
24
+
onSuccess?: () => void;
25
25
+
noteInput: HTMLTextAreaElement | null = null;
26
26
+
27
27
+
constructor(plugin: ATmarkPlugin, card: CardWithNotes, onSuccess?: () => void) {
28
28
+
super(plugin.app);
29
29
+
this.plugin = plugin;
30
30
+
this.card = card;
31
31
+
this.onSuccess = onSuccess;
32
32
+
}
33
33
+
34
34
+
onOpen() {
35
35
+
const { contentEl } = this;
36
36
+
contentEl.empty();
37
37
+
contentEl.addClass("semble-detail-modal");
38
38
+
39
39
+
const card = this.card.value;
40
40
+
41
41
+
// Header with type badge
42
42
+
const header = contentEl.createEl("div", { cls: "semble-detail-header" });
43
43
+
header.createEl("span", {
44
44
+
text: card.type,
45
45
+
cls: `semble-badge semble-badge-${card.type?.toLowerCase() || "unknown"}`,
46
46
+
});
47
47
+
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
+
}
53
53
+
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
+
}
72
72
+
}
73
73
+
74
74
+
// Add note form
75
75
+
this.renderAddNoteForm(contentEl);
76
76
+
77
77
+
// 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",
148
148
+
});
149
149
+
link.setAttr("target", "_blank");
150
150
+
}
151
151
+
152
152
+
private renderAddNoteForm(contentEl: HTMLElement) {
153
153
+
const formSection = contentEl.createEl("div", { cls: "semble-detail-add-note" });
154
154
+
formSection.createEl("h3", { text: "Add a note", cls: "semble-detail-section-title" });
155
155
+
156
156
+
const form = formSection.createEl("div", { cls: "semble-add-note-form" });
157
157
+
158
158
+
this.noteInput = form.createEl("textarea", {
159
159
+
cls: "semble-textarea semble-note-input",
160
160
+
attr: { placeholder: "Write a note about this card..." },
161
161
+
});
162
162
+
163
163
+
const addBtn = form.createEl("button", { text: "Add note", cls: "semble-btn semble-btn-primary" });
164
164
+
addBtn.addEventListener("click", () => { void this.handleAddNote(); });
165
165
+
}
166
166
+
167
167
+
private async handleAddNote() {
168
168
+
if (!this.plugin.client || !this.noteInput) return;
169
169
+
170
170
+
const text = this.noteInput.value.trim();
171
171
+
if (!text) {
172
172
+
new Notice("Please enter a note");
173
173
+
return;
174
174
+
}
175
175
+
176
176
+
try {
177
177
+
await createNoteCard(
178
178
+
this.plugin.client,
179
179
+
this.plugin.settings.identifier,
180
180
+
text,
181
181
+
{ uri: this.card.uri, cid: this.card.cid }
182
182
+
);
183
183
+
184
184
+
new Notice("Note added");
185
185
+
this.close();
186
186
+
this.onSuccess?.();
187
187
+
} catch (err) {
188
188
+
const message = err instanceof Error ? err.message : String(err);
189
189
+
new Notice(`Failed to add note: ${message}`);
190
190
+
}
191
191
+
}
192
192
+
193
193
+
private async handleDeleteNote(noteUri: string) {
194
194
+
if (!this.plugin.client) return;
195
195
+
196
196
+
const rkey = noteUri.split("/").pop();
197
197
+
if (!rkey) {
198
198
+
new Notice("Invalid note uri");
199
199
+
return;
200
200
+
}
201
201
+
202
202
+
try {
203
203
+
await deleteRecord(
204
204
+
this.plugin.client,
205
205
+
this.plugin.settings.identifier,
206
206
+
"network.cosmik.card",
207
207
+
rkey
208
208
+
);
209
209
+
210
210
+
new Notice("Note deleted");
211
211
+
this.close();
212
212
+
this.onSuccess?.();
213
213
+
} catch (err) {
214
214
+
const message = err instanceof Error ? err.message : String(err);
215
215
+
new Notice(`Failed to delete note: ${message}`);
216
216
+
}
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
223
+
}
224
224
+
225
225
+
onClose() {
226
226
+
this.contentEl.empty();
227
227
+
}
228
228
+
}
+45
src/lib.ts
···
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
+
38
83
export async function getCards(client: Client, repo: string) {
39
84
return await client.get("com.atproto.repo.listRecords", {
40
85
params: {
+15
-14
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, getProfile } from "./lib";
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";
8
8
import { CreateCardModal } from "components/cardForm";
···
26
26
});
27
27
this.addCommand({
28
28
id: 'semble-add-card',
29
29
-
name: 'Create Semble Card',
29
29
+
name: 'Create semble card',
30
30
editorCheckCallback: (checking: boolean, editor: Editor, _view: MarkdownView) => {
31
31
const sel = editor.getSelection()
32
32
33
33
if (!this.settings.identifier || !this.settings.appPassword) {
34
34
-
new Notice("Please set your Bluesky credentials in the plugin settings to create new records.");
34
34
+
new Notice("Please set your credentials in the plugin settings to create new records.");
35
35
return false;
36
36
}
37
37
if (!checking) {
···
45
45
46
46
this.addCommand({
47
47
id: "view-semble-collections",
48
48
-
name: "View Semble Collections",
49
49
-
callback: () => this.activateView(VIEW_TYPE_SEMBLE_COLLECTIONS),
48
48
+
name: "View semble collections",
49
49
+
callback: () => { void this.activateView(VIEW_TYPE_SEMBLE_COLLECTIONS); },
50
50
});
51
51
52
52
this.addCommand({
53
53
id: "view-semble-cards",
54
54
-
name: "View Semble Cards",
55
55
-
callback: () => this.activateView(VIEW_TYPE_SEMBLE_CARDS),
54
54
+
name: "View semble cards",
55
55
+
callback: () => { void this.activateView(VIEW_TYPE_SEMBLE_CARDS); },
56
56
});
57
57
58
58
this.addSettingTab(new SettingTab(this.app, this));
···
65
65
try {
66
66
this.client = await createAuthenticatedClient({ identifier, password: appPassword });
67
67
await this.fetchProfile();
68
68
-
new Notice("Connected to Bluesky");
69
69
-
} catch (e) {
70
70
-
new Notice(`Auth failed: ${e}`);
68
68
+
new Notice("Connected");
69
69
+
} catch (err) {
70
70
+
const message = err instanceof Error ? err.message : String(err);
71
71
+
new Notice(`Auth failed: ${message}`);
71
72
this.client = createPublicClient();
72
73
this.profile = null;
73
74
}
···
114
115
if (leaves.length > 0) {
115
116
// A leaf with our view already exists, use that
116
117
leaf = leaves[0] as WorkspaceLeaf;
117
117
-
workspace.revealLeaf(leaf);
118
118
+
void workspace.revealLeaf(leaf);
118
119
return;
119
120
}
120
121
···
126
127
127
128
// "Reveal" the leaf in case it is in a collapsed sidebar
128
129
if (leaf) {
129
129
-
workspace.revealLeaf(leaf);
130
130
+
void workspace.revealLeaf(leaf);
130
131
}
131
132
}
132
133
···
138
139
const view = leaf.view as SembleCardsView;
139
140
view.setCollection(uri, name);
140
141
141
141
-
workspace.revealLeaf(leaf);
142
142
+
void workspace.revealLeaf(leaf);
142
143
}
143
144
144
145
async loadSettings() {
145
145
-
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
146
146
+
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData() as Partial<AtProtoSettings>);
146
147
}
147
148
148
149
async saveSettings() {
+7
-3
src/settings.ts
···
24
24
containerEl.empty();
25
25
26
26
new Setting(containerEl)
27
27
+
// eslint-disable-next-line obsidianmd/ui/sentence-case
27
28
.setName("Handle or DID")
28
28
-
.setDesc("Your Bluesky handle (e.g., user.bsky.social) or DID")
29
29
+
.setDesc("Your handle or did (e.g., user.bsky.social)")
29
30
.addText((text) =>
30
31
text
32
32
+
// eslint-disable-next-line obsidianmd/ui/sentence-case
31
33
.setPlaceholder("user.bsky.social")
32
34
.setValue(this.plugin.settings.identifier)
33
35
.onChange(async (value) => {
···
37
39
);
38
40
39
41
new Setting(containerEl)
40
40
-
.setName("App Password")
41
41
-
.setDesc("Create one at Settings → Privacy and Security → App Passwords")
42
42
+
.setName("App password")
43
43
+
// eslint-disable-next-line obsidianmd/ui/sentence-case
44
44
+
.setDesc("Create one at Settings → Privacy and security → App passwords")
42
45
.addText((text) => {
43
46
text.inputEl.type = "password";
44
47
text
48
48
+
// eslint-disable-next-line obsidianmd/ui/sentence-case
45
49
.setPlaceholder("xxxx-xxxx-xxxx-xxxx")
46
50
.setValue(this.plugin.settings.appPassword)
47
51
.onChange(async (value) => {
+69
-6
src/views/cards.ts
···
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";
10
11
11
12
export const VIEW_TYPE_SEMBLE_CARDS = "semble-cards-view";
12
13
···
14
15
uri: string;
15
16
cid: string;
16
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[];
17
27
}
18
28
19
29
interface CollectionLinkRecord {
···
58
68
await this.render();
59
69
}
60
70
61
61
-
async getAllCards() {
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[]> {
62
110
if (!this.plugin.client) return [];
63
111
64
112
const repo = this.plugin.settings.identifier;
65
113
const cardsResp = await getCards(this.plugin.client, repo);
66
114
if (!cardsResp.ok) return [];
67
67
-
return cardsResp.data.records as unknown as CardRecord[];
115
115
+
const cards = cardsResp.data.records as unknown as CardRecord[];
116
116
+
return this.processCardsWithNotes(cards);
68
117
}
69
118
70
70
-
async getCardsInCollection(collectionUri: string) {
119
119
+
async getCardsInCollection(collectionUri: string): Promise<CardWithNotes[]> {
71
120
if (!this.plugin.client) return [];
72
121
73
122
const repo = this.plugin.settings.identifier;
···
87
136
const cardUris = new Set(links.map((link) => String(link.value.card.uri)));
88
137
const cards = allCards.filter((card) => cardUris.has(String(card.uri)));
89
138
90
90
-
return cards;
139
139
+
return this.processCardsWithNotes(cards);
91
140
}
92
141
93
142
async render() {
···
104
153
105
154
try {
106
155
107
107
-
let cards: CardRecord[] = [];
156
156
+
let cards: CardWithNotes[] = [];
108
157
try {
109
158
if (this.collectionUri) {
110
159
cards = await this.getCardsInCollection(this.collectionUri);
···
195
244
}
196
245
}
197
246
198
198
-
private renderCard(container: HTMLElement, record: CardRecord) {
247
247
+
private renderCard(container: HTMLElement, record: CardWithNotes) {
199
248
const card = record.value;
200
249
const el = container.createEl("div", { cls: "semble-card" });
201
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
+
202
258
const header = el.createEl("div", { cls: "semble-card-header" });
203
259
header.createEl("span", {
204
260
text: card.type,
···
214
270
void this.render();
215
271
}).open();
216
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
+
}
217
280
218
281
if (card.type === "NOTE") {
219
282
const content = card.content as NoteContent;
+212
styles.css
···
143
143
color: var(--text-normal);
144
144
}
145
145
146
146
+
.semble-card-note {
147
147
+
margin: 0;
148
148
+
padding: 8px 12px;
149
149
+
background: var(--background-primary);
150
150
+
border-left: 3px solid var(--color-blue);
151
151
+
border-radius: var(--radius-s);
152
152
+
font-size: var(--font-small);
153
153
+
font-style: italic;
154
154
+
color: var(--text-muted);
155
155
+
white-space: pre-wrap;
156
156
+
line-height: var(--line-height-normal);
157
157
+
}
158
158
+
146
159
.semble-card-url {
147
160
font-size: var(--font-small);
148
161
color: var(--text-accent);
···
557
570
resize: vertical;
558
571
min-height: 60px;
559
572
}
573
573
+
574
574
+
/* Card Detail Modal */
575
575
+
.semble-detail-modal {
576
576
+
padding: 20px;
577
577
+
max-width: 600px;
578
578
+
}
579
579
+
580
580
+
.semble-detail-header {
581
581
+
margin-bottom: 16px;
582
582
+
}
583
583
+
584
584
+
.semble-detail-body {
585
585
+
display: flex;
586
586
+
flex-direction: column;
587
587
+
gap: 16px;
588
588
+
}
589
589
+
590
590
+
.semble-detail-title {
591
591
+
margin: 0;
592
592
+
font-size: var(--h2-size);
593
593
+
font-weight: var(--font-semibold);
594
594
+
color: var(--text-normal);
595
595
+
line-height: 1.3;
596
596
+
}
597
597
+
598
598
+
.semble-detail-image {
599
599
+
max-width: 100%;
600
600
+
max-height: 200px;
601
601
+
object-fit: contain;
602
602
+
border-radius: var(--radius-m);
603
603
+
}
604
604
+
605
605
+
.semble-detail-description {
606
606
+
margin: 0;
607
607
+
color: var(--text-normal);
608
608
+
line-height: var(--line-height-normal);
609
609
+
}
610
610
+
611
611
+
.semble-detail-text {
612
612
+
margin: 0;
613
613
+
white-space: pre-wrap;
614
614
+
line-height: var(--line-height-normal);
615
615
+
color: var(--text-normal);
616
616
+
font-size: 1.1em;
617
617
+
}
618
618
+
619
619
+
.semble-detail-meta {
620
620
+
display: grid;
621
621
+
grid-template-columns: repeat(2, 1fr);
622
622
+
gap: 12px;
623
623
+
padding: 16px;
624
624
+
background: var(--background-secondary);
625
625
+
border-radius: var(--radius-m);
626
626
+
}
627
627
+
628
628
+
.semble-detail-meta-item {
629
629
+
display: flex;
630
630
+
flex-direction: column;
631
631
+
gap: 2px;
632
632
+
}
633
633
+
634
634
+
.semble-detail-meta-label {
635
635
+
font-size: var(--font-smallest);
636
636
+
color: var(--text-faint);
637
637
+
text-transform: uppercase;
638
638
+
letter-spacing: 0.5px;
639
639
+
}
640
640
+
641
641
+
.semble-detail-meta-value {
642
642
+
font-size: var(--font-small);
643
643
+
color: var(--text-normal);
644
644
+
}
645
645
+
646
646
+
.semble-detail-link-wrapper {
647
647
+
padding-top: 8px;
648
648
+
}
649
649
+
650
650
+
.semble-detail-link {
651
651
+
font-size: var(--font-small);
652
652
+
color: var(--text-accent);
653
653
+
text-decoration: none;
654
654
+
word-break: break-all;
655
655
+
}
656
656
+
657
657
+
.semble-detail-link:hover {
658
658
+
text-decoration: underline;
659
659
+
}
660
660
+
661
661
+
.semble-detail-notes-section {
662
662
+
margin-top: 20px;
663
663
+
padding-top: 20px;
664
664
+
border-top: 1px solid var(--background-modifier-border);
665
665
+
}
666
666
+
667
667
+
.semble-detail-section-title {
668
668
+
margin: 0 0 12px 0;
669
669
+
font-size: var(--font-small);
670
670
+
font-weight: var(--font-semibold);
671
671
+
color: var(--text-muted);
672
672
+
text-transform: uppercase;
673
673
+
letter-spacing: 0.5px;
674
674
+
}
675
675
+
676
676
+
.semble-detail-note {
677
677
+
display: flex;
678
678
+
align-items: flex-start;
679
679
+
justify-content: space-between;
680
680
+
gap: 12px;
681
681
+
padding: 12px 16px;
682
682
+
background: var(--background-secondary);
683
683
+
border-left: 3px solid var(--color-blue);
684
684
+
border-radius: var(--radius-s);
685
685
+
margin-bottom: 8px;
686
686
+
}
687
687
+
688
688
+
.semble-detail-note-content {
689
689
+
display: flex;
690
690
+
gap: 12px;
691
691
+
flex: 1;
692
692
+
min-width: 0;
693
693
+
}
694
694
+
695
695
+
.semble-detail-note-icon {
696
696
+
flex-shrink: 0;
697
697
+
color: var(--color-blue);
698
698
+
}
699
699
+
700
700
+
.semble-detail-note-icon svg {
701
701
+
width: 16px;
702
702
+
height: 16px;
703
703
+
}
704
704
+
705
705
+
.semble-detail-note-text {
706
706
+
margin: 0;
707
707
+
color: var(--text-normal);
708
708
+
line-height: var(--line-height-normal);
709
709
+
white-space: pre-wrap;
710
710
+
}
711
711
+
712
712
+
.semble-note-delete-btn {
713
713
+
display: flex;
714
714
+
align-items: center;
715
715
+
justify-content: center;
716
716
+
width: 28px;
717
717
+
height: 28px;
718
718
+
padding: 0;
719
719
+
flex-shrink: 0;
720
720
+
background: transparent;
721
721
+
border: none;
722
722
+
border-radius: var(--radius-s);
723
723
+
cursor: pointer;
724
724
+
color: var(--text-faint);
725
725
+
opacity: 0.6;
726
726
+
transition: all 0.15s ease;
727
727
+
}
728
728
+
729
729
+
.semble-note-delete-btn:hover {
730
730
+
background: color-mix(in srgb, var(--color-red) 15%, transparent);
731
731
+
color: var(--color-red);
732
732
+
opacity: 1;
733
733
+
}
734
734
+
735
735
+
.semble-note-delete-btn svg {
736
736
+
width: 14px;
737
737
+
height: 14px;
738
738
+
}
739
739
+
740
740
+
.semble-detail-footer {
741
741
+
margin-top: 20px;
742
742
+
padding-top: 16px;
743
743
+
border-top: 1px solid var(--background-modifier-border);
744
744
+
}
745
745
+
746
746
+
.semble-detail-date {
747
747
+
font-size: var(--font-small);
748
748
+
color: var(--text-faint);
749
749
+
}
750
750
+
751
751
+
/* Add Note Form */
752
752
+
.semble-detail-add-note {
753
753
+
margin-top: 20px;
754
754
+
padding-top: 20px;
755
755
+
border-top: 1px solid var(--background-modifier-border);
756
756
+
}
757
757
+
758
758
+
.semble-add-note-form {
759
759
+
display: flex;
760
760
+
flex-direction: column;
761
761
+
gap: 12px;
762
762
+
}
763
763
+
764
764
+
.semble-note-input {
765
765
+
min-height: 80px;
766
766
+
resize: vertical;
767
767
+
}
768
768
+
769
769
+
.semble-add-note-form .semble-btn {
770
770
+
align-self: flex-end;
771
771
+
}