Various AT Protocol integrations with obsidian
20
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor for semble and margin interop / multi filtering (#27)

* enable multiple bookmark sources

* cleanup imports and return collections/tags from datasource interface

* render unified filters

* simplify/consolidate filters, multi filter select

* track item collecitons in view

* render collection/count on card

* refactor render into view

* fix all filter

* refactor card detial rendering

* add edit button to detial modal

* add icons

* add icons

* better styling of filters

* fix tag filtering

* unify create collection modal

* add source icon to filter menus

* add icon to chips, fix bookmark colleciton filtering

* cleanup

* bump version

authored by

treethought and committed by
GitHub
3cdc3447 ada3685d

+929 -720
+1 -1
manifest.json
··· 1 1 { 2 2 "id": "atmosphere", 3 3 "name": "Atmosphere", 4 - "version": "0.1.16", 4 + "version": "0.1.17", 5 5 "minAppVersion": "0.15.0", 6 6 "description": "Various integrations with AT Protocol.", 7 7 "author": "treethought",
+1 -1
package.json
··· 1 1 { 2 2 "name": "obsidian-atmosphere", 3 - "version": "0.1.16", 3 + "version": "0.1.17", 4 4 "description": "Various integrations with AT Protocol.", 5 5 "main": "main.js", 6 6 "type": "module",
+94 -11
src/components/cardDetailModal.ts
··· 1 1 import { Modal, Notice, setIcon } from "obsidian"; 2 2 import type AtmospherePlugin from "../main"; 3 - import { createNoteCard, deleteRecord } from "../lib"; 3 + import { createSembleNote, deleteRecord } from "../lib"; 4 4 import type { ATBookmarkItem } from "../sources/types"; 5 5 6 6 export class CardDetailModal extends Modal { ··· 21 21 contentEl.empty(); 22 22 contentEl.addClass("atmosphere-detail-modal"); 23 23 24 - const header = contentEl.createEl("div", { cls: "atmosphere-detail-header" }); 25 - const source = this.item.getSource(); 26 - header.createEl("span", { 27 - text: source, 28 - cls: `atmosphere-badge atmosphere-badge-source atmosphere-badge-${source}`, 29 - }); 24 + this.renderBody(contentEl); 25 + 26 + const collections = this.item.getCollections(); 27 + if (collections.length > 0) { 28 + this.renderCollectionsSection(contentEl, collections); 29 + } 30 30 31 - this.item.renderDetail(contentEl); 31 + if (this.item.canAddTags()) { 32 + this.renderTagsSection(contentEl); 33 + } 32 34 33 - // semble 34 35 if (this.item.canAddNotes() && this.item.getAttachedNotes) { 35 36 this.renderNotesSection(contentEl); 36 37 } ··· 40 41 } 41 42 42 43 const footer = contentEl.createEl("div", { cls: "atmosphere-detail-footer" }); 43 - footer.createEl("span", { 44 + const footerLeft = footer.createEl("div", { cls: "atmosphere-detail-footer-left" }); 45 + const source = this.item.getSource(); 46 + const sourceBadge = footerLeft.createEl("span", { cls: `atmosphere-badge atmosphere-badge-${source}` }); 47 + setIcon(sourceBadge, sourceIconId(source)); 48 + footerLeft.createEl("span", { 44 49 text: `Created ${new Date(this.item.getCreatedAt()).toLocaleDateString()}`, 45 50 cls: "atmosphere-detail-date", 46 51 }); 52 + 53 + if (this.item.canEdit()) { 54 + const editBtn = footer.createEl("button", { cls: "atmosphere-detail-edit-btn" }); 55 + setIcon(editBtn, "pencil"); 56 + editBtn.addEventListener("click", () => { 57 + this.close(); 58 + this.item.openEditModal(this.onSuccess); 59 + }); 60 + } 61 + } 62 + 63 + private renderBody(contentEl: HTMLElement) { 64 + const body = contentEl.createEl("div", { cls: "atmosphere-detail-body" }); 65 + 66 + const title = this.item.getTitle(); 67 + if (title) { 68 + body.createEl("h2", { text: title, cls: "atmosphere-detail-title" }); 69 + } 70 + 71 + const imageUrl = this.item.getImageUrl(); 72 + if (imageUrl) { 73 + const img = body.createEl("img", { cls: "atmosphere-detail-image" }); 74 + img.src = imageUrl; 75 + img.alt = title || "Image"; 76 + } 77 + 78 + const description = this.item.getDescription(); 79 + if (description) { 80 + body.createEl("p", { text: description, cls: "atmosphere-detail-description" }); 81 + } 82 + 83 + const siteName = this.item.getSiteName(); 84 + if (siteName) { 85 + const metaGrid = body.createEl("div", { cls: "atmosphere-detail-meta" }); 86 + const metaItem = metaGrid.createEl("div", { cls: "atmosphere-detail-meta-item" }); 87 + metaItem.createEl("span", { text: "Site", cls: "atmosphere-detail-meta-label" }); 88 + metaItem.createEl("span", { text: siteName, cls: "atmosphere-detail-meta-value" }); 89 + } 90 + 91 + const url = this.item.getUrl(); 92 + if (url) { 93 + const linkWrapper = body.createEl("div", { cls: "atmosphere-detail-link-wrapper" }); 94 + const link = linkWrapper.createEl("a", { 95 + text: url, 96 + href: url, 97 + cls: "atmosphere-detail-link", 98 + }); 99 + link.setAttr("target", "_blank"); 100 + } 101 + } 102 + 103 + private renderTagsSection(contentEl: HTMLElement) { 104 + const tags = this.item.getTags(); 105 + if (tags.length === 0) return; 106 + const section = contentEl.createEl("div", { cls: "atmosphere-detail-tags" }); 107 + section.createEl("h3", { text: "Tags", cls: "atmosphere-detail-section-title" }); 108 + const container = section.createEl("div", { cls: "atmosphere-item-tags" }); 109 + for (const tag of tags) { 110 + container.createEl("span", { text: tag, cls: "atmosphere-tag" }); 111 + } 112 + } 113 + 114 + private renderCollectionsSection(contentEl: HTMLElement, collections: Array<{ uri: string; name: string; source: string }>) { 115 + const section = contentEl.createEl("div", { cls: "atmosphere-detail-collections" }); 116 + section.createEl("span", { text: "In collections", cls: "atmosphere-detail-collections-label" }); 117 + const badges = section.createEl("div", { cls: "atmosphere-detail-collections-badges" }); 118 + for (const collection of collections) { 119 + const badge = badges.createEl("span", { cls: "atmosphere-collection" }); 120 + const iconEl = badge.createEl("span", { cls: "atmosphere-collection-source-icon" }); 121 + setIcon(iconEl, sourceIconId(collection.source as "semble" | "bookmark" | "margin")); 122 + badge.createEl("span", { text: collection.name }); 123 + } 47 124 } 48 125 49 126 private renderNotesSection(contentEl: HTMLElement) { ··· 94 171 } 95 172 96 173 try { 97 - await createNoteCard( 174 + await createSembleNote( 98 175 this.plugin.client, 99 176 this.plugin.settings.did!, 100 177 text, ··· 140 217 this.contentEl.empty(); 141 218 } 142 219 } 220 + 221 + function sourceIconId(source: "semble" | "bookmark" | "margin"): string { 222 + if (source === "semble") return "atmosphere-semble"; 223 + if (source === "margin") return "atmosphere-margin"; 224 + return "bookmark"; 225 + }
+55 -19
src/components/createCollectionModal.ts
··· 1 1 import { Modal, Notice } from "obsidian"; 2 2 import type AtmospherePlugin from "../main"; 3 - import { createCollection } from "../lib"; 3 + import { createSembleCollection, createMarginCollection } from "../lib"; 4 + 5 + type SourceName = "semble" | "margin"; 4 6 5 7 export class CreateCollectionModal extends Modal { 6 8 plugin: AtmospherePlugin; 9 + availableSources: SourceName[]; 10 + selectedSource: SourceName; 7 11 onSuccess?: () => void; 8 12 9 - constructor(plugin: AtmospherePlugin, onSuccess?: () => void) { 13 + constructor(plugin: AtmospherePlugin, availableSources: SourceName[], onSuccess?: () => void) { 10 14 super(plugin.app); 11 15 this.plugin = plugin; 16 + this.availableSources = availableSources; 17 + this.selectedSource = availableSources[0]!; 12 18 this.onSuccess = onSuccess; 13 19 } 14 20 15 - onOpen() { 21 + onOpen() { this.render(); } 22 + 23 + private render() { 16 24 const { contentEl } = this; 17 25 contentEl.empty(); 18 26 contentEl.addClass("atmosphere-modal"); 19 - 20 27 contentEl.createEl("h2", { text: "New collection" }); 21 28 22 29 if (!this.plugin.client) { ··· 24 31 return; 25 32 } 26 33 34 + if (this.availableSources.length > 1) { 35 + const toggleRow = contentEl.createEl("div", { cls: "atmosphere-source-toggle-row" }); 36 + for (const source of this.availableSources) { 37 + const btn = toggleRow.createEl("button", { 38 + text: source.charAt(0).toUpperCase() + source.slice(1), 39 + cls: "atmosphere-source-toggle-btn" + (this.selectedSource === source ? " is-active" : ""), 40 + type: "button", 41 + }); 42 + btn.addEventListener("click", () => { this.selectedSource = source; this.render(); }); 43 + } 44 + } 45 + 27 46 const form = contentEl.createEl("form", { cls: "atmosphere-form" }); 28 47 29 48 const nameGroup = form.createEl("div", { cls: "atmosphere-form-group" }); ··· 34 53 attr: { id: "collection-name", placeholder: "Collection name", required: "true" }, 35 54 }); 36 55 56 + let iconInput: HTMLInputElement | null = null; 57 + if (this.selectedSource === "margin") { 58 + const iconGroup = form.createEl("div", { cls: "atmosphere-form-group" }); 59 + iconGroup.createEl("label", { text: "Icon (optional)", attr: { for: "collection-icon" } }); 60 + iconInput = iconGroup.createEl("input", { 61 + type: "text", 62 + cls: "atmosphere-input", 63 + attr: { id: "collection-icon" }, 64 + }); 65 + } 66 + 37 67 const descGroup = form.createEl("div", { cls: "atmosphere-form-group" }); 38 68 descGroup.createEl("label", { text: "Description", attr: { for: "collection-desc" } }); 39 69 const descInput = descGroup.createEl("textarea", { ··· 42 72 }); 43 73 44 74 const actions = form.createEl("div", { cls: "atmosphere-modal-actions" }); 45 - 46 - const cancelBtn = actions.createEl("button", { 75 + actions.createEl("button", { 47 76 text: "Cancel", 48 77 cls: "atmosphere-btn atmosphere-btn-secondary", 49 78 type: "button", 50 - }); 51 - cancelBtn.addEventListener("click", () => this.close()); 79 + }).addEventListener("click", () => this.close()); 52 80 53 81 const createBtn = actions.createEl("button", { 54 82 text: "Create", ··· 58 86 59 87 form.addEventListener("submit", (e) => { 60 88 e.preventDefault(); 61 - void this.handleSubmit(nameInput, descInput, createBtn); 89 + void this.handleSubmit(nameInput, iconInput, descInput, createBtn); 62 90 }); 63 91 64 92 nameInput.focus(); ··· 66 94 67 95 private async handleSubmit( 68 96 nameInput: HTMLInputElement, 97 + iconInput: HTMLInputElement | null, 69 98 descInput: HTMLTextAreaElement, 70 99 createBtn: HTMLButtonElement 71 100 ) { ··· 79 108 createBtn.textContent = "Creating..."; 80 109 81 110 try { 82 - await createCollection( 83 - this.plugin.client, 84 - this.plugin.settings.did!, 85 - name, 86 - descInput.value.trim() 87 - ); 88 - 111 + if (this.selectedSource === "margin") { 112 + await createMarginCollection( 113 + this.plugin.client, 114 + this.plugin.settings.did!, 115 + name, 116 + descInput.value.trim() || undefined, 117 + iconInput?.value.trim() || undefined 118 + ); 119 + } else { 120 + await createSembleCollection( 121 + this.plugin.client, 122 + this.plugin.settings.did!, 123 + name, 124 + descInput.value.trim() 125 + ); 126 + } 89 127 new Notice(`Created collection "${name}"`); 90 128 this.close(); 91 129 this.onSuccess?.(); ··· 97 135 } 98 136 } 99 137 100 - onClose() { 101 - this.contentEl.empty(); 102 - } 138 + onClose() { this.contentEl.empty(); } 103 139 }
-113
src/components/createMarginCollectionModal.ts
··· 1 - import { Modal, Notice } from "obsidian"; 2 - import type AtmospherePlugin from "../main"; 3 - import { createMarginCollection } from "../lib"; 4 - 5 - export class CreateMarginCollectionModal extends Modal { 6 - plugin: AtmospherePlugin; 7 - onSuccess?: () => void; 8 - 9 - constructor(plugin: AtmospherePlugin, onSuccess?: () => void) { 10 - super(plugin.app); 11 - this.plugin = plugin; 12 - this.onSuccess = onSuccess; 13 - } 14 - 15 - onOpen() { 16 - const { contentEl } = this; 17 - contentEl.empty(); 18 - contentEl.addClass("atmosphere-modal"); 19 - 20 - contentEl.createEl("h2", { text: "New margin collection" }); 21 - 22 - if (!this.plugin.client) { 23 - // contentEl.createEl("p", { text: "Not Logged In. Please Login Using Settings." }); 24 - return; 25 - } 26 - 27 - const form = contentEl.createEl("form", { cls: "atmosphere-form" }); 28 - 29 - const nameGroup = form.createEl("div", { cls: "atmosphere-form-group" }); 30 - nameGroup.createEl("label", { text: "Name", attr: { for: "collection-name" } }); 31 - const nameInput = nameGroup.createEl("input", { 32 - type: "text", 33 - cls: "atmosphere-input", 34 - attr: { id: "collection-name", placeholder: "Collection name", required: "true" }, 35 - }); 36 - 37 - const iconGroup = form.createEl("div", { cls: "atmosphere-form-group" }); 38 - iconGroup.createEl("label", { text: "Icon (optional)", attr: { for: "collection-icon" } }); 39 - const iconInput = iconGroup.createEl("input", { 40 - type: "text", 41 - cls: "atmosphere-input", 42 - attr: { id: "collection-icon" }, 43 - }); 44 - 45 - const descGroup = form.createEl("div", { cls: "atmosphere-form-group" }); 46 - descGroup.createEl("label", { text: "Description", attr: { for: "collection-desc" } }); 47 - const descInput = descGroup.createEl("textarea", { 48 - cls: "atmosphere-textarea", 49 - attr: { id: "collection-desc", placeholder: "Optional description", rows: "3" }, 50 - }); 51 - 52 - const actions = form.createEl("div", { cls: "atmosphere-modal-actions" }); 53 - 54 - const cancelBtn = actions.createEl("button", { 55 - text: "Cancel", 56 - cls: "atmosphere-btn atmosphere-btn-secondary", 57 - type: "button", 58 - }); 59 - cancelBtn.addEventListener("click", () => this.close()); 60 - 61 - const createBtn = actions.createEl("button", { 62 - text: "Create", 63 - cls: "atmosphere-btn atmosphere-btn-primary", 64 - type: "submit", 65 - }); 66 - 67 - form.addEventListener("submit", (e) => { 68 - e.preventDefault(); 69 - void this.handleSubmit(nameInput, iconInput, descInput, createBtn); 70 - }); 71 - 72 - nameInput.focus(); 73 - } 74 - 75 - private async handleSubmit( 76 - nameInput: HTMLInputElement, 77 - iconInput: HTMLInputElement, 78 - descInput: HTMLTextAreaElement, 79 - createBtn: HTMLButtonElement 80 - ) { 81 - const name = nameInput.value.trim(); 82 - if (!name) { 83 - new Notice("Please enter a collection name"); 84 - return; 85 - } 86 - 87 - createBtn.disabled = true; 88 - createBtn.textContent = "Creating..."; 89 - 90 - try { 91 - await createMarginCollection( 92 - this.plugin.client, 93 - this.plugin.settings.did!, 94 - name, 95 - descInput.value.trim() || undefined, 96 - iconInput.value.trim() || undefined 97 - ); 98 - 99 - new Notice(`Created collection "${name}"`); 100 - this.close(); 101 - this.onSuccess?.(); 102 - } catch (err) { 103 - const message = err instanceof Error ? err.message : String(err); 104 - new Notice(`Failed to create collection: ${message}`); 105 - createBtn.disabled = false; 106 - createBtn.textContent = "Create"; 107 - } 108 - } 109 - 110 - onClose() { 111 - this.contentEl.empty(); 112 - } 113 - }
+4 -4
src/components/editCardModal.ts
··· 1 1 import { Modal, Notice } from "obsidian"; 2 2 import type AtmospherePlugin from "../main"; 3 - import { getCollections, getCollectionLinks, createCollectionLink, getRecord, deleteRecord } from "../lib"; 4 3 import type { Main as Collection } from "../lexicons/types/network/cosmik/collection"; 5 4 import type { Main as CollectionLink } from "../lexicons/types/network/cosmik/collectionLink"; 5 + import {getSembleCollections, getSembleCollectionLinks, deleteRecord, getRecord, createSembleCollectionLink} from "../lib"; 6 6 7 7 interface CollectionRecord { 8 8 uri: string; ··· 53 53 54 54 try { 55 55 const [collectionsResp, linksResp] = await Promise.all([ 56 - getCollections(this.plugin.client, this.plugin.settings.did!), 57 - getCollectionLinks(this.plugin.client, this.plugin.settings.did!), 56 + getSembleCollections(this.plugin.client, this.plugin.settings.did!), 57 + getSembleCollectionLinks(this.plugin.client, this.plugin.settings.did!), 58 58 ]); 59 59 60 60 loading.remove(); ··· 223 223 224 224 if (!collectionResp.ok || !collectionResp.data.cid) continue; 225 225 226 - await createCollectionLink( 226 + await createSembleCollectionLink( 227 227 this.plugin.client, 228 228 this.plugin.settings.did!, 229 229 this.cardUri,
+28
src/icons.ts
··· 1 + import { addIcon } from "obsidian"; 2 + 3 + export const sembleLogo = `<svg width="24" height="24" viewBox="0 0 32 43" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M31.0164 33.1306C31.0164 38.581 25.7882 42.9994 15.8607 42.9994C5.93311 42.9994 0 37.5236 0 32.0732C0 26.6228 5.93311 23.2617 15.8607 23.2617C25.7882 23.2617 31.0164 27.6802 31.0164 33.1306Z" fill="#ff6400"></path><path d="M25.7295 19.3862C25.7295 22.5007 20.7964 22.2058 15.1558 22.2058C9.51511 22.2058 4.93445 22.1482 4.93445 19.0337C4.93445 15.9192 9.71537 12.6895 15.356 12.6895C20.9967 12.6895 25.7295 16.2717 25.7295 19.3862Z" fill="#ff6400"></path><path d="M25.0246 10.9256C25.0246 14.0401 20.7964 11.9829 15.1557 11.9829C9.51506 11.9829 6.34424 13.6876 6.34424 10.5731C6.34424 7.45857 9.51506 5.63867 15.1557 5.63867C20.7964 5.63867 25.0246 7.81103 25.0246 10.9256Z" fill="#ff6400"></path><path d="M20.4426 3.5755C20.4426 5.8323 18.2088 4.22951 15.2288 4.22951C12.2489 4.22951 10.5737 5.8323 10.5737 3.5755C10.5737 1.31871 12.2489 0 15.2288 0C18.2088 0 20.4426 1.31871 20.4426 3.5755Z" fill="#ff6400"></path></svg>`; 4 + 5 + export const marginLogo = `<svg width="265" height="231" viewBox="0 0 265 231" fill="#027bff" xmlns="http://www.w3.org/2000/svg"> 6 + <path d="M0 230 V0 H199 V65.7156 H149.5 V115.216 H182.5 L199 131.716 V230 Z"/> 7 + <path d="M215 214.224 V230 H264.5 V0 H215.07 V16.2242 H248.5 V214.224 H215 Z"/> 8 + </svg> 9 + `; 10 + 11 + // for guidelines mapped to 100×100: stroke-width=8 (≈2px@24px), ~4px padding, ~8px element spacing. 12 + // 13 + // Original semble paths scaled from viewBox "0 0 32 43" → 100×100 (x×3.125, y×2.326) 14 + const sembleCompat = 15 + `<path d="M96.9 77.1C96.9 89.8 80.6 100 49.6 100C18.5 100 0 87.3 0 74.6C0 61.9 18.5 54.1 49.6 54.1C80.6 54.1 96.9 61.9 96.9 77.1Z" fill="#ff6400"/>` + 16 + `<path d="M80.4 45.1C80.4 52.3 65.0 51.7 47.4 51.7C29.7 51.7 15.4 51.5 15.4 44.3C15.4 37.0 30.4 29.5 48.0 29.5C65.6 29.5 80.4 37.0 80.4 45.1Z" fill="#ff6400"/>` + 17 + `<path d="M78.2 25.4C78.2 32.7 65.0 27.9 47.4 27.9C29.7 27.9 19.8 31.8 19.8 24.6C19.8 17.4 29.7 13.1 47.4 13.1C65.0 13.1 78.2 17.4 78.2 25.4Z" fill="#ff6400"/>` + 18 + `<path d="M63.9 8.3C63.9 13.6 56.9 9.8 47.6 9.8C38.3 9.8 33.1 13.6 33.1 8.3C33.1 3.1 38.3 0 47.6 0C56.9 0 63.9 3.1 63.9 8.3Z" fill="#ff6400"/>`; 19 + 20 + // margin logo scaled to 100×100 21 + const marginCompat = 22 + `<path d="M12,92 L12,8 L50,8 L50,29 L37,29 L37,50 L42,50 L50,58 L50,92 L12,92" fill="none" stroke="currentColor" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>` + 23 + `<path d="M67,8 L88,8 L88,92 L67,92" fill="none" stroke="currentColor" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>`; 24 + 25 + export function registerIcons() { 26 + addIcon("atmosphere-semble", sembleCompat); 27 + addIcon("atmosphere-margin", marginCompat); 28 + }
+7 -7
src/lib.ts
··· 3 3 export { getRecord, deleteRecord, putRecord, getProfile } from "./lib/atproto"; 4 4 5 5 export { 6 - getSembleCollections as getCollections, 7 - createSembleCollection as createCollection, 8 - getSembleCards as getCards, 9 - createSembleNote as createNoteCard, 10 - createSembleUrlCard as createUrlCard, 11 - getSembleCollectionLinks as getCollectionLinks, 12 - createSembleCollectionLink as createCollectionLink, 6 + getSembleCollections, 7 + createSembleCollection, 8 + getSembleCards, 9 + createSembleNote, 10 + createSembleUrlCard, 11 + getSembleCollectionLinks, 12 + createSembleCollectionLink, 13 13 } from "./lib/bookmarks/cosmik"; 14 14 15 15 export { getBookmarks, createBookmark, getTags, createTag } from "./lib/bookmarks/community";
+2
src/main.ts
··· 5 5 import { StandardFeedView, VIEW_ATMOSPHERE_STANDARD_FEED } from "views/standardfeed"; 6 6 import { ATClient } from "lib/client"; 7 7 import { Clipper } from "lib/clipper"; 8 + import { registerIcons } from "./icons"; 8 9 9 10 export default class AtmospherePlugin extends Plugin { 10 11 settings: AtProtoSettings = DEFAULT_SETTINGS; ··· 12 13 clipper: Clipper; 13 14 14 15 async onload() { 16 + registerIcons(); 15 17 await this.loadSettings(); 16 18 this.client = new ATClient(); 17 19 this.clipper = new Clipper(this);
+37 -132
src/sources/bookmark.ts
··· 1 1 import type { Client } from "@atcute/client"; 2 2 import type { Record } from "@atcute/atproto/types/repo/listRecords"; 3 - import { setIcon } from "obsidian"; 4 3 import type AtmospherePlugin from "../main"; 5 4 import { getBookmarks } from "../lib"; 6 5 import type { ATBookmarkItem, DataSource, SourceFilter } from "./types"; 7 6 import { EditBookmarkModal } from "../components/editBookmarkModal"; 8 - import { CreateTagModal } from "../components/createTagModal"; 9 7 import type { Main as Bookmark } from "../lexicons/types/community/lexicon/bookmarks/bookmark"; 10 8 11 9 type BookmarkRecord = Record & { value: Bookmark }; ··· 13 11 class BookmarkItem implements ATBookmarkItem { 14 12 private record: BookmarkRecord; 15 13 private plugin: AtmospherePlugin; 14 + private collections: Array<{ uri: string; name: string; source: string }> = []; 16 15 17 16 constructor(record: BookmarkRecord, plugin: AtmospherePlugin) { 18 17 this.record = record; ··· 43 42 return true; 44 43 } 45 44 46 - openEditModal(onSuccess?: () => void): void { 47 - new EditBookmarkModal(this.plugin, this.record, onSuccess).open(); 45 + getCollections(): Array<{ uri: string; name: string; source: string }> { 46 + return this.collections; 48 47 } 49 48 50 - render(container: HTMLElement): void { 51 - const el = container.createEl("div", { cls: "atmosphere-item-content" }); 52 - const bookmark = this.record.value; 53 - const enriched = bookmark.enriched; 49 + setCollections(collections: Array<{ uri: string; name: string; source: string }>) { 50 + this.collections = collections; 51 + } 54 52 55 - if (bookmark.tags && bookmark.tags.length > 0) { 56 - const tagsContainer = el.createEl("div", { cls: "atmosphere-item-tags" }); 57 - for (const tag of bookmark.tags) { 58 - tagsContainer.createEl("span", { text: tag, cls: "atmosphere-tag" }); 59 - } 60 - } 53 + canAddTags(): boolean { 54 + return true; 55 + } 61 56 62 - const title = enriched?.title || bookmark.title; 63 - if (title) { 64 - el.createEl("div", { text: title, cls: "atmosphere-item-title" }); 65 - } 57 + openEditModal(onSuccess?: () => void): void { 58 + new EditBookmarkModal(this.plugin, this.record, onSuccess).open(); 59 + } 66 60 67 - const imageUrl = enriched?.image || enriched?.thumb; 68 - if (imageUrl) { 69 - const img = el.createEl("img", { cls: "atmosphere-item-image" }); 70 - img.src = imageUrl; 71 - img.alt = title || "Image"; 72 - } 61 + getTitle(): string | undefined { 62 + const enriched = this.record.value.enriched; 63 + return enriched?.title || this.record.value.title || undefined; 64 + } 73 65 74 - const description = enriched?.description || bookmark.description; 75 - if (description) { 76 - const desc = description.length > 200 77 - ? description.slice(0, 200) + "…" 78 - : description; 79 - el.createEl("p", { text: desc, cls: "atmosphere-item-desc" }); 80 - } 81 - 82 - if (enriched?.siteName) { 83 - el.createEl("span", { text: enriched.siteName, cls: "atmosphere-item-site" }); 84 - } 85 - 86 - const link = el.createEl("a", { 87 - text: bookmark.subject, 88 - href: bookmark.subject, 89 - cls: "atmosphere-item-url", 90 - }); 91 - link.setAttr("target", "_blank"); 66 + getDescription(): string | undefined { 67 + const enriched = this.record.value.enriched; 68 + return enriched?.description || this.record.value.description || undefined; 92 69 } 93 70 94 - renderDetail(container: HTMLElement): void { 95 - const body = container.createEl("div", { cls: "atmosphere-detail-body" }); 96 - const bookmark = this.record.value; 97 - const enriched = bookmark.enriched; 71 + getImageUrl(): string | undefined { 72 + const enriched = this.record.value.enriched; 73 + return enriched?.image || enriched?.thumb || undefined; 74 + } 98 75 99 - const title = enriched?.title || bookmark.title; 100 - if (title) { 101 - body.createEl("h2", { text: title, cls: "atmosphere-detail-title" }); 102 - } 76 + getUrl(): string | undefined { 77 + return this.record.value.subject; 78 + } 103 79 104 - const imageUrl = enriched?.image || enriched?.thumb; 105 - if (imageUrl) { 106 - const img = body.createEl("img", { cls: "atmosphere-detail-image" }); 107 - img.src = imageUrl; 108 - img.alt = title || "Image"; 109 - } 110 - 111 - const description = enriched?.description || bookmark.description; 112 - if (description) { 113 - body.createEl("p", { text: description, cls: "atmosphere-detail-description" }); 114 - } 115 - 116 - if (enriched?.siteName) { 117 - const metaGrid = body.createEl("div", { cls: "atmosphere-detail-meta" }); 118 - const item = metaGrid.createEl("div", { cls: "atmosphere-detail-meta-item" }); 119 - item.createEl("span", { text: "Site", cls: "atmosphere-detail-meta-label" }); 120 - item.createEl("span", { text: enriched.siteName, cls: "atmosphere-detail-meta-value" }); 121 - } 122 - 123 - const linkWrapper = body.createEl("div", { cls: "atmosphere-detail-link-wrapper" }); 124 - const link = linkWrapper.createEl("a", { 125 - text: bookmark.subject, 126 - href: bookmark.subject, 127 - cls: "atmosphere-detail-link", 128 - }); 129 - link.setAttr("target", "_blank"); 130 - 131 - if (bookmark.tags && bookmark.tags.length > 0) { 132 - const tagsSection = container.createEl("div", { cls: "atmosphere-item-tags-section" }); 133 - tagsSection.createEl("h3", { text: "Tags", cls: "atmosphere-detail-section-title" }); 134 - const tagsContainer = tagsSection.createEl("div", { cls: "atmosphere-item-tags" }); 135 - for (const tag of bookmark.tags) { 136 - tagsContainer.createEl("span", { text: tag, cls: "atmosphere-tag" }); 137 - } 138 - } 80 + getSiteName(): string | undefined { 81 + return this.record.value.enriched?.siteName || undefined; 139 82 } 140 83 141 - getTags() { 84 + getTags(): string[] { 142 85 return this.record.value.tags || []; 143 86 } 144 87 ··· 157 100 this.repo = repo; 158 101 } 159 102 160 - async fetchItems(filters: SourceFilter[], plugin: AtmospherePlugin): Promise<ATBookmarkItem[]> { 103 + async fetchItems(plugin: AtmospherePlugin, _filteredCollections: Set<string> | undefined, filteredTags: Set<string>): Promise<ATBookmarkItem[]> { 161 104 const bookmarksResp = await getBookmarks(this.client, this.repo); 162 105 if (!bookmarksResp.ok) return []; 163 106 164 107 let bookmarks = bookmarksResp.data.records as BookmarkRecord[]; 165 108 166 - const tagFilter = filters.find(f => f.type === "bookmarkTag"); 167 - if (tagFilter && tagFilter.value) { 109 + // no collecitons for community bookmarks 110 + 111 + if (filteredTags.size > 0) { 168 112 bookmarks = bookmarks.filter((record: BookmarkRecord) => 169 - record.value.tags?.includes(tagFilter.value) 113 + record.value.tags?.some(t => filteredTags.has(t)) 170 114 ); 171 115 } 172 116 173 117 return bookmarks.map((record: BookmarkRecord) => new BookmarkItem(record, plugin)); 174 118 } 175 119 176 - async getAvailableFilters(): Promise<SourceFilter[]> { 120 + async getAvilableTags(): Promise<SourceFilter[]> { 177 121 const bookmarksResp = await getBookmarks(this.client, this.repo); 178 122 if (!bookmarksResp.ok) return []; 179 123 ··· 187 131 } 188 132 } 189 133 190 - return Array.from(tagSet).map(tag => ({ 191 - type: "bookmarkTag", 192 - value: tag, 193 - label: tag, 194 - })); 134 + return Array.from(tagSet).map(tag => ({ value: tag, label: tag })); 195 135 } 196 136 197 - renderFilterUI(container: HTMLElement, activeFilters: Map<string, SourceFilter>, onChange: () => void, onDataChange: () => void, plugin: AtmospherePlugin): void { 198 - const section = container.createEl("div", { cls: "atmosphere-filter-section" }); 199 - 200 - const titleRow = section.createEl("div", { cls: "atmosphere-filter-title-row" }); 201 - titleRow.createEl("h3", { text: "Tags", cls: "atmosphere-filter-title" }); 202 - 203 - const createBtn = titleRow.createEl("button", { cls: "atmosphere-filter-create-btn" }); 204 - setIcon(createBtn, "plus"); 205 - createBtn.addEventListener("click", () => { 206 - new CreateTagModal(plugin, onDataChange).open(); 207 - }); 208 - 209 - const chips = section.createEl("div", { cls: "atmosphere-filter-chips" }); 210 - 211 - const allChip = chips.createEl("button", { 212 - text: "All", 213 - cls: `atmosphere-chip ${!activeFilters.has("bookmarkTag") ? "atmosphere-chip-active" : ""}`, 214 - }); 215 - allChip.addEventListener("click", () => { 216 - activeFilters.delete("bookmarkTag"); 217 - onChange(); 218 - }); 137 + } 219 138 220 - void this.getAvailableFilters().then(tags => { 221 - for (const tag of tags) { 222 - const chip = chips.createEl("button", { 223 - text: tag.label, 224 - cls: `atmosphere-chip ${activeFilters.get("bookmarkTag")?.value === tag.value ? "atmosphere-chip-active" : ""}`, 225 - }); 226 - chip.addEventListener("click", () => { 227 - activeFilters.set("bookmarkTag", tag); 228 - onChange(); 229 - }); 230 - } 231 - }); 232 - } 233 - }
+60 -202
src/sources/margin.ts
··· 1 1 import type { Client } from "@atcute/client"; 2 2 import type { Record } from "@atcute/atproto/types/repo/listRecords"; 3 - import { setIcon } from "obsidian"; 4 3 import type AtmospherePlugin from "../main"; 5 4 import { getMarginBookmarks, getMarginCollections, getMarginCollectionItems } from "../lib"; 6 - import type { ATBookmarkItem, DataSource, SourceFilter } from "./types"; 5 + import type { ATBookmarkItem, CollectionAssociation, DataSource, SourceFilter } from "./types"; 7 6 import type { Main as MarginBookmark } from "../lexicons/types/at/margin/bookmark"; 8 7 import type { Main as MarginCollection } from "../lexicons/types/at/margin/collection"; 9 8 import type { Main as MarginCollectionItem } from "../lexicons/types/at/margin/collectionItem"; 10 9 import { EditMarginBookmarkModal } from "../components/editMarginBookmarkModal"; 11 - import { CreateMarginCollectionModal } from "../components/createMarginCollectionModal"; 12 10 13 11 type MarginBookmarkRecord = Record & { value: MarginBookmark }; 14 12 type MarginCollectionRecord = Record & { value: MarginCollection }; ··· 17 15 class MarginItem implements ATBookmarkItem { 18 16 private record: MarginBookmarkRecord; 19 17 private plugin: AtmospherePlugin; 20 - private collections: Array<{ uri: string; name: string }>; 18 + private collections: Array<{ uri: string; name: string; source: string }>; 21 19 22 - constructor(record: MarginBookmarkRecord, collections: Array<{ uri: string; name: string }>, plugin: AtmospherePlugin) { 20 + constructor(record: MarginBookmarkRecord, collections: Array<{ uri: string; name: string; source: string }>, plugin: AtmospherePlugin) { 23 21 this.record = record; 24 22 this.collections = collections; 25 23 this.plugin = plugin; ··· 45 43 return false; 46 44 } 47 45 46 + canAddTags(): boolean { 47 + return true; 48 + } 49 + 48 50 canEdit(): boolean { 49 51 return true; 50 52 } ··· 53 55 new EditMarginBookmarkModal(this.plugin, this.record, onSuccess).open(); 54 56 } 55 57 56 - render(container: HTMLElement): void { 57 - const el = container.createEl("div", { cls: "atmosphere-item-content" }); 58 - const bookmark = this.record.value; 59 - 60 - if (this.collections.length > 0) { 61 - const collectionsContainer = el.createEl("div", { cls: "atmosphere-item-collections" }); 62 - for (const collection of this.collections) { 63 - collectionsContainer.createEl("span", { text: collection.name, cls: "atmosphere-collection" }); 64 - } 65 - } 58 + getTitle(): string | undefined { 59 + return this.record.value.title || undefined; 60 + } 66 61 67 - if (bookmark.tags && bookmark.tags.length > 0) { 68 - const tagsContainer = el.createEl("div", { cls: "atmosphere-item-tags" }); 69 - for (const tag of bookmark.tags) { 70 - tagsContainer.createEl("span", { text: tag, cls: "atmosphere-tag" }); 71 - } 72 - } 62 + getDescription(): string | undefined { 63 + return this.record.value.description || undefined; 64 + } 73 65 74 - if (bookmark.title) { 75 - el.createEl("div", { text: bookmark.title, cls: "atmosphere-item-title" }); 76 - } 66 + getImageUrl(): string | undefined { 67 + return undefined; 68 + } 77 69 78 - if (bookmark.description) { 79 - const desc = bookmark.description.length > 200 80 - ? bookmark.description.slice(0, 200) + "…" 81 - : bookmark.description; 82 - el.createEl("p", { text: desc, cls: "atmosphere-item-desc" }); 83 - } 70 + getUrl(): string | undefined { 71 + return this.record.value.source; 72 + } 84 73 85 - const link = el.createEl("a", { 86 - text: bookmark.source, 87 - href: bookmark.source, 88 - cls: "atmosphere-item-url", 89 - }); 90 - link.setAttr("target", "_blank"); 74 + getSiteName(): string | undefined { 75 + return undefined; 91 76 } 92 77 93 - renderDetail(container: HTMLElement): void { 94 - const body = container.createEl("div", { cls: "atmosphere-detail-body" }); 95 - const bookmark = this.record.value; 78 + getCollections(): Array<{ uri: string; name: string; source: string }> { 79 + return this.collections; 80 + } 96 81 97 - if (bookmark.title) { 98 - body.createEl("h2", { text: bookmark.title, cls: "atmosphere-detail-title" }); 99 - } 100 - 101 - if (bookmark.description) { 102 - body.createEl("p", { text: bookmark.description, cls: "atmosphere-detail-description" }); 103 - } 104 - 105 - const linkWrapper = body.createEl("div", { cls: "atmosphere-detail-link-wrapper" }); 106 - const link = linkWrapper.createEl("a", { 107 - text: bookmark.source, 108 - href: bookmark.source, 109 - cls: "atmosphere-detail-link", 110 - }); 111 - link.setAttr("target", "_blank"); 112 - 113 - if (this.collections.length > 0) { 114 - const collectionsSection = container.createEl("div", { cls: "atmosphere-item-collections-section" }); 115 - collectionsSection.createEl("h3", { text: "Collections", cls: "atmosphere-detail-section-title" }); 116 - const collectionsContainer = collectionsSection.createEl("div", { cls: "atmosphere-item-collections" }); 117 - for (const collection of this.collections) { 118 - collectionsContainer.createEl("span", { text: collection.name, cls: "atmosphere-collection" }); 119 - } 120 - } 121 - 122 - if (bookmark.tags && bookmark.tags.length > 0) { 123 - const tagsSection = container.createEl("div", { cls: "atmosphere-item-tags-section" }); 124 - tagsSection.createEl("h3", { text: "Tags", cls: "atmosphere-detail-section-title" }); 125 - const tagsContainer = tagsSection.createEl("div", { cls: "atmosphere-item-tags" }); 126 - for (const tag of bookmark.tags) { 127 - tagsContainer.createEl("span", { text: tag, cls: "atmosphere-tag" }); 128 - } 129 - } 82 + setCollections(collections: Array<{ uri: string; name: string; source: string }>) { 83 + this.collections = collections; 130 84 } 131 85 132 86 getTags() { ··· 148 102 this.repo = repo; 149 103 } 150 104 151 - async fetchItems(filters: SourceFilter[], plugin: AtmospherePlugin): Promise<ATBookmarkItem[]> { 105 + async fetchItems(plugin: AtmospherePlugin, filteredCollections: Set<string>, filteredTags: Set<string>): Promise<ATBookmarkItem[]> { 152 106 const bookmarksResp = await getMarginBookmarks(this.client, this.repo); 153 107 if (!bookmarksResp.ok) return []; 154 108 155 109 let bookmarks = bookmarksResp.data.records as MarginBookmarkRecord[]; 156 110 157 - // Build collections map (bookmark URI -> collection info) 158 - const collectionsMap = new Map<string, Array<{ uri: string; name: string }>>(); 159 - const collectionsResp = await getMarginCollections(this.client, this.repo); 160 - const itemsResp = await getMarginCollectionItems(this.client, this.repo); 161 - 162 - if (collectionsResp.ok && itemsResp.ok) { 163 - const collections = collectionsResp.data.records as MarginCollectionRecord[]; 164 - const collectionNameMap = new Map<string, string>(); 165 - for (const collection of collections) { 166 - collectionNameMap.set(collection.uri, collection.value.name); 167 - } 168 - 169 - const items = itemsResp.data.records as MarginCollectionItemRecord[]; 170 - for (const item of items) { 171 - const bookmarkUri = item.value.annotation; 172 - const collectionUri = item.value.collection; 173 - const collectionName = collectionNameMap.get(collectionUri); 174 - 175 - if (collectionName) { 176 - const existing = collectionsMap.get(bookmarkUri) || []; 177 - existing.push({ uri: collectionUri, name: collectionName }); 178 - collectionsMap.set(bookmarkUri, existing); 179 - } 180 - } 181 - } 182 - 183 - const collectionFilter = filters.find(f => f.type === "marginCollection"); 184 - if (collectionFilter && collectionFilter.value) { 185 - if (itemsResp.ok) { 186 - const items = itemsResp.data.records as MarginCollectionItemRecord[]; 187 - const filteredItems = items.filter((item: MarginCollectionItemRecord) => 188 - item.value.collection === collectionFilter.value 189 - ); 190 - const bookmarkUris = new Set(filteredItems.map((item: MarginCollectionItemRecord) => item.value.annotation)); 191 - bookmarks = bookmarks.filter((bookmark: MarginBookmarkRecord) => bookmarkUris.has(bookmark.uri)); 192 - } 111 + if (filteredCollections.size > 0) { 112 + bookmarks = bookmarks.filter((bookmark: MarginBookmarkRecord) => filteredCollections.has(bookmark.uri)); 193 113 } 194 114 195 - const tagFilter = filters.find(f => f.type === "marginTag"); 196 - if (tagFilter && tagFilter.value) { 115 + if (filteredTags.size > 0) { 197 116 bookmarks = bookmarks.filter((record: MarginBookmarkRecord) => 198 - record.value.tags?.includes(tagFilter.value) 117 + record.value.tags?.some(t => filteredTags.has(t)) 199 118 ); 200 119 } 201 120 202 121 return bookmarks.map((record: MarginBookmarkRecord) => 203 - new MarginItem(record, collectionsMap.get(record.uri) || [], plugin) 122 + new MarginItem(record, [], plugin) 204 123 ); 205 124 } 206 - 207 - async getAvailableFilters(): Promise<SourceFilter[]> { 208 - const filters: SourceFilter[] = []; 209 - 125 + async getAvailableCollections(): Promise<SourceFilter[]> { 210 126 const collectionsResp = await getMarginCollections(this.client, this.repo); 211 - if (collectionsResp.ok) { 212 - const collections = collectionsResp.data.records as MarginCollectionRecord[]; 213 - filters.push(...collections.map((c: MarginCollectionRecord) => ({ 214 - type: "marginCollection", 215 - value: c.uri, 216 - label: c.value.name, 217 - }))); 218 - } 127 + if (!collectionsResp.ok) return []; 219 128 220 - const bookmarksResp = await getMarginBookmarks(this.client, this.repo); 221 - if (bookmarksResp.ok) { 222 - const tagSet = new Set<string>(); 223 - const records = bookmarksResp.data.records as MarginBookmarkRecord[]; 224 - for (const record of records) { 225 - if (record.value.tags) { 226 - for (const tag of record.value.tags) { 227 - tagSet.add(tag); 228 - } 229 - } 230 - } 231 - filters.push(...Array.from(tagSet).map(tag => ({ 232 - type: "marginTag", 233 - value: tag, 234 - label: tag, 235 - }))); 236 - } 129 + const collections = collectionsResp.data.records as MarginCollectionRecord[]; 130 + return collections.map((c: MarginCollectionRecord) => ({ 131 + value: c.uri, 132 + label: c.value.name, 133 + })); 134 + } 135 + async getCollectionAssociations(): Promise<CollectionAssociation[]> { 136 + const itemsResp = await getMarginCollectionItems(this.client, this.repo); 137 + if (!itemsResp.ok) return []; 237 138 238 - return filters; 139 + return (itemsResp.data.records as MarginCollectionItemRecord[]).map(item => ({ 140 + record: item.value.annotation, 141 + collection: item.value.collection, 142 + })); 239 143 } 240 144 241 - renderFilterUI(container: HTMLElement, activeFilters: Map<string, SourceFilter>, onChange: () => void, onDataChange: () => void, plugin: AtmospherePlugin): void { 242 - const collectionsSection = container.createEl("div", { cls: "atmosphere-filter-section" }); 145 + async getAvilableTags(): Promise<SourceFilter[]> { 146 + const resp = await getMarginBookmarks(this.client, this.repo); 147 + if (!resp.ok) return []; 243 148 244 - const collectionsTitleRow = collectionsSection.createEl("div", { cls: "atmosphere-filter-title-row" }); 245 - collectionsTitleRow.createEl("h3", { text: "Collections", cls: "atmosphere-filter-title" }); 246 - 247 - const createCollectionBtn = collectionsTitleRow.createEl("button", { cls: "atmosphere-filter-create-btn" }); 248 - setIcon(createCollectionBtn, "plus"); 249 - createCollectionBtn.addEventListener("click", () => { 250 - new CreateMarginCollectionModal(plugin, onDataChange).open(); 149 + const records = resp.data.records as MarginBookmarkRecord[]; 150 + // return list of unique tags 151 + const tagSet = new Set<string>(); 152 + records.forEach(record => { 153 + if (record.value.tags) { 154 + record.value.tags.forEach(tag => tagSet.add(tag)); 155 + } 251 156 }); 157 + return Array.from(tagSet).map(tag => ({ value: tag, label: tag })); 252 158 253 - const collectionsChips = collectionsSection.createEl("div", { cls: "atmosphere-filter-chips" }); 159 + } 254 160 255 - const allCollectionsChip = collectionsChips.createEl("button", { 256 - text: "All", 257 - cls: `atmosphere-chip ${!activeFilters.has("marginCollection") ? "atmosphere-chip-active" : ""}`, 258 - }); 259 - allCollectionsChip.addEventListener("click", () => { 260 - activeFilters.delete("marginCollection"); 261 - onChange(); 262 - }); 161 + } 263 162 264 - const tagsSection = container.createEl("div", { cls: "atmosphere-filter-section" }); 265 - 266 - const tagsTitleRow = tagsSection.createEl("div", { cls: "atmosphere-filter-title-row" }); 267 - tagsTitleRow.createEl("h3", { text: "Tags", cls: "atmosphere-filter-title" }); 268 - 269 - const tagsChips = tagsSection.createEl("div", { cls: "atmosphere-filter-chips" }); 270 - 271 - const allTagsChip = tagsChips.createEl("button", { 272 - text: "All", 273 - cls: `atmosphere-chip ${!activeFilters.has("marginTag") ? "atmosphere-chip-active" : ""}`, 274 - }); 275 - allTagsChip.addEventListener("click", () => { 276 - activeFilters.delete("marginTag"); 277 - onChange(); 278 - }); 279 - 280 - void this.getAvailableFilters().then(filters => { 281 - for (const filter of filters) { 282 - if (filter.type === "marginCollection") { 283 - const chip = collectionsChips.createEl("button", { 284 - text: filter.label, 285 - cls: `atmosphere-chip ${activeFilters.get("marginCollection")?.value === filter.value ? "atmosphere-chip-active" : ""}`, 286 - }); 287 - chip.addEventListener("click", () => { 288 - activeFilters.set("marginCollection", filter); 289 - onChange(); 290 - }); 291 - } else if (filter.type === "marginTag") { 292 - const chip = tagsChips.createEl("button", { 293 - text: filter.label, 294 - cls: `atmosphere-chip ${activeFilters.get("marginTag")?.value === filter.value ? "atmosphere-chip-active" : ""}`, 295 - }); 296 - chip.addEventListener("click", () => { 297 - activeFilters.set("marginTag", filter); 298 - onChange(); 299 - }); 300 - } 301 - } 302 - }); 303 - } 304 - }
+65 -128
src/sources/semble.ts
··· 1 1 import type { Client } from "@atcute/client"; 2 2 import type { Record } from "@atcute/atproto/types/repo/listRecords"; 3 - import { setIcon } from "obsidian"; 4 3 import type AtmospherePlugin from "../main"; 5 - import { getCards, getCollections, getCollectionLinks } from "../lib"; 4 + import { getSembleCollections, getSembleCards, getSembleCollectionLinks } from "../lib"; 6 5 import type { Main as Card, NoteContent, UrlContent } from "../lexicons/types/network/cosmik/card"; 7 6 import type { Main as Collection } from "../lexicons/types/network/cosmik/collection"; 8 7 import type { Main as CollectionLink } from "../lexicons/types/network/cosmik/collectionLink"; 9 - import type { ATBookmarkItem, DataSource, SourceFilter } from "./types"; 8 + import type { ATBookmarkItem, CollectionAssociation, DataSource, SourceFilter } from "./types"; 10 9 import { EditCardModal } from "../components/editCardModal"; 11 - import { CreateCollectionModal } from "../components/createCollectionModal"; 12 10 13 11 type CardRecord = Record & { value: Card }; 14 12 type CollectionRecord = Record & { value: Collection }; ··· 17 15 class SembleItem implements ATBookmarkItem { 18 16 private record: CardRecord; 19 17 private attachedNotes: Array<{ uri: string; text: string }>; 18 + private collections: Array<{ uri: string; name: string; source: string }>; 20 19 private plugin: AtmospherePlugin; 21 20 22 - constructor(record: CardRecord, attachedNotes: Array<{ uri: string; text: string }>, plugin: AtmospherePlugin) { 21 + constructor(record: CardRecord, attachedNotes: Array<{ uri: string; text: string }>, collections: Array<{ uri: string; name: string; source: string }>, plugin: AtmospherePlugin) { 23 22 this.record = record; 24 23 this.attachedNotes = attachedNotes; 24 + this.collections = collections; 25 25 this.plugin = plugin; 26 26 } 27 27 ··· 45 45 return true; 46 46 } 47 47 48 + canAddTags(): boolean { 49 + return false; 50 + } 51 + 48 52 canEdit(): boolean { 49 53 return true; 50 54 } ··· 53 57 new EditCardModal(this.plugin, this.record.uri, this.record.cid, onSuccess).open(); 54 58 } 55 59 56 - render(container: HTMLElement): void { 57 - const el = container.createEl("div", { cls: "atmosphere-item-content" }); 58 - 60 + getTitle(): string | undefined { 59 61 const card = this.record.value; 62 + if (card.type === "URL") { 63 + return (card.content as UrlContent).metadata?.title || undefined; 64 + } 65 + return undefined; 66 + } 60 67 68 + getDescription(): string | undefined { 69 + const card = this.record.value; 61 70 if (card.type === "NOTE") { 62 - const content = card.content as NoteContent; 63 - el.createEl("p", { text: content.text, cls: "atmosphere-semble-card-text" }); 71 + return (card.content as NoteContent).text; 64 72 } else if (card.type === "URL") { 65 - const content = card.content as UrlContent; 66 - const meta = content.metadata; 73 + return (card.content as UrlContent).metadata?.description || undefined; 74 + } 75 + return undefined; 76 + } 67 77 68 - if (meta?.title) { 69 - el.createEl("div", { text: meta.title, cls: "atmosphere-item-title" }); 70 - } 78 + getImageUrl(): string | undefined { 79 + const card = this.record.value; 80 + if (card.type === "URL") { 81 + return (card.content as UrlContent).metadata?.imageUrl || undefined; 82 + } 83 + return undefined; 84 + } 71 85 72 - if (meta?.imageUrl) { 73 - const img = el.createEl("img", { cls: "atmosphere-item-image" }); 74 - img.src = meta.imageUrl; 75 - img.alt = meta.title || "Image"; 76 - } 77 - 78 - if (meta?.description) { 79 - const desc = meta.description.length > 200 80 - ? meta.description.slice(0, 200) + "…" 81 - : meta.description; 82 - el.createEl("p", { text: desc, cls: "atmosphere-item-desc" }); 83 - } 84 - 85 - if (meta?.siteName) { 86 - el.createEl("span", { text: meta.siteName, cls: "atmosphere-item-site" }); 87 - } 88 - 89 - const link = el.createEl("a", { 90 - text: content.url, 91 - href: content.url, 92 - cls: "atmosphere-item-url", 93 - }); 94 - link.setAttr("target", "_blank"); 86 + getUrl(): string | undefined { 87 + const card = this.record.value; 88 + if (card.type === "URL") { 89 + return (card.content as UrlContent).url; 95 90 } 91 + return undefined; 96 92 } 97 93 98 - renderDetail(container: HTMLElement): void { 99 - const body = container.createEl("div", { cls: "atmosphere-detail-body" }); 94 + getSiteName(): string | undefined { 100 95 const card = this.record.value; 96 + if (card.type === "URL") { 97 + return (card.content as UrlContent).metadata?.siteName || undefined; 98 + } 99 + return undefined; 100 + } 101 101 102 - if (card.type === "NOTE") { 103 - const content = card.content as NoteContent; 104 - body.createEl("p", { text: content.text, cls: "atmosphere-semble-detail-text" }); 105 - } else if (card.type === "URL") { 106 - const content = card.content as UrlContent; 107 - const meta = content.metadata; 102 + getTags(): string[] { 103 + return []; 104 + } 108 105 109 - if (meta?.title) { 110 - body.createEl("h2", { text: meta.title, cls: "atmosphere-detail-title" }); 111 - } 106 + getCollections(): Array<{ uri: string; name: string; source: string }> { 107 + return this.collections; 108 + } 112 109 113 - if (meta?.imageUrl) { 114 - const img = body.createEl("img", { cls: "atmosphere-detail-image" }); 115 - img.src = meta.imageUrl; 116 - img.alt = meta.title || "Image"; 117 - } 118 - 119 - if (meta?.description) { 120 - body.createEl("p", { text: meta.description, cls: "atmosphere-detail-description" }); 121 - } 122 - 123 - if (meta?.siteName) { 124 - const metaGrid = body.createEl("div", { cls: "atmosphere-detail-meta" }); 125 - const item = metaGrid.createEl("div", { cls: "atmosphere-detail-meta-item" }); 126 - item.createEl("span", { text: "Site", cls: "atmosphere-detail-meta-label" }); 127 - item.createEl("span", { text: meta.siteName, cls: "atmosphere-detail-meta-value" }); 128 - } 129 - 130 - const linkWrapper = body.createEl("div", { cls: "atmosphere-detail-link-wrapper" }); 131 - const link = linkWrapper.createEl("a", { 132 - text: content.url, 133 - href: content.url, 134 - cls: "atmosphere-detail-link", 135 - }); 136 - link.setAttr("target", "_blank"); 137 - } 138 - 110 + setCollections(collections: Array<{ uri: string; name: string; source: string }>) { 111 + this.collections = collections; 139 112 } 140 113 141 114 getAttachedNotes() { ··· 157 130 this.repo = repo; 158 131 } 159 132 160 - async fetchItems(filters: SourceFilter[], plugin: AtmospherePlugin): Promise<ATBookmarkItem[]> { 161 - const cardsResp = await getCards(this.client, this.repo); 133 + async fetchItems(plugin: AtmospherePlugin, filteredCollections: Set<string> | undefined, _filteredTags: Set<string>): Promise<ATBookmarkItem[]> { 134 + const cardsResp = await getSembleCards(this.client, this.repo); 162 135 if (!cardsResp.ok) return []; 163 136 164 137 const allSembleCards = cardsResp.data.records as CardRecord[]; ··· 179 152 // Filter out NOTE cards that are attached to other cards 180 153 let sembleCards = allSembleCards.filter((record: CardRecord) => { 181 154 if (record.value.type === "NOTE") { 182 - const hasParent = record.value.parentCard?.uri; 183 - return !hasParent; 155 + return !record.value.parentCard?.uri; 184 156 } 185 157 return true; 186 158 }); 187 159 188 - const collectionFilter = filters.find(f => f.type === "sembleCollection"); 189 - if (collectionFilter && collectionFilter.value) { 190 - const linksResp = await getCollectionLinks(this.client, this.repo); 191 - if (linksResp.ok) { 192 - const links = linksResp.data.records as CollectionLinkRecord[]; 193 - const filteredLinks = links.filter((link: CollectionLinkRecord) => 194 - link.value.collection.uri === collectionFilter.value 195 - ); 196 - const cardUris = new Set(filteredLinks.map((link: CollectionLinkRecord) => link.value.card.uri)); 197 - sembleCards = sembleCards.filter((card: CardRecord) => cardUris.has(card.uri)); 198 - } 160 + if (filteredCollections && filteredCollections.size > 0) { 161 + sembleCards = sembleCards.filter((card: CardRecord) => filteredCollections.has(card.uri)); 199 162 } 200 163 201 164 return sembleCards.map((record: CardRecord) => 202 - new SembleItem(record, notesMap.get(record.uri) || [], plugin) 165 + new SembleItem(record, notesMap.get(record.uri) || [], [], plugin) 203 166 ); 204 167 } 205 168 206 - async getAvailableFilters(): Promise<SourceFilter[]> { 207 - const collectionsResp = await getCollections(this.client, this.repo); 169 + async getAvailableCollections(): Promise<SourceFilter[]> { 170 + const collectionsResp = await getSembleCollections(this.client, this.repo); 208 171 if (!collectionsResp.ok) return []; 209 172 210 173 const collections = collectionsResp.data.records as CollectionRecord[]; 211 174 return collections.map((c: CollectionRecord) => ({ 212 - type: "sembleCollection", 213 175 value: c.uri, 214 176 label: c.value.name, 215 177 })); 216 178 } 217 179 218 - renderFilterUI(container: HTMLElement, activeFilters: Map<string, SourceFilter>, onChange: () => void, onDataChange: () => void, plugin: AtmospherePlugin): void { 219 - const section = container.createEl("div", { cls: "atmosphere-filter-section" }); 220 - 221 - const titleRow = section.createEl("div", { cls: "atmosphere-filter-title-row" }); 222 - titleRow.createEl("h3", { text: "Semble collections", cls: "atmosphere-filter-title" }); 223 - 224 - const createBtn = titleRow.createEl("button", { cls: "atmosphere-filter-create-btn" }); 225 - setIcon(createBtn, "plus"); 226 - createBtn.addEventListener("click", () => { 227 - new CreateCollectionModal(plugin, onDataChange).open(); 228 - }); 229 - 230 - const chips = section.createEl("div", { cls: "atmosphere-filter-chips" }); 231 - 232 - const allChip = chips.createEl("button", { 233 - text: "All", 234 - cls: `atmosphere-chip ${!activeFilters.has("sembleCollection") ? "atmosphere-chip-active" : ""}`, 235 - }); 236 - allChip.addEventListener("click", () => { 237 - activeFilters.delete("sembleCollection"); 238 - onChange(); 239 - }); 180 + async getCollectionAssociations(): Promise<CollectionAssociation[]> { 181 + const linksResp = await getSembleCollectionLinks(this.client, this.repo); 182 + if (!linksResp.ok) return []; 240 183 241 - void this.getAvailableFilters().then(collections => { 242 - for (const collection of collections) { 243 - const chip = chips.createEl("button", { 244 - text: collection.label, 245 - cls: `atmosphere-chip ${activeFilters.get("sembleCollection")?.value === collection.value ? "atmosphere-chip-active" : ""}`, 246 - }); 247 - chip.addEventListener("click", () => { 248 - activeFilters.set("sembleCollection", collection); 249 - onChange(); 250 - }); 251 - } 252 - }); 184 + return (linksResp.data.records as CollectionLinkRecord[]).map(link => ({ 185 + record: link.value.card.uri, 186 + collection: link.value.collection.uri, 187 + })); 253 188 } 189 + 254 190 } 191 +
+18 -6
src/sources/types.ts
··· 1 1 import type AtmospherePlugin from "../main"; 2 2 3 3 export interface ATBookmarkItem { 4 - render(container: HTMLElement): void; 5 - renderDetail(container: HTMLElement): void; 6 4 canAddNotes(): boolean; 5 + canAddTags(): boolean; 7 6 canEdit(): boolean; 8 7 openEditModal(onSuccess?: () => void): void; 9 8 getUri(): string; 10 9 getCid(): string; 11 10 getCreatedAt(): string; 12 11 getSource(): "semble" | "bookmark" | "margin"; 12 + getTitle(): string | undefined; 13 + getDescription(): string | undefined; 14 + getImageUrl(): string | undefined; 15 + getUrl(): string | undefined; 16 + getSiteName(): string | undefined; 17 + getTags(): string[]; 18 + getCollections(): Array<{ uri: string; name: string; source: string }>; 19 + setCollections(collections: Array<{ uri: string; name: string; source: string }>): void; 13 20 getAttachedNotes?(): Array<{ uri: string; text: string }>; 14 21 } 15 22 16 23 export interface SourceFilter { 17 - type: string; 18 24 value: string; 19 25 label?: string; 20 26 } 21 27 28 + export interface CollectionAssociation { 29 + record: string; 30 + collection: string; 31 + } 32 + 22 33 export interface DataSource { 23 34 readonly name: "semble" | "bookmark" | "margin"; 24 - fetchItems(filters: SourceFilter[], plugin: AtmospherePlugin): Promise<ATBookmarkItem[]>; 25 - getAvailableFilters(): Promise<SourceFilter[]>; 26 - renderFilterUI(container: HTMLElement, activeFilters: Map<string, SourceFilter>, onChange: () => void, onDataChange: () => void, plugin: AtmospherePlugin): void; 35 + fetchItems(plugin: AtmospherePlugin, filteredCollections: Set<string>, filteredTags: Set<string>): Promise<ATBookmarkItem[]>; 36 + getAvailableCollections?(): Promise<SourceFilter[]>; 37 + getAvilableTags?(): Promise<SourceFilter[]>; 38 + getCollectionAssociations?(): Promise<CollectionAssociation[]>; 27 39 }
+313 -55
src/views/bookmarks.ts
··· 1 - import { ItemView, WorkspaceLeaf, setIcon } from "obsidian"; 1 + import { ItemView, WorkspaceLeaf, setIcon, Menu } from "obsidian"; 2 2 import type AtmospherePlugin from "../main"; 3 3 import { CardDetailModal } from "../components/cardDetailModal"; 4 + import { CreateCollectionModal } from "../components/createCollectionModal"; 5 + import { CreateTagModal } from "../components/createTagModal"; 4 6 import type { ATBookmarkItem, DataSource, SourceFilter } from "../sources/types"; 5 7 import { SembleSource } from "../sources/semble"; 6 8 import { BookmarkSource } from "../sources/bookmark"; ··· 9 11 10 12 export const VIEW_TYPE_ATMOSPHERE_BOOKMARKS = "atmosphere-bookmarks"; 11 13 12 - type SourceType = "semble" | "bookmark" | "margin"; 14 + type SourceName = "semble" | "bookmark" | "margin"; 13 15 14 16 export class AtmosphereView extends ItemView { 15 17 plugin: AtmospherePlugin; 16 - activeSource: SourceType = "semble"; 17 - sources: Map<SourceType, { source: DataSource; filters: Map<string, SourceFilter> }> = new Map(); 18 + activeSources: Set<SourceName> = new Set(["semble"]); 19 + selectedCollections: Set<string> = new Set(); 20 + selectedTags: Set<string> = new Set(); 21 + sources: Map<SourceName, DataSource> = new Map(); 18 22 19 23 constructor(leaf: WorkspaceLeaf, plugin: AtmospherePlugin) { 20 24 super(leaf); ··· 24 28 initSources() { 25 29 if (this.plugin.settings.did) { 26 30 const repo = this.plugin.settings.did; 27 - this.sources.set("semble", { 28 - source: new SembleSource(this.plugin.client, repo), 29 - filters: new Map() 30 - }); 31 - this.sources.set("bookmark", { 32 - source: new BookmarkSource(this.plugin.client, repo), 33 - filters: new Map() 34 - }); 35 - this.sources.set("margin", { 36 - source: new MarginSource(this.plugin.client, repo), 37 - filters: new Map() 38 - }); 31 + this.sources.set("semble", new SembleSource(this.plugin.client, repo)); 32 + this.sources.set("bookmark", new BookmarkSource(this.plugin.client, repo)); 33 + this.sources.set("margin", new MarginSource(this.plugin.client, repo)); 39 34 } 40 - 41 35 } 42 36 43 37 getViewType() { ··· 52 46 return "layers"; 53 47 } 54 48 49 + private get activeDatasources(): DataSource[] { 50 + return Array.from(this.activeSources, s => this.sources.get(s)!); 51 + } 52 + 55 53 async onOpen() { 56 54 this.initSources(); 57 55 await this.render(); 58 56 } 59 57 60 58 async fetchItems(): Promise<ATBookmarkItem[]> { 61 - const sourceData = this.sources.get(this.activeSource); 62 - if (!sourceData) return []; 59 + const allowedUris = await this.getFilteredItemUris(); 60 + 61 + if (this.selectedCollections.size > 0 && allowedUris.size === 0) return []; 63 62 64 - const filters = Array.from(sourceData.filters.values()); 65 - return await sourceData.source.fetchItems(filters, this.plugin); 63 + const results = await Promise.all( 64 + this.activeDatasources.map(async (source) => { 65 + if (this.selectedTags.size > 0 && !source.getAvilableTags) return []; 66 + if (this.selectedCollections.size > 0 && !source.getAvailableCollections) return []; 67 + return source.fetchItems(this.plugin, allowedUris, this.selectedTags); 68 + }) 69 + ); 70 + 71 + const items = results.flat().sort((a, b) => 72 + new Date(b.getCreatedAt()).getTime() - new Date(a.getCreatedAt()).getTime() 73 + ); 74 + 75 + await this.injectCollections(items); 76 + return items; 77 + } 78 + 79 + private async injectCollections(items: ATBookmarkItem[]) { 80 + const sources = this.activeDatasources; 81 + 82 + const [allCollections, assocResults] = await Promise.all([ 83 + Promise.all(sources.map(async s => { 84 + const cols = await (s.getAvailableCollections?.() ?? Promise.resolve([])); 85 + return cols.map(c => ({ value: c.value, name: c.label ?? c.value, source: s.name })); 86 + })).then(r => r.flat()), 87 + Promise.all(sources.map(s => s.getCollectionAssociations?.() ?? Promise.resolve([]))), 88 + ]); 89 + 90 + const collectionMeta = new Map(allCollections.map(c => [c.value, { name: c.name, source: c.source }])); 91 + 92 + const collectionsMap = new Map<string, Array<{ uri: string; name: string; source: string }>>(); 93 + for (const assoc of assocResults.flat()) { 94 + const meta = collectionMeta.get(assoc.collection); 95 + if (meta) { 96 + const existing = collectionsMap.get(assoc.record) ?? []; 97 + existing.push({ uri: assoc.collection, name: meta.name, source: meta.source }); 98 + collectionsMap.set(assoc.record, existing); 99 + } 100 + } 101 + 102 + for (const item of items) { 103 + item.setCollections(collectionsMap.get(item.getUri()) ?? []); 104 + } 105 + } 106 + 107 + private async getFilteredItemUris(): Promise<Set<string>> { 108 + if (this.selectedCollections.size === 0) return new Set<string>(); 109 + 110 + const allowedUris = new Set<string>(); 111 + for (const source of this.activeDatasources) { 112 + if (!source.getCollectionAssociations) continue; 113 + for (const assoc of await source.getCollectionAssociations()) { 114 + if (this.selectedCollections.has(assoc.collection)) { 115 + allowedUris.add(assoc.record); 116 + } 117 + } 118 + } 119 + return allowedUris; 66 120 } 67 121 68 122 async render() { ··· 70 124 container.empty(); 71 125 container.addClass("atmosphere-view"); 72 126 73 - 74 127 if (!await this.plugin.checkAuth()) { 75 - renderLoginMessage(container) 76 - return 128 + renderLoginMessage(container); 129 + return; 77 130 } 78 131 79 132 this.renderHeader(container); ··· 83 136 try { 84 137 const items = await this.fetchItems(); 85 138 loading.remove(); 86 - 87 139 88 140 if (items.length === 0) { 89 141 container.createEl("p", { text: "No items found." }); ··· 109 161 private async refresh() { 110 162 this.plugin.client.clearCache(); 111 163 await this.render(); 112 - 113 164 } 114 165 115 166 private renderHeader(container: HTMLElement) { ··· 118 169 const topRow = header.createEl("div", { cls: "atmosphere-header-top-row" }); 119 170 120 171 const sourceSelector = topRow.createEl("div", { cls: "atmosphere-source-selector" }); 121 - const sources: SourceType[] = ["semble", "margin", "bookmark"]; 172 + const sources: SourceName[] = ["semble", "margin", "bookmark"]; 122 173 123 174 for (const source of sources) { 124 175 const label = sourceSelector.createEl("label", { cls: "atmosphere-source-option" }); 125 176 126 - const radio = label.createEl("input", { 127 - type: "radio", 128 - cls: "atmosphere-source-radio", 177 + const checkbox = label.createEl("input", { 178 + type: "checkbox", 179 + cls: "atmosphere-source-toggle", 129 180 }); 130 - radio.name = "atmosphere-source"; 131 - radio.checked = this.activeSource === source; 132 - radio.addEventListener("change", () => { 133 - this.activeSource = source; 181 + checkbox.checked = this.activeSources.has(source); 182 + checkbox.addEventListener("change", () => { 183 + if (checkbox.checked) { 184 + this.activeSources.add(source); 185 + } else { 186 + this.activeSources.delete(source); 187 + } 134 188 void this.render(); 135 189 }); 136 190 ··· 142 196 143 197 const refreshBtn = topRow.createEl("button", { 144 198 cls: "atmosphere-refresh-btn", 145 - attr: { "aria-label": "Refresh bookmarks" } 199 + attr: { "aria-label": "Refresh bookmarks" }, 146 200 }); 147 201 setIcon(refreshBtn, "refresh-cw"); 148 202 refreshBtn.addEventListener("click", () => { ··· 151 205 refreshBtn.removeClass("atmosphere-refresh-btn-spinning"); 152 206 }); 153 207 154 - const filtersContainer = container.createEl("div", { cls: "atmosphere-filters" }); 155 - const sourceData = this.sources.get(this.activeSource); 156 - if (sourceData) { 157 - sourceData.source.renderFilterUI( 158 - filtersContainer, 159 - sourceData.filters, 160 - () => void this.render(), 161 - () => void this.refresh(), 162 - this.plugin 208 + this.renderFilters(container); 209 + } 210 + 211 + private renderFilters(container: HTMLElement) { 212 + const filtersEl = container.createEl("div", { cls: "atmosphere-filters" }); 213 + 214 + const collectionSources = (["semble", "margin"] as SourceName[]).filter(s => this.activeSources.has(s)); 215 + if (collectionSources.length > 0) { 216 + void this.renderCollectionsFilter(filtersEl, collectionSources); 217 + } 218 + 219 + const tagSources = (["margin", "bookmark"] as SourceName[]).filter(s => this.activeSources.has(s)); 220 + if (tagSources.length > 0) { 221 + void this.renderTagsFilter(filtersEl, tagSources); 222 + } 223 + } 224 + 225 + private async fetchAllCollections(sources: SourceName[]): Promise<(SourceFilter & { source: SourceName })[]> { 226 + const results = await Promise.all( 227 + sources.map(async s => { 228 + const items = await (this.sources.get(s)?.getAvailableCollections?.() ?? Promise.resolve([])); 229 + return items.map(c => ({ ...c, source: s })); 230 + }) 231 + ); 232 + const seen = new Set<string>(); 233 + return results.flat().filter(c => !seen.has(c.value) && Boolean(seen.add(c.value))); 234 + } 235 + 236 + private async fetchAllTags(sources: SourceName[]): Promise<(SourceFilter & { source: SourceName })[]> { 237 + const results = await Promise.all( 238 + sources.map(async s => { 239 + const items = await (this.sources.get(s)?.getAvilableTags?.() ?? Promise.resolve([])); 240 + return items.map(t => ({ ...t, source: s })); 241 + }) 242 + ); 243 + const seen = new Set<string>(); 244 + return results.flat().filter(t => !seen.has(t.value) && Boolean(seen.add(t.value))); 245 + } 246 + 247 + private async renderCollectionsFilter(container: HTMLElement, collectionSources: SourceName[]) { 248 + const section = container.createEl("div", { cls: "atmosphere-filter-section" }); 249 + 250 + const titleRow = section.createEl("div", { cls: "atmosphere-filter-title-row" }); 251 + 252 + const pickerBtn = titleRow.createEl("button", { 253 + cls: "atmosphere-filter-picker-btn", 254 + attr: { "aria-label": "Filter collections" }, 255 + }); 256 + setIcon(pickerBtn, "folder"); 257 + pickerBtn.createEl("span", { text: "Collections", cls: "atmosphere-filter-title" }); 258 + 259 + const btn = titleRow.createEl("button", { 260 + cls: "atmosphere-filter-create-btn", 261 + attr: { "aria-label": "New collection" }, 262 + }); 263 + setIcon(btn, "plus"); 264 + btn.addEventListener("click", (e) => { 265 + e.stopPropagation(); 266 + new CreateCollectionModal( 267 + this.plugin, 268 + collectionSources as ("semble" | "margin")[], 269 + () => void this.refresh() 270 + ).open(); 271 + }); 272 + pickerBtn.addEventListener("click", (e) => void this.showCollectionsMenu(e, collectionSources)); 273 + 274 + if (this.selectedCollections.size > 0) { 275 + const chipsRow = section.createEl("div", { cls: "atmosphere-filter-active-chips" }); 276 + const collections = await this.fetchAllCollections(collectionSources); 277 + for (const c of collections) { 278 + if (!this.selectedCollections.has(c.value)) continue; 279 + const chip = chipsRow.createEl("span", { cls: "atmosphere-chip atmosphere-chip-active atmosphere-chip-removable" }); 280 + setIcon(chip, sourceIconId(c.source)); 281 + chip.createEl("span", { text: c.label ?? c.value }); 282 + const x = chip.createEl("button", { cls: "atmosphere-chip-remove-btn", attr: { "aria-label": `Remove ${c.label ?? c.value}` } }); 283 + setIcon(x, "x"); 284 + x.addEventListener("click", () => { 285 + this.selectedCollections.delete(c.value); 286 + void this.render(); 287 + }); 288 + } 289 + } 290 + } 291 + 292 + private async renderTagsFilter(container: HTMLElement, tagSources: SourceName[]) { 293 + const section = container.createEl("div", { cls: "atmosphere-filter-section" }); 294 + 295 + const titleRow = section.createEl("div", { cls: "atmosphere-filter-title-row" }); 296 + 297 + const pickerBtn = titleRow.createEl("button", { 298 + cls: "atmosphere-filter-picker-btn", 299 + attr: { "aria-label": "Filter tags" }, 300 + }); 301 + setIcon(pickerBtn, "tag"); 302 + pickerBtn.createEl("span", { text: "Tags", cls: "atmosphere-filter-title" }); 303 + 304 + if (tagSources.includes("bookmark")) { 305 + const btn = titleRow.createEl("button", { 306 + cls: "atmosphere-filter-create-btn", 307 + attr: { "aria-label": "New tag" }, 308 + }); 309 + setIcon(btn, "plus"); 310 + btn.addEventListener("click", (e) => { e.stopPropagation(); new CreateTagModal(this.plugin, () => void this.refresh()).open(); }); 311 + } 312 + pickerBtn.addEventListener("click", (e) => void this.showTagsMenu(e, tagSources)); 313 + 314 + if (this.selectedTags.size > 0) { 315 + const chipsRow = section.createEl("div", { cls: "atmosphere-filter-active-chips" }); 316 + const tags = await this.fetchAllTags(tagSources); 317 + for (const t of tags) { 318 + if (!this.selectedTags.has(t.value)) continue; 319 + const chip = chipsRow.createEl("span", { cls: "atmosphere-chip atmosphere-chip-active atmosphere-chip-removable" }); 320 + setIcon(chip, sourceIconId(t.source)) 321 + 322 + chip.createEl("span", { text: t.label ?? t.value }); 323 + const x = chip.createEl("button", { cls: "atmosphere-chip-remove-btn", attr: { "aria-label": `Remove ${t.label ?? t.value}` } }); 324 + setIcon(x, "x"); 325 + x.addEventListener("click", () => { 326 + this.selectedTags.delete(t.value); 327 + void this.render(); 328 + }); 329 + } 330 + } 331 + } 332 + 333 + private async showCollectionsMenu(e: MouseEvent, sources: SourceName[]) { 334 + e.stopPropagation(); 335 + const collections = (await this.fetchAllCollections(sources)) 336 + .sort((a, b) => (a.label ?? a.value).localeCompare(b.label ?? b.value)); 337 + const menu = new Menu(); 338 + for (const c of collections) { 339 + menu.addItem(item => item 340 + .setTitle(c.label ?? c.value) 341 + .setIcon(sourceIconId(c.source)) 342 + .setChecked(this.selectedCollections.has(c.value)) 343 + .onClick(() => { 344 + if (this.selectedCollections.has(c.value)) this.selectedCollections.delete(c.value); 345 + else this.selectedCollections.add(c.value); 346 + void this.render(); 347 + }) 163 348 ); 164 349 } 350 + menu.showAtMouseEvent(e); 351 + } 352 + 353 + private async showTagsMenu(e: MouseEvent, sources: SourceName[]) { 354 + e.stopPropagation(); 355 + const tags = (await this.fetchAllTags(sources)) 356 + .sort((a, b) => (a.label ?? a.value).localeCompare(b.label ?? b.value)); 357 + const menu = new Menu(); 358 + for (const t of tags) { 359 + menu.addItem(item => item 360 + .setTitle(t.label ?? t.value) 361 + .setIcon(sourceIconId(t.source)) 362 + .setChecked(this.selectedTags.has(t.value)) 363 + .onClick(() => { 364 + if (this.selectedTags.has(t.value)) this.selectedTags.delete(t.value); 365 + else this.selectedTags.add(t.value); 366 + void this.render(); 367 + }) 368 + ); 369 + } 370 + menu.showAtMouseEvent(e); 165 371 } 166 372 167 373 private renderItem(container: HTMLElement, item: ATBookmarkItem) { 168 374 const el = container.createEl("div", { cls: "atmosphere-item" }); 169 375 170 376 el.addEventListener("click", (e) => { 171 - // Don't open detail if clicking the edit button 172 377 if ((e.target as HTMLElement).closest(".atmosphere-item-edit-btn")) { 173 378 return; 174 379 } ··· 177 382 }).open(); 178 383 }); 179 384 180 - const header = el.createEl("div", { cls: "atmosphere-item-header" }); 181 385 const source = item.getSource(); 182 - header.createEl("span", { 183 - text: source, 184 - cls: `atmosphere-badge atmosphere-badge-${source}`, 185 - }); 186 386 387 + const header = el.createEl("div", { cls: "atmosphere-item-header" }); 388 + const title = item.getTitle(); 389 + if (title) { 390 + header.createEl("div", { text: title, cls: "atmosphere-item-title" }); 391 + } 187 392 if (item.canEdit()) { 188 393 const editBtn = header.createEl("button", { 189 394 cls: "atmosphere-item-edit-btn", ··· 197 402 }); 198 403 } 199 404 200 - item.render(el); 405 + const content = el.createEl("div", { cls: "atmosphere-item-content" }); 406 + 407 + const tags = item.getTags(); 408 + if (tags.length > 0) { 409 + const tagsContainer = content.createEl("div", { cls: "atmosphere-item-tags" }); 410 + for (const tag of tags) { 411 + tagsContainer.createEl("span", { text: tag, cls: "atmosphere-tag" }); 412 + } 413 + } 414 + 415 + const imageUrl = item.getImageUrl(); 416 + if (imageUrl) { 417 + const img = content.createEl("img", { cls: "atmosphere-item-image" }); 418 + img.src = imageUrl; 419 + img.alt = title || "Image"; 420 + } 421 + 422 + const description = item.getDescription(); 423 + if (description) { 424 + const desc = description.length > 200 ? description.slice(0, 200) + "…" : description; 425 + content.createEl("p", { text: desc, cls: "atmosphere-item-desc" }); 426 + } 427 + 428 + const siteName = item.getSiteName(); 429 + if (siteName) { 430 + content.createEl("span", { text: siteName, cls: "atmosphere-item-site" }); 431 + } 432 + 433 + const url = item.getUrl(); 434 + if (url) { 435 + const link = content.createEl("a", { text: url, href: url, cls: "atmosphere-item-url" }); 436 + link.setAttr("target", "_blank"); 437 + } 201 438 202 439 const footer = el.createEl("div", { cls: "atmosphere-item-footer" }); 203 - footer.createEl("span", { 440 + const footerLeft = footer.createEl("div", { cls: "atmosphere-item-footer-left" }); 441 + const sourceBadge = footerLeft.createEl("span", { cls: `atmosphere-badge atmosphere-badge-${source} atmosphere-item-source-icon` }); 442 + setIcon(sourceBadge, sourceIconId(source)); 443 + footerLeft.createEl("span", { 204 444 text: new Date(item.getCreatedAt()).toLocaleDateString(), 205 445 cls: "atmosphere-date", 206 446 }); 207 447 208 - // Show note indicator for items with attached notes (semble cards) 448 + const center = footer.createEl("div", { cls: "atmosphere-item-footer-center" }); 209 449 const notes = item.getAttachedNotes?.(); 210 450 if (notes && notes.length > 0) { 211 - const noteIndicator = footer.createEl("div", { cls: "atmosphere-note-indicator" }); 451 + const noteIndicator = center.createEl("div", { cls: "atmosphere-note-indicator" }); 212 452 const icon = noteIndicator.createEl("span", { cls: "atmosphere-note-icon" }); 213 453 setIcon(icon, "message-square"); 214 454 noteIndicator.createEl("span", { 215 455 text: `${notes.length} note${notes.length > 1 ? 's' : ''}`, 216 - cls: "atmosphere-note-count" 456 + cls: "atmosphere-note-count", 457 + }); 458 + } 459 + 460 + const right = footer.createEl("div", { cls: "atmosphere-item-footer-right" }); 461 + const collections = item.getCollections?.(); 462 + if (collections && collections.length > 0) { 463 + const collectionIndicator = right.createEl("div", { cls: "atmosphere-collection-indicator" }); 464 + const collectionIcon = collectionIndicator.createEl("span", { cls: "atmosphere-collection-indicator-icon" }); 465 + setIcon(collectionIcon, "folder"); 466 + collectionIndicator.createEl("span", { 467 + text: collections.length === 1 ? collections[0]!.name : `${collections.length} collections`, 468 + cls: "atmosphere-collection-indicator-name", 217 469 }); 218 470 } 219 471 } 220 472 221 473 async onClose() { } 222 474 } 475 + 476 + function sourceIconId(source: "semble" | "bookmark" | "margin"): string { 477 + if (source === "semble") return "atmosphere-semble"; 478 + if (source === "margin") return "atmosphere-margin"; 479 + return "bookmark"; 480 + }
+244 -41
styles.css
··· 65 65 font-weight: var(--font-semibold); 66 66 } 67 67 68 - .atmosphere-source-radio { 68 + .atmosphere-source-toggle { 69 69 display: none; 70 70 } 71 71 ··· 120 120 margin-bottom: 2px; 121 121 } 122 122 123 + 124 + 123 125 .atmosphere-filter-title { 124 126 margin: 0; 125 127 font-size: var(--font-smallest); ··· 133 135 display: flex; 134 136 align-items: center; 135 137 justify-content: center; 136 - width: 18px; 137 - height: 18px; 138 + width: 22px; 139 + height: 22px; 138 140 padding: 0; 139 141 background: transparent; 140 142 border: none; 141 143 border-radius: var(--radius-s); 142 144 cursor: pointer; 143 - color: var(--text-faint); 145 + color: var(--text-muted); 144 146 transition: all 0.15s ease; 145 - opacity: 0.7; 146 147 } 147 148 148 149 .atmosphere-filter-create-btn:hover { 150 + background: var(--background-modifier-hover); 151 + color: var(--interactive-accent); 152 + } 153 + 154 + .atmosphere-filter-create-btn svg { 155 + width: 16px; 156 + height: 16px; 157 + } 158 + 159 + .atmosphere-filter-picker-btn { 160 + display: inline-flex; 161 + align-items: center; 162 + gap: 5px; 163 + height: 26px; 164 + padding: 0 6px 0 4px; 165 + background: transparent; 166 + border: none; 167 + border-radius: var(--radius-s); 168 + cursor: pointer; 169 + color: var(--text-faint); 170 + opacity: 0.7; 171 + transition: all 0.15s ease; 172 + } 173 + 174 + .atmosphere-filter-picker-btn:hover { 149 175 background: var(--background-modifier-hover); 150 176 color: var(--interactive-accent); 151 177 opacity: 1; 152 178 } 153 179 154 - .atmosphere-filter-create-btn svg { 155 - width: 12px; 156 - height: 12px; 180 + .atmosphere-filter-picker-btn svg { 181 + width: 15px; 182 + height: 15px; 183 + } 184 + 185 + .atmosphere-filter-active-chips { 186 + display: flex; 187 + flex-wrap: wrap; 188 + gap: 6px; 189 + margin-top: 4px; 190 + } 191 + 192 + .atmosphere-chip-removable { 193 + display: inline-flex; 194 + align-items: center; 195 + gap: 4px; 196 + padding: 2px 4px 2px 8px; 197 + font-size: var(--font-smallest); 198 + } 199 + 200 + .atmosphere-chip-remove-btn { 201 + display: flex; 202 + align-items: center; 203 + justify-content: center; 204 + width: 14px; 205 + height: 14px; 206 + flex-shrink: 0; 207 + padding: 0; 208 + border: none; 209 + background: transparent; 210 + cursor: pointer; 211 + color: currentColor; 212 + opacity: 0.6; 213 + border-radius: 2px; 214 + line-height: 1; 215 + } 216 + 217 + .atmosphere-chip-remove-btn:hover { 218 + opacity: 1; 219 + } 220 + 221 + .atmosphere-chip-remove-btn svg { 222 + width: 10px; 223 + height: 10px; 224 + display: block; 157 225 } 158 226 159 227 .atmosphere-filter-chips { ··· 166 234 .atmosphere-chip { 167 235 padding: 3px 10px; 168 236 border-radius: var(--radius-m); 169 - border: none; 170 - background: var(--background-modifier-border); 237 + border: 1px solid var(--background-modifier-border); 238 + background: transparent; 171 239 color: var(--text-muted); 172 240 font-size: var(--font-smallest); 173 241 font-weight: var(--font-medium); ··· 177 245 } 178 246 179 247 .atmosphere-chip:hover { 180 - background: var(--background-modifier-border-hover); 248 + border-color: var(--background-modifier-border-hover); 181 249 color: var(--text-normal); 182 - transform: translateY(-1px); 183 250 } 184 251 185 252 .atmosphere-chip-active { 186 - background: var(--interactive-accent); 187 - color: var(--text-on-accent); 253 + background: color-mix(in srgb, var(--interactive-accent) 12%, transparent); 254 + border-color: color-mix(in srgb, var(--interactive-accent) 50%, transparent); 255 + color: var(--interactive-accent); 188 256 font-weight: var(--font-semibold); 189 257 } 190 258 191 259 .atmosphere-chip-active:hover { 192 - background: var(--interactive-accent-hover); 193 - transform: translateY(-1px); 260 + background: color-mix(in srgb, var(--interactive-accent) 20%, transparent); 261 + border-color: var(--interactive-accent); 194 262 } 195 263 196 264 .atmosphere-grid { ··· 223 291 gap: 8px; 224 292 } 225 293 294 + 295 + .atmosphere-item-source-icon svg, 296 + .atmosphere-badge svg { 297 + width: 14px; 298 + height: 14px; 299 + } 300 + 226 301 .atmosphere-item-edit-btn { 227 302 display: flex; 228 303 align-items: center; ··· 256 331 } 257 332 258 333 .atmosphere-badge { 259 - font-size: 10px; 260 - padding: 3px 8px; 261 - border-radius: 12px; 262 - text-transform: capitalize; 263 - font-weight: var(--font-normal); 334 + display: flex; 335 + align-items: center; 264 336 flex-shrink: 0; 265 - letter-spacing: 0.3px; 266 337 } 267 338 339 + 268 340 .atmosphere-badge-semble { 269 - background: color-mix(in srgb, var(--color-orange) 15%, transparent); 270 341 color: var(--color-orange); 271 - border: 1px solid color-mix(in srgb, var(--color-orange) 30%, transparent); 272 342 } 273 343 274 344 .atmosphere-badge-bookmark { 275 - background: color-mix(in srgb, var(--color-cyan) 15%, transparent); 276 345 color: var(--color-cyan); 277 - border: 1px solid color-mix(in srgb, var(--color-cyan) 30%, transparent); 278 346 } 279 347 280 348 .atmosphere-badge-margin { 281 - background: color-mix(in srgb, var(--color-purple) 15%, transparent); 282 - color: var(--color-purple); 283 - border: 1px solid color-mix(in srgb, var(--color-purple) 30%, transparent); 349 + color: #2563eb; 284 350 } 285 351 286 352 .atmosphere-item-footer { 287 - display: flex; 288 - justify-content: space-between; 353 + display: grid; 354 + grid-template-columns: 1fr auto 1fr; 355 + align-items: center; 289 356 font-size: var(--font-smallest); 290 357 color: var(--text-faint); 291 358 margin-top: auto; ··· 293 360 border-top: 1px solid var(--background-modifier-border); 294 361 } 295 362 363 + .atmosphere-item-footer-left { 364 + display: flex; 365 + align-items: center; 366 + gap: 5px; 367 + } 368 + 369 + .atmosphere-item-footer-center { 370 + display: flex; 371 + justify-content: center; 372 + } 373 + 374 + .atmosphere-item-footer-right { 375 + display: flex; 376 + justify-content: flex-end; 377 + } 378 + 296 379 .atmosphere-date { 297 380 font-size: var(--font-smallest); 298 381 color: var(--text-faint); ··· 385 468 } 386 469 387 470 .atmosphere-collection { 471 + display: inline-flex; 472 + align-items: center; 473 + gap: 4px; 388 474 font-size: var(--font-smallest); 389 475 padding: 2px 8px; 390 476 border-radius: var(--radius-s); 391 - background: color-mix(in srgb, var(--color-purple) 10%, transparent); 392 - color: var(--color-purple); 393 - border: 1px solid color-mix(in srgb, var(--color-purple) 30%, transparent); 477 + background: color-mix(in srgb, var(--interactive-accent) 10%, transparent); 478 + color: var(--interactive-accent); 479 + border: 1px solid color-mix(in srgb, var(--interactive-accent) 30%, transparent); 480 + } 481 + 482 + .atmosphere-collection-source-icon { 483 + display: flex; 484 + align-items: center; 485 + flex-shrink: 0; 486 + } 487 + 488 + .atmosphere-collection-source-icon svg { 489 + width: 10px; 490 + height: 10px; 394 491 } 395 492 396 493 .atmosphere-item-collections-section { ··· 429 526 font-size: var(--font-smallest); 430 527 } 431 528 529 + .atmosphere-collection-indicator { 530 + display: flex; 531 + align-items: center; 532 + gap: 4px; 533 + font-size: var(--font-smallest); 534 + color: var(--text-muted); 535 + } 536 + 537 + .atmosphere-collection-indicator-icon { 538 + display: flex; 539 + align-items: center; 540 + color: var(--text-muted); 541 + } 542 + 543 + .atmosphere-collection-indicator-icon svg { 544 + width: 12px; 545 + height: 12px; 546 + } 547 + 548 + .atmosphere-collection-indicator-name { 549 + font-size: var(--font-smallest); 550 + } 551 + 432 552 /* Detail Modal (shared between sources) */ 433 553 .atmosphere-detail-body { 434 554 display: flex; ··· 520 640 color: var(--text-normal); 521 641 } 522 642 643 + .atmosphere-source-toggle-row { 644 + display: flex; 645 + gap: 6px; 646 + margin-bottom: 16px; 647 + } 648 + 649 + .atmosphere-source-toggle-btn { 650 + flex: 1; 651 + padding: 6px 12px; 652 + background: var(--background-modifier-hover); 653 + border: 1px solid var(--background-modifier-border); 654 + border-radius: var(--radius-s); 655 + color: var(--text-muted); 656 + font-size: var(--font-ui-small); 657 + font-weight: var(--font-medium); 658 + cursor: pointer; 659 + transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; 660 + } 661 + 662 + .atmosphere-source-toggle-btn:hover { 663 + background: var(--background-modifier-border); 664 + color: var(--text-normal); 665 + } 666 + 667 + .atmosphere-source-toggle-btn.is-active { 668 + background: color-mix(in srgb, var(--interactive-accent) 12%, transparent); 669 + border-color: color-mix(in srgb, var(--interactive-accent) 50%, transparent); 670 + color: var(--interactive-accent); 671 + font-weight: var(--font-semibold); 672 + } 673 + 523 674 .atmosphere-form { 524 675 display: flex; 525 676 flex-direction: column; ··· 770 921 font-size: 1.1em; 771 922 } 772 923 924 + .atmosphere-detail-collections { 925 + display: flex; 926 + flex-direction: column; 927 + gap: 8px; 928 + margin: 16px 0; 929 + padding: 12px 16px; 930 + background: var(--background-secondary); 931 + border: 1px solid var(--background-modifier-border); 932 + border-radius: var(--radius-m); 933 + } 934 + 935 + .atmosphere-detail-collections-label { 936 + font-size: var(--font-smallest); 937 + font-weight: var(--font-semibold); 938 + color: var(--text-muted); 939 + text-transform: uppercase; 940 + letter-spacing: 0.06em; 941 + } 942 + 943 + .atmosphere-detail-collections-badges { 944 + display: flex; 945 + flex-wrap: wrap; 946 + gap: 6px; 947 + } 948 + 773 949 .atmosphere-semble-detail-notes-section { 774 950 margin-top: 20px; 775 951 padding-top: 20px; ··· 823 999 color: var(--text-on-accent); 824 1000 } 825 1001 826 - .atmosphere-badge-source { 827 - font-size: var(--font-smallest); 828 - opacity: 0.8; 829 - } 830 1002 831 1003 .atmosphere-semble-badge-semble { 832 1004 background: color-mix(in srgb, var(--color-green) 80%, var(--background-primary)); ··· 922 1094 max-width: 600px; 923 1095 } 924 1096 925 - .atmosphere-detail-header { 926 - margin-bottom: 16px; 927 - } 928 1097 929 1098 .atmosphere-detail-footer { 1099 + display: flex; 1100 + align-items: center; 1101 + justify-content: space-between; 930 1102 margin-top: 20px; 931 1103 padding-top: 16px; 932 1104 border-top: 1px solid var(--background-modifier-border); 1105 + } 1106 + 1107 + .atmosphere-detail-footer-left { 1108 + display: flex; 1109 + align-items: center; 1110 + gap: 6px; 1111 + } 1112 + 1113 + .atmosphere-detail-edit-btn { 1114 + display: flex; 1115 + align-items: center; 1116 + justify-content: center; 1117 + width: 28px; 1118 + height: 28px; 1119 + padding: 0; 1120 + background: transparent; 1121 + border: none; 1122 + border-radius: 4px; 1123 + color: var(--text-accent); 1124 + cursor: pointer; 1125 + opacity: 0.6; 1126 + transition: opacity 0.15s ease; 1127 + } 1128 + 1129 + .atmosphere-detail-edit-btn:hover { 1130 + opacity: 1; 1131 + } 1132 + 1133 + .atmosphere-detail-edit-btn svg { 1134 + width: 16px; 1135 + height: 16px; 933 1136 } 934 1137 935 1138 .atmosphere-detail-date {