diff --git a/README.md b/README.md index 071ddd5..718b324 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ - Full calendar for each stream. - New view for missing notes. - New view for viewing all notes in a stream. +- "All Streams" dashboard view with statistics and quick actions. ## Getting started - Add new stream in settings, "Daily Notes", pointing to where you store your daily notes. There will be a ribbon button and a command to get to Today's Daily Note. Enable Full Stream view to get a view into your stream history. Disable core Daily Note plugin. @@ -26,4 +27,15 @@ ![Each Stream's Daily Note](assets/demo-today.gif) ## View Full Stream -![View Full Stream](assets/demo-full-stream.gif) \ No newline at end of file +![View Full Stream](assets/demo-full-stream.gif) + +## All Streams Dashboard +The new All Streams view provides a comprehensive overview of all your configured streams. Access it via: +- **Ribbon Button**: Click the dashboard icon in the left sidebar +- **Command Palette**: Use "Open All Streams View" + +This view displays each stream as a card showing: +- Total number of files +- Files created this year and month +- Last modified date +- Quick action buttons to open today's note or view the full stream \ No newline at end of file diff --git a/main.ts b/main.ts index 3b705e9..ee27b71 100644 --- a/main.ts +++ b/main.ts @@ -7,7 +7,9 @@ import { OpenTodayStreamCommand } from './src/commands/OpenTodayStreamCommand'; import { StreamSelectionModal } from './src/modals/StreamSelectionModal'; import { CREATE_FILE_VIEW_TYPE, CreateFileView } from './src/views/CreateFileView'; import { STREAM_VIEW_TYPE, StreamView } from './src/views/StreamView'; +import { ALL_STREAMS_VIEW_TYPE, AllStreamsView } from './src/views/AllStreamsView'; import { OpenStreamViewCommand } from './src/commands/OpenStreamViewCommand'; +import { OpenAllStreamsViewCommand } from './src/commands/OpenAllStreamsViewCommand'; const DEFAULT_SETTINGS: StreamsSettings = { streams: [], @@ -36,6 +38,7 @@ export default class StreamsPlugin extends Plugin { this.initializeMobileIntegration(); this.registerCalendarCommands(); this.registerLogCommands(); + this.registerAllStreamsCommands(); this.addSettingTab(new StreamsSettingTab(this.app, this)); this.initializeActiveView(); @@ -99,6 +102,12 @@ export default class StreamsPlugin extends Plugin { STREAM_VIEW_TYPE, (leaf) => this.createStreamViewFromState(leaf) ); + + // Register AllStreamsView + this.registerView( + ALL_STREAMS_VIEW_TYPE, + (leaf) => new AllStreamsView(leaf, this.app) + ); } private createStreamViewFromState(leaf: WorkspaceLeaf): StreamView { @@ -131,6 +140,9 @@ export default class StreamsPlugin extends Plugin { } private initializeAllRibbonIcons(): void { + // Create the main All Streams ribbon icon + this.createAllStreamsIcon(); + // Create icons for all streams (even if hidden) this.settings.streams.forEach(stream => { this.createStreamIcons(stream); @@ -140,6 +152,17 @@ export default class StreamsPlugin extends Plugin { this.updateAllIconVisibility(); } + private createAllStreamsIcon(): void { + this.addRibbonIcon( + 'layout-dashboard', + 'Streams: View All Streams', + () => { + const command = new OpenAllStreamsViewCommand(this.app); + command.execute(); + } + ); + } + private createStreamIcons(stream: Stream): void { // Get or create entry for this stream let streamIcons = this.ribbonIconsByStream.get(stream.id); @@ -743,6 +766,17 @@ export default class StreamsPlugin extends Plugin { }); } + private registerAllStreamsCommands(): void { + this.addCommand({ + id: 'open-all-streams-view', + name: 'Open All Streams View', + callback: () => { + const command = new OpenAllStreamsViewCommand(this.app); + command.execute(); + } + }); + } + private registerCalendarCommands(): void { this.addCommand({ id: 'toggle-calendar-component', diff --git a/src/commands/OpenAllStreamsViewCommand.ts b/src/commands/OpenAllStreamsViewCommand.ts new file mode 100644 index 0000000..18e6723 --- /dev/null +++ b/src/commands/OpenAllStreamsViewCommand.ts @@ -0,0 +1,33 @@ +import { App, WorkspaceLeaf } from 'obsidian'; +import { ALL_STREAMS_VIEW_TYPE, AllStreamsView } from '../views/AllStreamsView'; + +export class OpenAllStreamsViewCommand { + private app: App; + + constructor(app: App) { + this.app = app; + } + + async execute(): Promise { + // Look for existing All Streams view + let leaf: WorkspaceLeaf | null = null; + const existingLeaves = this.app.workspace.getLeavesOfType(ALL_STREAMS_VIEW_TYPE); + + if (existingLeaves.length > 0) { + // Reuse existing leaf + leaf = existingLeaves[0]; + } else { + // Create new leaf + leaf = this.app.workspace.getLeaf('tab'); + } + + // Set the view state + await leaf.setViewState({ + type: ALL_STREAMS_VIEW_TYPE, + state: {} + }); + + // Activate the leaf + this.app.workspace.setActiveLeaf(leaf, { focus: true }); + } +} diff --git a/src/views/AllStreamsView.ts b/src/views/AllStreamsView.ts new file mode 100644 index 0000000..d2649b9 --- /dev/null +++ b/src/views/AllStreamsView.ts @@ -0,0 +1,292 @@ +import { App, ItemView, TFile, TFolder, WorkspaceLeaf } from 'obsidian'; +import { Stream } from '../../types'; +import { Logger } from '../utils/Logger'; + +export const ALL_STREAMS_VIEW_TYPE = 'streams-all-streams-view'; + +export class AllStreamsView extends ItemView { + public app: App; + private log: Logger; + private streamsContainer: HTMLElement; + private streams: Stream[] = []; + + constructor(leaf: WorkspaceLeaf, app: App) { + super(leaf); + this.app = app; + this.log = new Logger(); + } + + getViewType(): string { + return ALL_STREAMS_VIEW_TYPE; + } + + getDisplayText(): string { + return 'All Streams'; + } + + getIcon(): string { + return 'layout-dashboard'; + } + + async onOpen(): Promise { + this.log.debug('Opening All Streams view'); + + const container = this.containerEl.children[1]; + container.empty(); + container.addClass('streams-all-streams-container'); + + // Get streams from plugin settings + const plugin = this.app.plugins.plugins['streams']; + if (plugin) { + this.streams = plugin.settings.streams; + } + + // Create header + const header = container.createDiv('streams-all-streams-header'); + header.createEl('h1', { text: 'All Streams' }); + header.createEl('p', { + text: `You have ${this.streams.length} stream${this.streams.length !== 1 ? 's' : ''} configured`, + cls: 'streams-all-streams-subtitle' + }); + + // Create streams container + this.streamsContainer = container.createDiv('streams-all-streams-grid'); + + // Load and display streams + await this.loadStreamsData(); + } + + private async loadStreamsData(): Promise { + if (this.streams.length === 0) { + this.showNoStreamsMessage(); + return; + } + + // Clear existing content + this.streamsContainer.empty(); + + // Create cards for each stream + for (const stream of this.streams) { + const card = await this.createStreamCard(stream); + this.streamsContainer.appendChild(card); + } + } + + private async createStreamCard(stream: Stream): Promise { + const card = document.createElement('div'); + card.className = 'streams-all-streams-card'; + + // Get stream statistics + const stats = await this.getStreamStats(stream); + + // Create card header + const header = card.createDiv('streams-all-streams-card-header'); + const icon = header.createDiv('streams-all-streams-card-icon'); + // Use a simple icon representation + icon.innerHTML = `${stream.icon.charAt(0).toUpperCase()}`; + + const title = header.createEl('h3', { text: stream.name }); + title.className = 'streams-all-streams-card-title'; + + // Create card content + const content = card.createDiv('streams-all-streams-card-content'); + + // Folder path + const folderInfo = content.createDiv('streams-all-streams-card-folder'); + folderInfo.innerHTML = `Folder: ${stream.folder || 'Root'}`; + + // Statistics + const statsContainer = content.createDiv('streams-all-streams-card-stats'); + + // Total files + const totalFiles = statsContainer.createDiv('streams-all-streams-stat'); + totalFiles.innerHTML = `Total Files: ${stats.totalFiles}`; + + // This year files + const thisYearFiles = statsContainer.createDiv('streams-all-streams-stat'); + thisYearFiles.innerHTML = `This Year: ${stats.thisYearFiles}`; + + // This month files + const thisMonthFiles = statsContainer.createDiv('streams-all-streams-stat'); + thisMonthFiles.innerHTML = `This Month: ${stats.thisMonthFiles}`; + + // Last modified + if (stats.lastModified) { + const lastModified = statsContainer.createDiv('streams-all-streams-stat'); + lastModified.innerHTML = `Last Modified: ${stats.lastModified}`; + } + + // Create card actions + const actions = card.createDiv('streams-all-streams-card-actions'); + + // Today button + const todayBtn = actions.createEl('button', { text: 'Today' }); + todayBtn.className = 'streams-all-streams-card-btn streams-all-streams-card-btn-primary'; + todayBtn.addEventListener('click', () => { + this.openTodayStream(stream); + }); + + // View All button + const viewAllBtn = actions.createEl('button', { text: 'View All' }); + viewAllBtn.className = 'streams-all-streams-card-btn streams-all-streams-card-btn-secondary'; + viewAllBtn.addEventListener('click', () => { + this.openStreamView(stream); + }); + + return card; + } + + private async getStreamStats(stream: Stream): Promise<{ + totalFiles: number; + thisYearFiles: number; + thisMonthFiles: number; + lastModified: string | null; + }> { + try { + if (!stream.folder) { + return { totalFiles: 0, thisYearFiles: 0, thisMonthFiles: 0, lastModified: null }; + } + + const folder = this.app.vault.getAbstractFileByPath(stream.folder); + if (!(folder instanceof TFolder)) { + return { totalFiles: 0, thisYearFiles: 0, thisMonthFiles: 0, lastModified: null }; + } + + const files = this.getFilesInFolder(folder); + const totalFiles = files.length; + + const now = new Date(); + const thisYear = now.getFullYear(); + const thisMonth = now.getMonth(); + + let thisYearFiles = 0; + let thisMonthFiles = 0; + let lastModified: Date | null = null; + + for (const file of files) { + if (file instanceof TFile) { + // Check if it's a date-based file (YYYY-MM-DD.md) + const dateMatch = file.basename.match(/^\d{4}-\d{2}-\d{2}$/); + if (dateMatch) { + const fileDate = new Date(file.basename); + if (fileDate.getFullYear() === thisYear) { + thisYearFiles++; + } + if (fileDate.getFullYear() === thisYear && fileDate.getMonth() === thisMonth) { + thisMonthFiles++; + } + } + + // Track last modified + if (!lastModified || file.stat.mtime > lastModified.getTime()) { + lastModified = new Date(file.stat.mtime); + } + } + } + + return { + totalFiles, + thisYearFiles, + thisMonthFiles, + lastModified: lastModified ? this.formatDate(lastModified) : null + }; + } catch (error) { + this.log.error('Error getting stream stats:', error); + return { totalFiles: 0, thisYearFiles: 0, thisMonthFiles: 0, lastModified: null }; + } + } + + private getFilesInFolder(folder: TFolder): TFile[] { + const files: TFile[] = []; + + function recurseFolder(currentFolder: TFolder) { + for (const child of currentFolder.children) { + if (child instanceof TFile) { + files.push(child); + } else if (child instanceof TFolder) { + recurseFolder(child); + } + } + } + + recurseFolder(folder); + return files; + } + + private formatDate(date: Date): string { + const now = new Date(); + const diffTime = Math.abs(now.getTime() - date.getTime()); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + if (diffDays === 0) { + return 'Today'; + } else if (diffDays === 1) { + return 'Yesterday'; + } else if (diffDays < 7) { + return `${diffDays} days ago`; + } else if (diffDays < 30) { + const weeks = Math.floor(diffDays / 7); + return `${weeks} week${weeks !== 1 ? 's' : ''} ago`; + } else { + return date.toLocaleDateString(); + } + } + + private showNoStreamsMessage(): void { + this.streamsContainer.innerHTML = ` +
+
📝
+

No Streams Configured

+

You haven't configured any streams yet. Go to Settings → Streams to add your first stream.

+
+ `; + } + + private openTodayStream(stream: Stream): void { + // Use the existing command to open today's stream + try { + this.app.commands.executeCommandById(`open-${stream.id}`); + } catch (error) { + this.log.error('Error executing today command:', error); + // Fallback: try to open the stream directly + this.openStreamDate(stream, new Date()); + } + } + + private openStreamView(stream: Stream): void { + // Use the existing command to open the full stream view + try { + this.app.commands.executeCommandById(`view-${stream.id}`); + } catch (error) { + this.log.error('Error executing view command:', error); + // Fallback: try to open the stream view directly + this.openStreamViewDirect(stream); + } + } + + private async openStreamDate(stream: Stream, date: Date): Promise { + // Direct fallback for opening stream date + const plugin = this.app.plugins.plugins['streams']; + if (plugin) { + // Import and use the utility function directly + const { openStreamDate } = await import('../utils/streamUtils'); + openStreamDate(this.app, stream, date); + } + } + + private openStreamViewDirect(stream: Stream): void { + // Direct fallback for opening stream view + const plugin = this.app.plugins.plugins['streams']; + if (plugin) { + // Import and use the command directly + import('../commands/OpenStreamViewCommand').then(({ OpenStreamViewCommand }) => { + const command = new OpenStreamViewCommand(this.app, stream); + command.execute(); + }); + } + } + + async onClose(): Promise { + this.log.debug('Closing All Streams view'); + } +} diff --git a/styles.css b/styles.css index 5838b5a..11f8798 100644 --- a/styles.css +++ b/styles.css @@ -1,3 +1,216 @@ +/********************************************************* + * ALL STREAMS VIEW STYLES + *********************************************************/ + +.streams-all-streams-container { + padding: 20px; + max-width: 1200px; + margin: 0 auto; +} + +.streams-all-streams-header { + text-align: center; + margin-bottom: 40px; +} + +.streams-all-streams-header h1 { + font-size: 2.5em; + margin-bottom: 10px; + color: var(--text-normal); +} + +.streams-all-streams-subtitle { + font-size: 1.2em; + color: var(--text-muted); + margin: 0; +} + +.streams-all-streams-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); + gap: 24px; + margin-bottom: 40px; +} + +.streams-all-streams-card { + background: var(--background-secondary); + border-radius: 12px; + padding: 24px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + border: 1px solid var(--background-modifier-border); + transition: all 0.3s ease; + position: relative; + overflow: hidden; +} + +.streams-all-streams-card:hover { + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); + border-color: var(--text-accent); +} + +.streams-all-streams-card-header { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 1px solid var(--background-modifier-border); +} + +.streams-all-streams-card-icon { + width: 48px; + height: 48px; + background: var(--background-primary); + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-accent); + border: 2px solid var(--background-modifier-border); +} + +.streams-all-streams-card-icon-text { + font-size: 20px; + font-weight: bold; + color: var(--text-accent); +} + +.streams-all-streams-card-title { + margin: 0; + font-size: 1.4em; + font-weight: 600; + color: var(--text-normal); + flex: 1; +} + +.streams-all-streams-card-content { + margin-bottom: 24px; +} + +.streams-all-streams-card-folder { + margin-bottom: 20px; + padding: 12px; + background: var(--background-primary); + border-radius: 8px; + border: 1px solid var(--background-modifier-border); + font-family: var(--font-monospace); + font-size: 0.9em; +} + +.streams-all-streams-card-folder code { + background: var(--background-secondary); + padding: 2px 6px; + border-radius: 4px; + color: var(--text-accent); +} + +.streams-all-streams-card-stats { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} + +.streams-all-streams-stat { + display: flex; + flex-direction: column; + gap: 4px; +} + +.streams-all-streams-stat-label { + font-size: 0.85em; + color: var(--text-muted); + font-weight: 500; +} + +.streams-all-streams-stat-value { + font-size: 1.2em; + font-weight: 600; + color: var(--text-normal); +} + +.streams-all-streams-card-actions { + display: flex; + gap: 12px; +} + +.streams-all-streams-card-btn { + flex: 1; + padding: 10px 16px; + border: none; + border-radius: 8px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + font-size: 0.9em; +} + +.streams-all-streams-card-btn-primary { + background: var(--text-accent); + color: var(--text-on-accent); +} + +.streams-all-streams-card-btn-primary:hover { + background: var(--text-accent-hover); + transform: translateY(-1px); +} + +.streams-all-streams-card-btn-secondary { + background: var(--background-primary); + color: var(--text-normal); + border: 1px solid var(--background-modifier-border); +} + +.streams-all-streams-card-btn-secondary:hover { + background: var(--background-secondary); + border-color: var(--text-accent); +} + +.streams-all-streams-empty { + text-align: center; + padding: 60px 20px; + color: var(--text-muted); +} + +.streams-all-streams-empty-icon { + font-size: 4em; + margin-bottom: 20px; + opacity: 0.5; +} + +.streams-all-streams-empty h3 { + margin-bottom: 16px; + color: var(--text-normal); +} + +.streams-all-streams-empty p { + font-size: 1.1em; + line-height: 1.6; + max-width: 400px; + margin: 0 auto; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .streams-all-streams-grid { + grid-template-columns: 1fr; + gap: 16px; + } + + .streams-all-streams-card { + padding: 20px; + } + + .streams-all-streams-card-stats { + grid-template-columns: 1fr; + gap: 12px; + } + + .streams-all-streams-card-actions { + flex-direction: column; + } +} + /********************************************************* * LAYOUT & CONTAINER STYLES *********************************************************/