diff --git a/src/history-modal.ts b/src/history-view.ts similarity index 74% rename from src/history-modal.ts rename to src/history-view.ts index 27f0ef3..2b246b2 100644 --- a/src/history-modal.ts +++ b/src/history-view.ts @@ -1,25 +1,37 @@ -import { Modal, normalizePath, Notice, setIcon, TFile } from 'obsidian'; +import { ItemView, normalizePath, Notice, setIcon, TFile, type WorkspaceLeaf } from 'obsidian'; import type SubstackClipperPlugin from './main'; import type { HistoryEntry } from './types'; import { fetchComments, renderComments } from './comments'; -export class HistoryModal extends Modal { +export const HISTORY_VIEW_TYPE = 'substack-clipper-history'; + +export class HistoryView extends ItemView { private plugin: SubstackClipperPlugin; private filterText = ''; private listEl: HTMLElement; private statusEl: HTMLElement; private selectedEntries: Set = new Set(); - constructor(plugin: SubstackClipperPlugin) { - super(plugin.app); + constructor(leaf: WorkspaceLeaf, plugin: SubstackClipperPlugin) { + super(leaf); this.plugin = plugin; } - onOpen(): void { - this.setTitle('Substack save history'); - this.modalEl.addClass('substack-clipper-history-modal'); + getViewType(): string { + return HISTORY_VIEW_TYPE; + } + getDisplayText(): string { + return 'Save history'; + } + + getIcon(): string { + return 'history'; + } + + async onOpen(): Promise { const { contentEl } = this; + contentEl.addClass('substack-clipper-history-view'); const searchInput = contentEl.createEl('input', { type: 'text', @@ -51,6 +63,14 @@ export class HistoryModal extends Modal { this.renderList(); } + async onClose(): Promise { + this.contentEl.empty(); + } + + refresh(): void { + this.renderList(); + } + private getFilteredEntries(): HistoryEntry[] { const reversed = [...this.plugin.settings.history].reverse(); if (!this.filterText) return reversed; @@ -68,29 +88,49 @@ export class HistoryModal extends Modal { if (entries.length === 0) { this.listEl.createDiv({ - text: 'No matching entries.', + text: this.plugin.settings.history.length === 0 + ? 'No saved posts yet.' + : 'No matching entries.', cls: 'substack-clipper-history-empty', }); return; } + const header = this.listEl.createDiv({ cls: 'substack-clipper-history-header' }); + const selectAllCb = header.createEl('input', { type: 'checkbox' }); + selectAllCb.setAttribute('aria-label', 'Select all entries'); + header.createEl('span', { + text: `${String(entries.length)} ${entries.length === 1 ? 'entry' : 'entries'}`, + cls: 'substack-clipper-history-count', + }); + + const rowCheckboxes: { checkbox: HTMLInputElement; idx: number }[] = []; + for (const entry of entries) { const row = this.listEl.createDiv({ cls: 'substack-clipper-history-row' }); const checkbox = row.createEl('input', { type: 'checkbox' }); checkbox.setAttribute('aria-label', `Select ${entry.title}`); const idx = this.plugin.settings.history.indexOf(entry); + rowCheckboxes.push({ checkbox, idx }); + checkbox.addEventListener('change', () => { if (checkbox.checked) { this.selectedEntries.add(idx); } else { this.selectedEntries.delete(idx); } + const total = rowCheckboxes.length; + const checked = rowCheckboxes.filter(r => r.checkbox.checked).length; + selectAllCb.checked = checked === total; + selectAllCb.indeterminate = checked > 0 && checked < total; }); const info = row.createDiv({ cls: 'substack-clipper-history-info' }); info.createEl('span', { text: entry.title, cls: 'substack-clipper-history-title' }); - const userLine = info.createDiv({ cls: 'substack-clipper-history-userline' }); + + const meta = info.createDiv({ cls: 'substack-clipper-history-meta' }); + const userLine = meta.createDiv({ cls: 'substack-clipper-history-userline' }); userLine.createEl('span', { text: entry.username, cls: 'substack-clipper-history-username' }); if (entry.commentCount > 0) { userLine.createEl('span', { @@ -99,12 +139,12 @@ export class HistoryModal extends Modal { }); } - row.createEl('span', { + meta.createEl('span', { text: new Date(entry.dateSaved).toLocaleDateString(), cls: 'substack-clipper-history-date', }); - const actions = row.createDiv({ cls: 'substack-clipper-history-actions' }); + const actions = meta.createDiv({ cls: 'substack-clipper-history-actions' }); const noteBtn = actions.createEl('button', { cls: 'clickable-icon' }); noteBtn.setAttribute('aria-label', 'Open saved note'); @@ -117,7 +157,6 @@ export class HistoryModal extends Modal { const file = this.app.vault.getAbstractFileByPath(notePath); if (file instanceof TFile) { void this.app.workspace.getLeaf(false).openFile(file); - this.close(); } else { new Notice('Note not found in vault.'); } @@ -141,6 +180,18 @@ export class HistoryModal extends Modal { this.renderList(); }); } + + selectAllCb.addEventListener('change', () => { + const checked = selectAllCb.checked; + for (const { checkbox, idx } of rowCheckboxes) { + checkbox.checked = checked; + if (checked) { + this.selectedEntries.add(idx); + } else { + this.selectedEntries.delete(idx); + } + } + }); } private async redownloadComments(btn: HTMLButtonElement): Promise { diff --git a/src/main.ts b/src/main.ts index 788a339..a2f0f21 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,7 +3,7 @@ import { type SubstackClipperSettings, DEFAULT_SETTINGS } from './types'; import type { ParsedArticle, DownloadResult, HistoryEntry } from './types'; import { SettingsTab } from './settings'; import { ClipModal } from './modal'; -import { HistoryModal } from './history-modal'; +import { HistoryView, HISTORY_VIEW_TYPE } from './history-view'; import { fetchHtml, extractPreloads, parsePost, parseUrl, extractImages, extractVideos, extractAudios, extractAttachments } from './parser'; import { htmlToMarkdown } from './converter'; import { postprocessMarkdown } from './postprocess'; @@ -17,6 +17,8 @@ export default class SubstackClipperPlugin extends Plugin { await this.loadSettings(); this.addSettingTab(new SettingsTab(this.app, this)); + this.registerView(HISTORY_VIEW_TYPE, (leaf) => new HistoryView(leaf, this)); + this.addCommand({ id: 'clip-post', name: 'Save substack post', @@ -33,13 +35,25 @@ export default class SubstackClipperPlugin extends Plugin { this.addCommand({ id: 'view-history', - name: 'View history', + name: 'View save history', callback: () => { - new HistoryModal(this).open(); + void this.activateHistoryView(); }, }); } + async activateHistoryView(): Promise { + const { workspace } = this.app; + let leaf = workspace.getLeavesOfType(HISTORY_VIEW_TYPE)[0]; + if (!leaf) { + const rightLeaf = workspace.getRightLeaf(false); + if (!rightLeaf) return; + await rightLeaf.setViewState({ type: HISTORY_VIEW_TYPE, active: true }); + leaf = rightLeaf; + } + await workspace.revealLeaf(leaf); + } + async loadSettings(): Promise { this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData() as Partial); } @@ -228,6 +242,12 @@ export default class SubstackClipperPlugin extends Plugin { } await this.saveSettings(); + for (const leaf of this.app.workspace.getLeavesOfType(HISTORY_VIEW_TYPE)) { + if (leaf.view instanceof HistoryView) { + leaf.view.refresh(); + } + } + } catch (e) { notice.hide(); new Notice(`Save failed: ${e instanceof Error ? e.message : String(e)}`); diff --git a/styles.css b/styles.css index dae16d5..6cfa883 100644 --- a/styles.css +++ b/styles.css @@ -11,10 +11,17 @@ width: 60px; } +.substack-clipper-history-view { + display: flex; + flex-direction: column; + height: 100%; + padding: var(--size-4-3); + gap: var(--size-4-2); +} + .substack-clipper-history-search { width: 100%; padding: var(--size-4-2); - margin-bottom: var(--size-4-2); border: 1px solid var(--background-modifier-border); border-radius: var(--radius-s); background: var(--background-primary); @@ -23,16 +30,33 @@ } .substack-clipper-history-list { - max-height: 400px; + flex: 1; overflow-y: auto; border: 1px solid var(--background-modifier-border); border-radius: var(--radius-s); } -.substack-clipper-history-row { +.substack-clipper-history-header { display: flex; align-items: center; gap: var(--size-4-2); + padding: var(--size-4-1) var(--size-4-3); + border-bottom: 1px solid var(--background-modifier-border); + background: var(--background-secondary); + position: sticky; + top: 0; + z-index: 1; +} + +.substack-clipper-history-count { + color: var(--text-muted); + font-size: var(--font-ui-smaller); +} + +.substack-clipper-history-row { + display: flex; + align-items: flex-start; + gap: var(--size-4-2); padding: var(--size-4-2) var(--size-4-3); min-height: 44px; border-bottom: 1px solid var(--background-modifier-border); @@ -47,19 +71,24 @@ flex-direction: column; flex: 1; min-width: 0; + gap: var(--size-4-1); } .substack-clipper-history-title { font-weight: var(--font-semibold); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; +} + +.substack-clipper-history-meta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--size-4-1) var(--size-4-2); } .substack-clipper-history-userline { display: flex; align-items: center; - gap: var(--size-4-2); + gap: var(--size-4-1); } .substack-clipper-history-username { @@ -88,8 +117,9 @@ .substack-clipper-history-actions { display: flex; - gap: var(--size-4-1); + gap: 0; flex-shrink: 0; + margin-left: auto; } .substack-clipper-history-empty {