AT protocol bookmarking platforms in obsidian

add cards view

+593 -6
+20
src/lib.ts
··· 26 26 }, 27 27 }); 28 28 } 29 + 30 + export async function getCollectionLinks(client: Client, repo: string) { 31 + return await client.get("com.atproto.repo.listRecords", { 32 + params: { 33 + repo: repo as ActorIdentifier, 34 + collection: "network.cosmik.collectionLink" as Nsid, 35 + limit: 100, 36 + }, 37 + }); 38 + } 39 + 40 + export async function getRecord(client: Client, repo: string, collection: string, rkey: string) { 41 + return await client.get("com.atproto.repo.getRecord", { 42 + params: { 43 + repo: repo as ActorIdentifier, 44 + collection: collection as Nsid, 45 + rkey, 46 + }, 47 + }); 48 + }
+22
src/main.ts
··· 4 4 import { createAuthenticatedClient, createPublicClient } from "./auth"; 5 5 import { getCollections } from "./lib"; 6 6 import { SembleCollectionsView, VIEW_TYPE_SEMBLE_COLLECTIONS } from "views/collections"; 7 + import { SembleCardsView, VIEW_TYPE_SEMBLE_CARDS } from "views/cards"; 7 8 8 9 export default class MyPlugin extends Plugin { 9 10 settings: AtProtoSettings = DEFAULT_SETTINGS; ··· 17 18 return new SembleCollectionsView(leaf, this); 18 19 }); 19 20 21 + this.registerView(VIEW_TYPE_SEMBLE_CARDS, (leaf) => { 22 + return new SembleCardsView(leaf, this); 23 + }); 24 + 20 25 21 26 this.addCommand({ 22 27 id: "list-collections", ··· 28 33 id: "view-semble-collections", 29 34 name: "View Semble Collections", 30 35 callback: () => this.activateView(VIEW_TYPE_SEMBLE_COLLECTIONS), 36 + }); 37 + 38 + this.addCommand({ 39 + id: "view-semble-cards", 40 + name: "View Semble Cards", 41 + callback: () => this.activateView(VIEW_TYPE_SEMBLE_CARDS), 31 42 }); 32 43 33 44 this.addSettingTab(new SettingTab(this.app, this)); ··· 99 110 if (leaf) { 100 111 workspace.revealLeaf(leaf); 101 112 } 113 + } 114 + 115 + async openCollection(uri: string, name: string) { 116 + const { workspace } = this.app; 117 + const leaf = workspace.getLeaf("tab"); 118 + await leaf.setViewState({ type: VIEW_TYPE_SEMBLE_CARDS, active: true }); 119 + 120 + const view = leaf.view as SembleCardsView; 121 + view.setCollection(uri, name); 122 + 123 + workspace.revealLeaf(leaf); 102 124 } 103 125 104 126 async loadSettings() {
+245
src/views/cards.ts
··· 1 + import { ItemView, WorkspaceLeaf, setIcon } from "obsidian"; 2 + import type MyPlugin from "../main"; 3 + import { getCollections, getCollectionLinks, getCards } from "../lib"; 4 + import type { Main as Card, NoteContent, UrlContent } from "../lexicons/types/network/cosmik/card"; 5 + import type { Main as CollectionLink } from "../lexicons/types/network/cosmik/collectionLink"; 6 + import type { Main as Collection } from "../lexicons/types/network/cosmik/collection"; 7 + import { VIEW_TYPE_SEMBLE_COLLECTIONS } from "./collections"; 8 + 9 + export const VIEW_TYPE_SEMBLE_CARDS = "semble-cards-view"; 10 + 11 + interface CardRecord { 12 + uri: string; 13 + cid: string; 14 + value: Card; 15 + } 16 + 17 + interface CollectionLinkRecord { 18 + uri: string; 19 + value: CollectionLink; 20 + } 21 + 22 + interface CollectionRecord { 23 + uri: string; 24 + value: Collection; 25 + } 26 + 27 + export class SembleCardsView extends ItemView { 28 + plugin: MyPlugin; 29 + collectionUri: string | null = null; 30 + collectionName: string = "All Cards"; 31 + 32 + constructor(leaf: WorkspaceLeaf, plugin: MyPlugin) { 33 + super(leaf); 34 + this.plugin = plugin; 35 + } 36 + 37 + getViewType() { 38 + return VIEW_TYPE_SEMBLE_CARDS; 39 + } 40 + 41 + getDisplayText() { 42 + return this.collectionName; 43 + } 44 + 45 + getIcon() { 46 + return "layers"; 47 + } 48 + 49 + setCollection(uri: string | null, name: string) { 50 + this.collectionUri = uri; 51 + this.collectionName = name; 52 + this.render(); 53 + } 54 + 55 + async onOpen() { 56 + await this.render(); 57 + } 58 + 59 + async getAllCards() { 60 + if (!this.plugin.client) return []; 61 + 62 + const repo = this.plugin.settings.identifier; 63 + const cardsResp = await getCards(this.plugin.client, repo); 64 + if (!cardsResp.ok) return []; 65 + return cardsResp.data.records as unknown as CardRecord[]; 66 + } 67 + 68 + async getCardsInCollection(collectionUri: string) { 69 + if (!this.plugin.client) return []; 70 + 71 + const repo = this.plugin.settings.identifier; 72 + const [linksResp, cardsResp] = await Promise.all([ 73 + getCollectionLinks(this.plugin.client, repo), 74 + getCards(this.plugin.client, repo), 75 + ]); 76 + 77 + if (!linksResp.ok || !cardsResp.ok) return []; 78 + const allLinks = linksResp.data.records as unknown as CollectionLinkRecord[]; 79 + const allCards = cardsResp.data.records as unknown as CardRecord[]; 80 + 81 + // Filter links by collection 82 + const links = allLinks.filter((link) => link.value.collection.uri === collectionUri); 83 + 84 + // Get cards in collection 85 + const cardUris = new Set(links.map((link) => link.value.card.uri as string)); 86 + const cards = allCards.filter((card) => cardUris.has(card.uri as string)); 87 + 88 + return cards; 89 + } 90 + 91 + async render() { 92 + const container = this.contentEl; 93 + container.empty(); 94 + container.addClass("semble-cards-view"); 95 + 96 + if (!this.plugin.client) { 97 + container.createEl("p", { text: "Not connected." }); 98 + return; 99 + } 100 + 101 + const loading = container.createEl("p", { text: "Loading..." }); 102 + 103 + try { 104 + 105 + let cards: CardRecord[] = []; 106 + try { 107 + if (this.collectionUri) { 108 + cards = await this.getCardsInCollection(this.collectionUri); 109 + } else { 110 + cards = await this.getAllCards(); 111 + } 112 + } catch (e) { 113 + loading.remove(); 114 + container.createEl("p", { text: `Failed to load cards: ${e}`, cls: "semble-error" }); 115 + return; 116 + } 117 + 118 + const collectionsResp = await getCollections(this.plugin.client, this.plugin.settings.identifier); 119 + if (!collectionsResp.ok) { 120 + loading.remove(); 121 + container.createEl("p", { text: `Failed to load collections: ${collectionsResp.data?.error}`, cls: "semble-error" }); 122 + return; 123 + } 124 + const collections = collectionsResp.data?.records as unknown as CollectionRecord[]; 125 + 126 + loading.remove(); 127 + 128 + // Render header with back button and filters 129 + this.renderHeader(container, collections); 130 + 131 + if (cards.length === 0) { 132 + container.createEl("p", { text: "No cards found." }); 133 + return; 134 + } 135 + 136 + const grid = container.createEl("div", { cls: "semble-card-grid" }); 137 + for (const record of cards) { 138 + try { 139 + this.renderCard(grid, record.value); 140 + } catch (e) { 141 + console.log(JSON.stringify(record.value, null, 2)); 142 + console.error(`Failed to render card ${record.uri}: ${e}`); 143 + } 144 + } 145 + } catch (e) { 146 + loading.remove(); 147 + container.createEl("p", { text: `Failed to load: ${e}`, cls: "semble-error" }); 148 + } 149 + } 150 + 151 + private renderHeader(container: HTMLElement, collections: CollectionRecord[]) { 152 + const header = container.createEl("div", { cls: "semble-page-header" }); 153 + 154 + const nav = header.createEl("div", { cls: "semble-nav-row" }); 155 + 156 + // Back button 157 + const backBtn = nav.createEl("button", { cls: "semble-back-btn" }); 158 + setIcon(backBtn, "arrow-left"); 159 + backBtn.addEventListener("click", () => { 160 + this.plugin.activateView(VIEW_TYPE_SEMBLE_COLLECTIONS); 161 + }); 162 + 163 + const brand = nav.createEl("span", { text: "Semble", cls: "semble-brand" }); 164 + 165 + header.createEl("h2", { text: this.collectionName, cls: "semble-page-title" }); 166 + 167 + // Filter chips 168 + const filters = container.createEl("div", { cls: "semble-filter-chips" }); 169 + 170 + // All chip 171 + const allChip = filters.createEl("button", { 172 + text: "All", 173 + cls: `semble-chip ${!this.collectionUri ? "semble-chip-active" : ""}`, 174 + }); 175 + allChip.addEventListener("click", () => { 176 + this.setCollection(null, "All Cards"); 177 + }); 178 + 179 + // Collection chips 180 + for (const record of collections) { 181 + const chip = filters.createEl("button", { 182 + text: record.value.name, 183 + cls: `semble-chip ${this.collectionUri === record.uri ? "semble-chip-active" : ""}`, 184 + }); 185 + chip.addEventListener("click", () => { 186 + this.setCollection(record.uri, record.value.name); 187 + }); 188 + } 189 + } 190 + 191 + private renderCard(container: HTMLElement, card: Card) { 192 + const el = container.createEl("div", { cls: "semble-card" }); 193 + 194 + const header = el.createEl("div", { cls: "semble-card-header" }); 195 + header.createEl("span", { 196 + text: card.type, 197 + cls: `semble-badge semble-badge-${card.type.toLowerCase()}`, 198 + }); 199 + 200 + if (card.type === "NOTE") { 201 + const content = card.content as NoteContent; 202 + el.createEl("p", { text: content.text, cls: "semble-card-text" }); 203 + } else if (card.type === "URL") { 204 + const content = card.content as UrlContent; 205 + const meta = content.metadata; 206 + 207 + if (meta?.title) { 208 + el.createEl("div", { text: meta.title, cls: "semble-card-title" }); 209 + } 210 + 211 + if (meta?.imageUrl) { 212 + const img = el.createEl("img", { cls: "semble-card-image" }); 213 + img.src = meta.imageUrl; 214 + img.alt = meta.title || "Image for " + content.url; 215 + } 216 + 217 + if (meta?.description) { 218 + const desc = meta.description.length > 200 219 + ? meta.description.slice(0, 200) + "…" 220 + : meta.description; 221 + el.createEl("p", { text: desc, cls: "semble-card-desc" }); 222 + } 223 + if (meta?.siteName) { 224 + el.createEl("span", { text: meta.siteName, cls: "semble-card-site" }); 225 + } 226 + 227 + const link = el.createEl("a", { 228 + text: content.url, 229 + href: content.url, 230 + cls: "semble-card-url", 231 + }); 232 + link.setAttr("target", "_blank"); 233 + } 234 + 235 + const footer = el.createEl("div", { cls: "semble-card-footer" }); 236 + if (card.createdAt) { 237 + footer.createEl("span", { 238 + text: new Date(card.createdAt).toLocaleDateString(), 239 + cls: "semble-card-date", 240 + }); 241 + } 242 + } 243 + 244 + async onClose() { } 245 + }
+83 -1
src/views/collections.ts
··· 1 - import { ItemView, WorkspaceLeaf } from "obsidian"; 1 + import { ItemView, WorkspaceLeaf, setIcon } from "obsidian"; 2 2 import type MyPlugin from "../main"; 3 3 import { getCollections } from "../lib"; 4 4 import type { Main as Collection } from "../lexicons/types/network/cosmik/collection"; 5 + import { SembleCardsView, VIEW_TYPE_SEMBLE_CARDS } from "./cards"; 5 6 6 7 export const VIEW_TYPE_SEMBLE_COLLECTIONS = "semble-collections-view"; 7 8 ··· 34 35 await this.render(); 35 36 } 36 37 38 + async openCollection(uri: string, name: string) { 39 + const { workspace } = this.app; 40 + const leaf = workspace.getLeaf("tab"); 41 + await leaf.setViewState({ type: VIEW_TYPE_SEMBLE_CARDS, active: true }); 42 + 43 + const view = leaf.view as SembleCardsView; 44 + view.setCollection(uri, name); 45 + 46 + workspace.revealLeaf(leaf); 47 + } 48 + 37 49 async render() { 38 50 const container = this.contentEl; 39 51 container.empty(); ··· 41 53 42 54 container.createEl("h4", { text: "Collections" }); 43 55 56 + if (!this.plugin.client) { 57 + container.createEl("p", { text: "Not connected. Configure credentials in settings." }); 58 + return; 59 + } 60 + 61 + const repo = this.plugin.settings.identifier; 62 + if (!repo) { 63 + container.createEl("p", { text: "No identifier configured in settings." }); 64 + return; 65 + } 66 + 67 + const loading = container.createEl("p", { text: "Loading..." }); 68 + 69 + try { 70 + const resp = await getCollections(this.plugin.client, repo); 71 + loading.remove(); 72 + 73 + if (!resp.ok) { 74 + container.createEl("p", { text: `Error: ${resp.data?.error}`, cls: "semble-error" }); 75 + return; 76 + } 77 + 78 + const records = resp.data.records as unknown as CollectionRecord[]; 79 + 80 + if (records.length === 0) { 81 + container.createEl("p", { text: "No collections found." }); 82 + return; 83 + } 84 + 85 + const grid = container.createEl("div", { cls: "semble-card-grid" }); 86 + 87 + for (const record of records) { 88 + const col = record.value; 89 + const card = grid.createEl("div", { cls: "semble-card" }); 90 + 91 + card.addEventListener("click", () => { 92 + this.plugin.openCollection(record.uri, col.name); 93 + }); 94 + 95 + const header = card.createEl("div", { cls: "semble-card-header" }); 96 + header.createEl("span", { text: col.name, cls: "semble-card-title" }); 97 + 98 + const accessIcon = header.createEl("span", { 99 + cls: `semble-access-icon semble-access-${col.accessType.toLowerCase()}`, 100 + attr: { "aria-label": col.accessType }, 101 + }); 102 + setIcon(accessIcon, col.accessType === "OPEN" ? "globe" : "lock"); 103 + 104 + if (col.description) { 105 + card.createEl("p", { text: col.description, cls: "semble-card-desc" }); 106 + } 107 + 108 + const footer = card.createEl("div", { cls: "semble-card-footer" }); 109 + if (col.createdAt) { 110 + footer.createEl("span", { 111 + text: new Date(col.createdAt).toLocaleDateString(), 112 + cls: "semble-card-date", 113 + }); 114 + } 115 + if (col.collaborators?.length) { 116 + footer.createEl("span", { 117 + text: `${col.collaborators.length} collaborators`, 118 + cls: "semble-card-collabs", 119 + }); 120 + } 121 + } 122 + } catch (e) { 123 + loading.remove(); 124 + container.createEl("p", { text: `Failed to load: ${e}`, cls: "semble-error" }); 125 + } 44 126 } 45 127 46 128 async onClose() {}
+223 -5
styles.css
··· 1 - /* 1 + .semble-card-grid { 2 + display: grid; 3 + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); 4 + gap: 16px; 5 + padding: 8px 0; 6 + } 7 + 8 + .semble-card { 9 + background: var(--background-secondary); 10 + border: 1px solid var(--background-modifier-border); 11 + border-radius: 8px; 12 + padding: 16px; 13 + display: flex; 14 + flex-direction: column; 15 + gap: 8px; 16 + transition: box-shadow 0.15s ease; 17 + cursor: pointer; 18 + } 19 + 20 + .semble-card:hover { 21 + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); 22 + border-color: var(--interactive-accent); 23 + } 24 + 25 + .semble-card-header { 26 + display: flex; 27 + justify-content: space-between; 28 + align-items: flex-start; 29 + gap: 8px; 30 + } 2 31 3 - This CSS file will be included with your plugin, and 4 - available in the app when your plugin is enabled. 32 + .semble-card-title { 33 + font-weight: 600; 34 + font-size: 1.1em; 35 + color: var(--text-normal); 36 + } 5 37 6 - If your plugin does not need CSS, delete this file. 38 + .semble-badge { 39 + font-size: 0.7em; 40 + padding: 2px 8px; 41 + border-radius: 12px; 42 + text-transform: uppercase; 43 + font-weight: 500; 44 + flex-shrink: 0; 45 + } 7 46 8 - */ 47 + .semble-badge-open { 48 + background: var(--color-green); 49 + color: var(--text-on-accent); 50 + } 51 + 52 + .semble-badge-closed { 53 + background: var(--color-orange); 54 + color: var(--text-on-accent); 55 + } 56 + 57 + /* Access type icons */ 58 + .semble-access-icon { 59 + display: flex; 60 + align-items: center; 61 + justify-content: center; 62 + width: 24px; 63 + height: 24px; 64 + border-radius: 50%; 65 + flex-shrink: 0; 66 + } 67 + 68 + .semble-access-icon svg { 69 + width: 14px; 70 + height: 14px; 71 + } 72 + 73 + .semble-access-open { 74 + background: var(--color-green); 75 + color: var(--text-on-accent); 76 + } 77 + 78 + .semble-access-closed { 79 + background: var(--color-orange); 80 + color: var(--text-on-accent); 81 + } 82 + 83 + .semble-card-desc { 84 + color: var(--text-muted); 85 + font-size: 0.9em; 86 + margin: 0; 87 + flex-grow: 1; 88 + } 89 + 90 + .semble-card-footer { 91 + display: flex; 92 + justify-content: space-between; 93 + font-size: 0.8em; 94 + color: var(--text-faint); 95 + margin-top: auto; 96 + padding-top: 8px; 97 + border-top: 1px solid var(--background-modifier-border); 98 + } 99 + 100 + .semble-error { 101 + color: var(--text-error); 102 + } 103 + 104 + /* Card type badges */ 105 + .semble-badge-note { 106 + background: var(--color-blue); 107 + color: var(--text-on-accent); 108 + } 109 + 110 + .semble-badge-url { 111 + background: var(--color-purple); 112 + color: var(--text-on-accent); 113 + } 114 + 115 + /* Page header */ 116 + .semble-page-header { 117 + margin-bottom: 16px; 118 + padding-bottom: 16px; 119 + border-bottom: 1px solid var(--background-modifier-border); 120 + } 121 + 122 + .semble-nav-row { 123 + display: flex; 124 + align-items: center; 125 + gap: 12px; 126 + margin-bottom: 8px; 127 + } 128 + 129 + .semble-brand { 130 + font-size: 0.85em; 131 + font-weight: 600; 132 + color: var(--text-accent); 133 + text-transform: uppercase; 134 + letter-spacing: 0.5px; 135 + } 136 + 137 + .semble-page-title { 138 + margin: 0; 139 + font-size: 1.8em; 140 + font-weight: 700; 141 + color: var(--text-normal); 142 + } 143 + 144 + .semble-card-text { 145 + margin: 0; 146 + white-space: pre-wrap; 147 + line-height: 1.5; 148 + } 149 + 150 + .semble-card-url { 151 + font-size: 0.85em; 152 + color: var(--text-accent); 153 + text-decoration: none; 154 + word-break: break-all; 155 + } 156 + 157 + .semble-card-url:hover { 158 + text-decoration: underline; 159 + } 160 + 161 + .semble-card-site { 162 + font-size: 0.8em; 163 + color: var(--text-faint); 164 + } 165 + 166 + .semble-card-image { 167 + width: 100%; 168 + max-height: 120px; 169 + object-fit: cover; 170 + border-radius: 4px; 171 + margin: 4px 0; 172 + } 173 + 174 + /* Back button */ 175 + .semble-back-btn { 176 + display: flex; 177 + align-items: center; 178 + justify-content: center; 179 + width: 32px; 180 + height: 32px; 181 + padding: 0; 182 + background: transparent; 183 + border: 1px solid var(--background-modifier-border); 184 + border-radius: 6px; 185 + cursor: pointer; 186 + color: var(--text-muted); 187 + } 188 + 189 + .semble-back-btn:hover { 190 + background: var(--background-modifier-hover); 191 + color: var(--text-normal); 192 + } 193 + 194 + /* Filter chips */ 195 + .semble-filter-chips { 196 + display: flex; 197 + flex-wrap: wrap; 198 + gap: 8px; 199 + margin-bottom: 16px; 200 + } 201 + 202 + .semble-chip { 203 + padding: 6px 14px; 204 + border-radius: 16px; 205 + border: 1px solid var(--background-modifier-border); 206 + background: var(--background-secondary); 207 + color: var(--text-muted); 208 + font-size: 0.85em; 209 + cursor: pointer; 210 + transition: all 0.15s ease; 211 + } 212 + 213 + .semble-chip:hover { 214 + background: var(--background-modifier-hover); 215 + color: var(--text-normal); 216 + } 217 + 218 + .semble-chip-active { 219 + background: var(--interactive-accent); 220 + color: var(--text-on-accent); 221 + border-color: var(--interactive-accent); 222 + } 223 + 224 + .semble-chip-active:hover { 225 + background: var(--interactive-accent-hover); 226 + }