AT protocol bookmarking platforms in obsidian

dit modal for cards / add collections

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