Initial dashboards plugin

- Rows and columns layout in a 12-column responsive grid
- Six widget types: markdown, chart, embed, stat, link, heading
- Read/edit mode toggle with pencil icon in top-right
- Widget editor modal with type-specific fields
- Chart widgets delegate to bases-chart via MarkdownRenderer
- Inline chart config OR link to note containing a chart block
- Stats, link cards, heading widgets for KPI dashboards
- Mobile responsive (columns stack below 768px)
- GitHub Actions release workflow matching bases-chart
This commit is contained in:
Kevin McAleer 2026-04-16 20:56:13 +01:00
commit 0c85108dbc
24 changed files with 3469 additions and 0 deletions

51
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,51 @@
name: Release
on:
push:
tags:
- '[0-9]+.[0-9]+.[0-9]+'
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: npm install
- name: Build
run: npm run build
- name: Verify release assets
run: |
test -f main.js || (echo "main.js not found" && exit 1)
test -f manifest.json || (echo "manifest.json not found" && exit 1)
test -f styles.css || (echo "styles.css not found" && exit 1)
echo "All release assets present"
- name: Verify version matches tag
run: |
TAG=${GITHUB_REF#refs/tags/}
MANIFEST_VERSION=$(node -e "console.log(require('./manifest.json').version)")
if [ "$TAG" != "$MANIFEST_VERSION" ]; then
echo "Tag ($TAG) does not match manifest version ($MANIFEST_VERSION)"
exit 1
fi
echo "Version $TAG verified"
- name: Create release
uses: softprops/action-gh-release@v2
with:
files: |
main.js
manifest.json
styles.css
generate_release_notes: true

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
node_modules/
main.js
*.map
.DS_Store

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Kevin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

180
README.md Normal file
View file

@ -0,0 +1,180 @@
# Dashboards
Compose content from across your vault into a dashboard layout — with charts, note embeds, stats, and link cards arranged in rows and columns.
![Obsidian](https://img.shields.io/badge/Obsidian-%3E%3D1.10.0-blueviolet)
## Features
- **Rows & columns grid** — 12-column responsive layout, wraps on mobile
- **Six widget types** — markdown, chart, embed, stat, link, heading
- **Chart integration** — works seamlessly with the [Bases Chart](https://github.com/kevinmcaleer/obsidian-bases-chart) plugin to embed SQL-driven charts
- **Read and edit modes** — clean reading view by default, pencil icon toggles inline editing
- **YAML code block** — dashboard layout is stored as readable YAML in a `dashboard` code block
- **Visual widget editor** — modal with type picker and fields for each widget
## Quick Start
Add a fenced code block with `dashboard`:
````markdown
```dashboard
title: Project overview
rows:
- height: 300
columns:
- width: 6
widget:
type: chart
chart:
type: pie
sql: SELECT COUNT(*) FROM "Projects.base" GROUP BY status
- width: 6
widget:
type: stat
label: Active projects
value: "12"
trend: +3
- columns:
- width: 12
widget:
type: embed
target: "[[Weekly summary]]"
```
````
Click the **pencil icon** in the top-right to switch to edit mode. You can then:
- Click **+ Add row** below the grid
- Click **+ Column** on any row's toolbar
- Click the **+ icon** on an empty column to add a widget (opens the editor modal)
- Adjust column widths with the slider (112 grid units)
- Set row heights in pixels, or leave blank for auto
## Widget Types
### Markdown
Any markdown content — including wikilinks, embeds, and code blocks:
```yaml
widget:
type: markdown
content: |
### Today's tasks
- [[Review PR]]
- [[Write docs]]
```
### Chart (inline)
A bases-chart config embedded directly:
```yaml
widget:
type: chart
chart:
type: bar
sql: SELECT COUNT(*) FROM "Todos.base" GROUP BY status
dataLabels: outside
```
### Chart (linked)
Reference a bases-chart block in another note:
```yaml
widget:
type: chart
source: "[[Charts/Project status]]"
```
### Embed
Transclude another note or section using Obsidian's native embed:
```yaml
widget:
type: embed
target: "[[Weekly summary]]"
```
### Stat
Big-number KPI card with optional trend:
```yaml
widget:
type: stat
label: Active projects
value: "12"
trend: +3
icon: folder
```
### Link
Link card that opens the target note:
```yaml
widget:
type: link
target: "[[Reading list]]"
description: My current reading queue
```
### Heading
A section divider:
```yaml
widget:
type: heading
text: This week
level: 2
```
## Grid System
- 12-column layout (Bootstrap-style). `width: 6` means the column takes half the row.
- If `width` is omitted, columns auto-distribute evenly (`12 / N columns`).
- Row `height` is in pixels. Omit for content-driven height.
- On mobile (≤768px), columns stack vertically.
## Installation
### From Obsidian (once accepted to community plugins)
1. **Settings → Community plugins → Browse**
2. Search for **Dashboards**
3. **Install**, then **Enable**
### From source
```bash
cd /path/to/vault/.obsidian/plugins
git clone https://github.com/YOUR_USERNAME/obsidian-dashboards.git dashboards
cd dashboards
npm install
npm run build
```
Restart Obsidian and enable the plugin in Settings → Community plugins.
### Manual
Download `main.js`, `manifest.json`, and `styles.css` from the [latest release](https://github.com/YOUR_USERNAME/obsidian-dashboards/releases), place them in `.obsidian/plugins/dashboards/`, restart Obsidian, and enable.
## Development
```bash
npm install
npm run dev # one-shot build with source maps
npm run build # production build (minified)
npm run lint # run eslint
npm run release # bump version, tag, and push (triggers GitHub release)
```
## License
MIT

17
esbuild.config.mjs Normal file
View file

@ -0,0 +1,17 @@
import esbuild from "esbuild";
import process from "process";
const prod = process.argv[2] === "production";
esbuild.build({
entryPoints: ["src/main.ts"],
bundle: true,
external: ["obsidian"],
format: "cjs",
target: "es2018",
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,
outfile: "main.js",
minify: prod,
}).catch(() => process.exit(1));

23
eslint.config.mjs Normal file
View file

@ -0,0 +1,23 @@
import tseslint from '@typescript-eslint/eslint-plugin';
import tsparser from '@typescript-eslint/parser';
export default [
{
files: ['src/**/*.ts'],
languageOptions: {
parser: tsparser,
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module',
},
},
plugins: {
'@typescript-eslint': tseslint,
},
rules: {
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'no-unused-vars': 'off',
},
},
];

9
manifest.json Normal file
View file

@ -0,0 +1,9 @@
{
"id": "dashboards",
"name": "Dashboards",
"version": "1.0.0",
"minAppVersion": "1.10.0",
"description": "Compose content from across your vault into a dashboard using a rows and columns layout, with charts, embeds, stats, and more.",
"author": "Kevin",
"isDesktopOnly": false
}

1763
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

21
package.json Normal file
View file

@ -0,0 +1,21 @@
{
"name": "dashboards",
"version": "1.0.0",
"description": "Dashboard layouts for Obsidian notes",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "node esbuild.config.mjs production",
"release": "node scripts/release.mjs",
"lint": "eslint src/"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@typescript-eslint/eslint-plugin": "^8.58.2",
"@typescript-eslint/parser": "^8.58.2",
"esbuild": "^0.21.0",
"eslint": "^10.2.0",
"obsidian": "latest",
"typescript": "^5.4.0"
}
}

49
scripts/release.mjs Normal file
View file

@ -0,0 +1,49 @@
/**
* Bump version, commit, tag, and push triggering the GitHub Actions release workflow.
*
* Usage:
* npm run release # bumps patch (1.0.0 1.0.1)
* npm run release minor # bumps minor (1.0.0 1.1.0)
* npm run release major # bumps major (1.0.0 2.0.0)
* npm run release 1.2.3 # sets exact version
*/
import { readFileSync, writeFileSync } from 'fs';
import { execSync } from 'child_process';
const arg = process.argv[2] || 'patch';
const pkg = JSON.parse(readFileSync('package.json', 'utf8'));
const manifest = JSON.parse(readFileSync('manifest.json', 'utf8'));
const oldVersion = pkg.version;
let newVersion;
if (/^\d+\.\d+\.\d+$/.test(arg)) {
newVersion = arg;
} else {
const parts = oldVersion.split('.').map(Number);
if (arg === 'major') { parts[0]++; parts[1] = 0; parts[2] = 0; }
else if (arg === 'minor') { parts[1]++; parts[2] = 0; }
else { parts[2]++; }
newVersion = parts.join('.');
}
pkg.version = newVersion;
manifest.version = newVersion;
writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
writeFileSync('manifest.json', JSON.stringify(manifest, null, 2) + '\n');
console.log(`Version: ${oldVersion}${newVersion}`);
console.log('Building...');
execSync('npm run build', { stdio: 'inherit' });
console.log('Committing and tagging...');
execSync('git add -A', { stdio: 'inherit' });
execSync(`git commit -m "Release ${newVersion}"`, { stdio: 'inherit' });
execSync(`git tag ${newVersion}`, { stdio: 'inherit' });
console.log('Pushing...');
execSync('git push && git push --tags', { stdio: 'inherit' });
console.log(`\nReleased ${newVersion} — GitHub Actions will create the release.`);

130
src/configSerializer.ts Normal file
View file

@ -0,0 +1,130 @@
import { parseYaml, stringifyYaml } from 'obsidian';
import { DashboardConfig, Row, Column, Widget, WidgetType } from './types';
const VALID_WIDGET_TYPES: WidgetType[] = ['markdown', 'chart', 'embed', 'stat', 'link', 'heading'];
/**
* Parse YAML from a ```dashboard code block.
* Returns null if the YAML is malformed.
*/
export function parseDashboard(source: string): DashboardConfig | null {
try {
const raw = parseYaml(source) as Record<string, unknown> | null;
if (!raw || typeof raw !== 'object') return { rows: [] };
const config: DashboardConfig = { rows: [] };
if (typeof raw.title === 'string') config.title = raw.title;
if (Array.isArray(raw.rows)) {
config.rows = raw.rows.map(parseRow).filter((r): r is Row => r !== null);
}
return config;
} catch {
return null;
}
}
function parseRow(raw: unknown): Row | null {
if (!raw || typeof raw !== 'object') return null;
const r = raw as Record<string, unknown>;
const row: Row = { columns: [] };
if (typeof r.height === 'number') row.height = r.height;
if (Array.isArray(r.columns)) {
row.columns = r.columns.map(parseColumn).filter((c): c is Column => c !== null);
}
return row;
}
function parseColumn(raw: unknown): Column | null {
if (!raw || typeof raw !== 'object') return null;
const c = raw as Record<string, unknown>;
const column: Column = {};
if (typeof c.width === 'number') column.width = Math.max(1, Math.min(12, c.width));
if (c.widget && typeof c.widget === 'object') {
const widget = parseWidget(c.widget);
if (widget) column.widget = widget;
}
return column;
}
function parseWidget(raw: unknown): Widget | null {
if (!raw || typeof raw !== 'object') return null;
const w = raw as Record<string, unknown>;
const type = w.type;
if (typeof type !== 'string' || !VALID_WIDGET_TYPES.includes(type as WidgetType)) return null;
switch (type) {
case 'markdown':
return { type: 'markdown', content: typeof w.content === 'string' ? w.content : '' };
case 'chart': {
const widget: Widget = { type: 'chart' };
if (w.chart && typeof w.chart === 'object') {
(widget as { chart?: Record<string, unknown> }).chart = w.chart as Record<string, unknown>;
}
if (typeof w.source === 'string') {
(widget as { source?: string }).source = w.source;
}
return widget;
}
case 'embed':
if (typeof w.target !== 'string') return null;
return { type: 'embed', target: w.target };
case 'stat':
return {
type: 'stat',
label: typeof w.label === 'string' ? w.label : '',
value: typeof w.value === 'string' ? w.value : String(w.value ?? ''),
trend: typeof w.trend === 'string' ? w.trend : undefined,
icon: typeof w.icon === 'string' ? w.icon : undefined,
};
case 'link':
if (typeof w.target !== 'string') return null;
return {
type: 'link',
target: w.target,
description: typeof w.description === 'string' ? w.description : undefined,
};
case 'heading': {
const level = typeof w.level === 'number' && w.level >= 1 && w.level <= 6
? w.level as 1 | 2 | 3 | 4 | 5 | 6
: 3;
return {
type: 'heading',
text: typeof w.text === 'string' ? w.text : '',
level,
};
}
default:
return null;
}
}
/**
* Serialize a DashboardConfig to YAML for storage in the code block.
*/
export function serializeDashboard(config: DashboardConfig): string {
const obj: Record<string, unknown> = {};
if (config.title) obj.title = config.title;
obj.rows = config.rows.map(row => {
const r: Record<string, unknown> = {};
if (row.height !== undefined) r.height = row.height;
r.columns = row.columns.map(col => {
const c: Record<string, unknown> = {};
if (col.width !== undefined) c.width = col.width;
if (col.widget) c.widget = col.widget;
return c;
});
return r;
});
return stringifyYaml(obj).trimEnd();
}

225
src/dashboardRenderer.ts Normal file
View file

@ -0,0 +1,225 @@
import { App, Component, setIcon } from 'obsidian';
import { DashboardConfig, Row, Column, Widget } from './types';
import { renderWidget, widgetDisplayName } from './widgetRenderer';
import { WidgetEditorModal } from './widgetEditor';
import { serializeDashboard } from './configSerializer';
export type DashboardChangedCallback = (newYaml: string) => void;
export interface DashboardState {
config: DashboardConfig;
editMode: boolean;
}
/**
* Render a dashboard. Returns a cleanup function that should be
* called before re-rendering.
*/
export function renderDashboard(
container: HTMLElement,
config: DashboardConfig,
app: App,
sourcePath: string,
component: Component,
onChanged: DashboardChangedCallback,
): void {
container.empty();
const state: DashboardState = { config, editMode: false };
const root = container.createDiv({ cls: 'dashboard-root' });
// Header with title + mode toggle
const header = root.createDiv({ cls: 'dashboard-header' });
if (config.title) {
header.createDiv({ cls: 'dashboard-title', text: config.title });
} else {
header.createDiv({ cls: 'dashboard-title dashboard-title--empty' });
}
const toggle = header.createEl('button', {
cls: 'dashboard-mode-toggle',
attr: { 'aria-label': 'Toggle edit mode' },
});
setIcon(toggle, 'pencil');
toggle.addEventListener('click', () => {
state.editMode = !state.editMode;
toggle.classList.toggle('is-active', state.editMode);
rebuild();
});
// Rows container
const rowsContainer = root.createDiv({ cls: 'dashboard-rows' });
// Add row button (only in edit mode)
const addRowBtn = root.createEl('button', { cls: 'dashboard-add-row', text: '+ Add row' });
addRowBtn.addEventListener('click', () => {
state.config.rows.push({ columns: [{ width: 12 }] });
emit(state, onChanged);
rebuild();
});
const emitAndRebuild = () => {
emit(state, onChanged);
rebuild();
};
function rebuild(): void {
root.toggleClass('is-edit-mode', state.editMode);
rowsContainer.empty();
state.config.rows.forEach((row, rowIdx) => {
renderRow(rowsContainer, row, rowIdx, state, app, sourcePath, component, emitAndRebuild);
});
if (state.config.rows.length === 0 && !state.editMode) {
rowsContainer.createDiv({
cls: 'dashboard-empty',
text: 'This dashboard is empty. Click the pencil icon to edit.',
});
}
}
rebuild();
}
function renderRow(
parent: HTMLElement,
row: Row,
rowIdx: number,
state: DashboardState,
app: App,
sourcePath: string,
component: Component,
emit: () => void,
): void {
const rowEl = parent.createDiv({ cls: 'dashboard-row' });
if (row.height) {
rowEl.addClass('dashboard-row--sized');
rowEl.setCssProps({ '--dashboard-row-height': `${row.height}px` });
}
// Row toolbar (edit mode only)
if (state.editMode) {
const toolbar = rowEl.createDiv({ cls: 'dashboard-row-toolbar' });
const heightInput = toolbar.createEl('input', {
type: 'number',
cls: 'dashboard-row-height-input',
placeholder: 'Height (px)',
value: row.height ? String(row.height) : '',
});
heightInput.addEventListener('change', () => {
const v = parseInt(heightInput.value, 10);
if (!isNaN(v) && v > 0) row.height = v;
else delete row.height;
emit();
});
const addColBtn = toolbar.createEl('button', { cls: 'dashboard-btn', text: '+ Column' });
addColBtn.addEventListener('click', () => {
row.columns.push({ width: 6 });
emit();
});
const deleteRowBtn = toolbar.createEl('button', {
cls: 'dashboard-btn dashboard-btn--danger',
attr: { 'aria-label': 'Delete row' },
});
setIcon(deleteRowBtn, 'trash-2');
deleteRowBtn.addEventListener('click', () => {
state.config.rows.splice(rowIdx, 1);
emit();
});
}
const grid = rowEl.createDiv({ cls: 'dashboard-row-grid' });
row.columns.forEach((col, colIdx) => {
renderColumn(grid, col, row, colIdx, state, app, sourcePath, component, emit);
});
}
function renderColumn(
parent: HTMLElement,
col: Column,
row: Row,
colIdx: number,
state: DashboardState,
app: App,
sourcePath: string,
component: Component,
emit: () => void,
): void {
const colEl = parent.createDiv({ cls: 'dashboard-column' });
const width = col.width ?? Math.floor(12 / Math.max(1, row.columns.length));
colEl.setCssProps({ '--dashboard-col-width': String(width) });
// Column toolbar (edit mode only)
if (state.editMode) {
const toolbar = colEl.createDiv({ cls: 'dashboard-column-toolbar' });
toolbar.createEl('span', { cls: 'dashboard-column-label', text: `Col ${colIdx + 1}` });
const widthSlider = toolbar.createEl('input', {
type: 'range',
cls: 'dashboard-column-width',
attr: { min: '1', max: '12', step: '1' },
value: String(width),
});
widthSlider.addEventListener('change', () => {
col.width = parseInt(widthSlider.value, 10);
emit();
});
toolbar.createEl('span', { cls: 'dashboard-column-width-label', text: `${width}/12` });
const editBtn = toolbar.createEl('button', {
cls: 'dashboard-btn',
attr: { 'aria-label': col.widget ? 'Edit widget' : 'Add widget' },
});
setIcon(editBtn, col.widget ? 'pencil' : 'plus');
editBtn.addEventListener('click', () => {
new WidgetEditorModal(app, col.widget, (widget: Widget) => {
col.widget = widget;
emit();
}).open();
});
const deleteBtn = toolbar.createEl('button', {
cls: 'dashboard-btn dashboard-btn--danger',
attr: { 'aria-label': 'Delete column' },
});
setIcon(deleteBtn, 'trash-2');
deleteBtn.addEventListener('click', () => {
row.columns.splice(colIdx, 1);
emit();
});
}
const body = colEl.createDiv({ cls: 'dashboard-column-body' });
if (col.widget) {
void renderWidget(body, col.widget, app, sourcePath, component);
} else {
const empty = body.createDiv({ cls: 'dashboard-column-empty' });
if (state.editMode) {
const addBtn = empty.createEl('button', { cls: 'dashboard-add-widget-btn', text: '+ Add widget' });
addBtn.addEventListener('click', () => {
new WidgetEditorModal(app, undefined, (widget: Widget) => {
col.widget = widget;
emit();
}).open();
});
} else {
empty.textContent = '(empty)';
}
}
if (state.editMode) {
const label = colEl.createDiv({ cls: 'dashboard-widget-label' });
label.textContent = col.widget ? widgetDisplayName(col.widget) : 'Empty';
}
}
function emit(state: DashboardState, onChanged: DashboardChangedCallback): void {
onChanged(serializeDashboard(state.config));
}

82
src/main.ts Normal file
View file

@ -0,0 +1,82 @@
import { Plugin, MarkdownPostProcessorContext, Component, TFile } from 'obsidian';
import { parseDashboard } from './configSerializer';
import { renderDashboard } from './dashboardRenderer';
export default class DashboardsPlugin extends Plugin {
onload(): void {
this.registerMarkdownCodeBlockProcessor('dashboard', (source, el, ctx) => {
this.processDashboard(source, el, ctx);
});
this.addCommand({
id: 'insert-dashboard',
name: 'Insert dashboard',
editorCallback: (editor) => {
const template = [
'```dashboard',
'title: New dashboard',
'rows:',
' - columns:',
' - width: 12',
'```',
].join('\n');
editor.replaceSelection(template + '\n');
},
});
}
private processDashboard(
source: string,
el: HTMLElement,
ctx: MarkdownPostProcessorContext,
): void {
const config = parseDashboard(source) ?? { rows: [] };
if (!config) {
el.createDiv({ cls: 'dashboard-error', text: 'Invalid dashboard configuration. Check your YAML syntax.' });
return;
}
// Component lifecycle tracks child renderers (for MarkdownRenderer.render)
const component = new Component();
component.load();
renderDashboard(el, config, this.app, ctx.sourcePath, component, (newYaml) => {
void this.updateCodeBlock(ctx, el, newYaml);
});
// Cleanup observer: when el is removed from DOM, unload the component
const observer = new MutationObserver(() => {
if (!el.isConnected) {
component.unload();
observer.disconnect();
}
});
if (el.parentElement) {
observer.observe(el.parentElement, { childList: true });
}
}
private async updateCodeBlock(
ctx: MarkdownPostProcessorContext,
el: HTMLElement,
newYaml: string,
): Promise<void> {
const file = this.app.vault.getAbstractFileByPath(ctx.sourcePath);
if (!(file instanceof TFile)) return;
const sectionInfo = ctx.getSectionInfo(el);
if (sectionInfo) {
await this.app.vault.process(file, (content) => {
const lines = content.split('\n');
const before = lines.slice(0, sectionInfo.lineStart + 1);
const after = lines.slice(sectionInfo.lineEnd);
return [...before, newYaml, ...after].join('\n');
});
} else {
await this.app.vault.process(file, (content) => {
const regex = /(```dashboard\n)([\s\S]*?)(```)/;
return content.replace(regex, `$1${newYaml}\n$3`);
});
}
}
}

64
src/types.ts Normal file
View file

@ -0,0 +1,64 @@
export type WidgetType = 'markdown' | 'chart' | 'embed' | 'stat' | 'link' | 'heading';
export interface MarkdownWidget {
type: 'markdown';
content: string;
}
export interface ChartWidget {
type: 'chart';
/** Inline bases-chart YAML config (mutually exclusive with source). */
chart?: Record<string, unknown>;
/** Wikilink to a note that contains a bases-chart code block. */
source?: string;
}
export interface EmbedWidget {
type: 'embed';
target: string;
}
export interface StatWidget {
type: 'stat';
label: string;
value: string;
trend?: string;
icon?: string;
}
export interface LinkWidget {
type: 'link';
target: string;
description?: string;
}
export interface HeadingWidget {
type: 'heading';
text: string;
level?: 1 | 2 | 3 | 4 | 5 | 6;
}
export type Widget =
| MarkdownWidget
| ChartWidget
| EmbedWidget
| StatWidget
| LinkWidget
| HeadingWidget;
export interface Column {
/** 1-12 column span (Bootstrap-style). If omitted, columns auto-distribute. */
width?: number;
widget?: Widget;
}
export interface Row {
/** Pixel height, or omitted for content-driven height. */
height?: number;
columns: Column[];
}
export interface DashboardConfig {
title?: string;
rows: Row[];
}

201
src/widgetEditor.ts Normal file
View file

@ -0,0 +1,201 @@
import { App, Modal, Setting, parseYaml, stringifyYaml } from 'obsidian';
import { Widget, WidgetType } from './types';
const WIDGET_TYPES: { value: WidgetType; label: string; description: string }[] = [
{ value: 'markdown', label: 'Markdown', description: 'Text, lists, wikilinks, or any markdown' },
{ value: 'chart', label: 'Chart', description: 'Bases chart — inline config or from another note' },
{ value: 'embed', label: 'Embed', description: 'Transclude another note or section' },
{ value: 'stat', label: 'Stat', description: 'Big-number KPI card' },
{ value: 'link', label: 'Link', description: 'Link card to another note' },
{ value: 'heading', label: 'Heading', description: 'Section header' },
];
export class WidgetEditorModal extends Modal {
private widget: Widget;
private onSave: (widget: Widget) => void;
private body: HTMLElement;
constructor(app: App, initial: Widget | undefined, onSave: (widget: Widget) => void) {
super(app);
this.widget = initial ?? { type: 'markdown', content: '' };
this.onSave = onSave;
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass('dashboard-widget-editor');
contentEl.createEl('h2', { text: 'Edit widget' });
new Setting(contentEl)
.setName('Widget type')
.setDesc('Choose the kind of content this widget displays')
.addDropdown((dd) => {
for (const t of WIDGET_TYPES) dd.addOption(t.value, t.label);
dd.setValue(this.widget.type);
dd.onChange((val) => {
this.widget = this.createDefaultForType(val as WidgetType);
this.renderFields();
});
});
this.body = contentEl.createDiv({ cls: 'dashboard-widget-editor-body' });
this.renderFields();
const buttons = contentEl.createDiv({ cls: 'dashboard-widget-editor-buttons' });
buttons.createEl('button', { text: 'Cancel' })
.addEventListener('click', () => this.close());
const saveBtn = buttons.createEl('button', { cls: 'mod-cta', text: 'Save' });
saveBtn.addEventListener('click', () => {
this.onSave(this.widget);
this.close();
});
}
onClose(): void {
this.contentEl.empty();
}
private createDefaultForType(type: WidgetType): Widget {
switch (type) {
case 'markdown': return { type: 'markdown', content: '' };
case 'chart': return { type: 'chart', chart: { type: 'bar', sql: '' } };
case 'embed': return { type: 'embed', target: '' };
case 'stat': return { type: 'stat', label: '', value: '' };
case 'link': return { type: 'link', target: '' };
case 'heading': return { type: 'heading', text: '', level: 3 };
}
}
private renderFields(): void {
this.body.empty();
switch (this.widget.type) {
case 'markdown': return this.renderMarkdownFields();
case 'chart': return this.renderChartFields();
case 'embed': return this.renderEmbedFields();
case 'stat': return this.renderStatFields();
case 'link': return this.renderLinkFields();
case 'heading': return this.renderHeadingFields();
}
}
private renderMarkdownFields(): void {
if (this.widget.type !== 'markdown') return;
const w = this.widget;
new Setting(this.body)
.setName('Content')
.setDesc('Markdown content. Wikilinks, embeds, and code blocks all work.')
.addTextArea((ta) => {
ta.setValue(w.content).onChange((val) => { w.content = val; });
ta.inputEl.rows = 8;
ta.inputEl.addClass('dashboard-widget-editor-textarea');
});
}
private renderChartFields(): void {
if (this.widget.type !== 'chart') return;
const w = this.widget;
const mode = w.source ? 'source' : 'inline';
new Setting(this.body)
.setName('Chart source')
.setDesc('Inline config, or link to a note that contains a bases-chart block')
.addDropdown((dd) => {
dd.addOption('inline', 'Inline config');
dd.addOption('source', 'Link to note');
dd.setValue(mode);
dd.onChange((val) => {
if (val === 'inline') {
delete w.source;
if (!w.chart) w.chart = { type: 'bar', sql: '' };
} else {
delete w.chart;
if (!w.source) w.source = '';
}
this.renderFields();
});
});
if (w.chart) {
new Setting(this.body)
.setName('Chart YAML')
.setDesc('Bases-chart config: type, sql, title, colors, etc.')
.addTextArea((ta) => {
ta.setValue(stringifyYaml(w.chart).trimEnd()).onChange((val) => {
try {
const parsed = parseYaml(val) as Record<string, unknown> | null;
if (parsed && typeof parsed === 'object') w.chart = parsed;
} catch {
// Ignore parse errors while typing
}
});
ta.inputEl.rows = 10;
ta.inputEl.addClass('dashboard-widget-editor-textarea');
ta.inputEl.addClass('dashboard-widget-editor-code');
});
} else {
new Setting(this.body)
.setName('Note link')
.setDesc('Wikilink to a note with a bases-chart block, e.g. "[[Charts/Projects]]"')
.addText((t) => {
t.setValue(w.source || '').onChange((val) => { w.source = val; });
t.inputEl.addClass('dashboard-widget-editor-input');
});
}
}
private renderEmbedFields(): void {
if (this.widget.type !== 'embed') return;
const w = this.widget;
new Setting(this.body)
.setName('Target')
.setDesc('Wikilink to the note or section to transclude, e.g. "[[Weekly summary]]"')
.addText((t) => {
t.setValue(w.target).onChange((val) => { w.target = val; });
t.inputEl.addClass('dashboard-widget-editor-input');
});
}
private renderStatFields(): void {
if (this.widget.type !== 'stat') return;
const w = this.widget;
new Setting(this.body).setName('Value').addText((t) => {
t.setValue(w.value).onChange((val) => { w.value = val; });
});
new Setting(this.body).setName('Label').addText((t) => {
t.setValue(w.label).onChange((val) => { w.label = val; });
});
new Setting(this.body).setName('Trend').setDesc('Optional, e.g. "+3" or "-5%"').addText((t) => {
t.setValue(w.trend || '').onChange((val) => { w.trend = val || undefined; });
});
new Setting(this.body).setName('Icon').setDesc('Optional Lucide icon name').addText((t) => {
t.setValue(w.icon || '').onChange((val) => { w.icon = val || undefined; });
});
}
private renderLinkFields(): void {
if (this.widget.type !== 'link') return;
const w = this.widget;
new Setting(this.body).setName('Target').setDesc('Note to link to').addText((t) => {
t.setValue(w.target).onChange((val) => { w.target = val; });
});
new Setting(this.body).setName('Description').setDesc('Optional override').addText((t) => {
t.setValue(w.description || '').onChange((val) => { w.description = val || undefined; });
});
}
private renderHeadingFields(): void {
if (this.widget.type !== 'heading') return;
const w = this.widget;
new Setting(this.body).setName('Text').addText((t) => {
t.setValue(w.text).onChange((val) => { w.text = val; });
});
new Setting(this.body).setName('Level').addDropdown((dd) => {
for (let i = 1; i <= 6; i++) dd.addOption(String(i), `H${i}`);
dd.setValue(String(w.level || 3));
dd.onChange((val) => { w.level = parseInt(val, 10) as 1 | 2 | 3 | 4 | 5 | 6; });
});
}
}

50
src/widgetRenderer.ts Normal file
View file

@ -0,0 +1,50 @@
import { App, Component } from 'obsidian';
import { Widget } from './types';
import { renderMarkdownWidget } from './widgets/markdownWidget';
import { renderChartWidget } from './widgets/chartWidget';
import { renderEmbedWidget } from './widgets/embedWidget';
import { renderStatWidget } from './widgets/statWidget';
import { renderLinkWidget } from './widgets/linkWidget';
import { renderHeadingWidget } from './widgets/headingWidget';
export async function renderWidget(
container: HTMLElement,
widget: Widget,
app: App,
sourcePath: string,
component: Component,
): Promise<void> {
container.addClass('dashboard-widget');
container.addClass(`dashboard-widget--${widget.type}`);
try {
switch (widget.type) {
case 'markdown':
return await renderMarkdownWidget(container, widget, app, sourcePath, component);
case 'chart':
return await renderChartWidget(container, widget, app, sourcePath, component);
case 'embed':
return await renderEmbedWidget(container, widget, app, sourcePath, component);
case 'stat':
return renderStatWidget(container, widget);
case 'link':
return renderLinkWidget(container, widget, app);
case 'heading':
return renderHeadingWidget(container, widget);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
container.createDiv({ cls: 'dashboard-widget-error', text: `Widget error: ${message}` });
}
}
export function widgetDisplayName(widget: Widget): string {
switch (widget.type) {
case 'markdown': return 'Markdown';
case 'chart': return widget.source ? `Chart (${widget.source})` : 'Chart (inline)';
case 'embed': return `Embed: ${widget.target}`;
case 'stat': return `Stat: ${widget.label}`;
case 'link': return `Link: ${widget.target}`;
case 'heading': return `Heading: ${widget.text}`;
}
}

View file

@ -0,0 +1,55 @@
import { App, Component, MarkdownRenderer, stringifyYaml } from 'obsidian';
import { ChartWidget } from '../types';
/**
* Render a chart widget. Supports two modes:
* 1. Inline: widget.chart is a bases-chart config object
* 2. Source: widget.source is a wikilink to a note with a bases-chart block
*
* In both cases we delegate rendering to the bases-chart plugin by
* producing a `bases-chart` code block and running it through
* MarkdownRenderer. The bases-chart plugin's registered processor
* picks it up automatically.
*/
export async function renderChartWidget(
container: HTMLElement,
widget: ChartWidget,
app: App,
sourcePath: string,
component: Component,
): Promise<void> {
if (widget.chart) {
const yaml = stringifyYaml(widget.chart).trimEnd();
const markdown = '```bases-chart\n' + yaml + '\n```';
await MarkdownRenderer.render(app, markdown, container, sourcePath, component);
return;
}
if (widget.source) {
const content = await readChartBlockFromNote(app, widget.source);
if (content) {
await MarkdownRenderer.render(app, content, container, sourcePath, component);
} else {
renderError(container, `No bases-chart block found in ${widget.source}`);
}
return;
}
renderError(container, 'Chart widget has neither inline config nor a source link.');
}
async function readChartBlockFromNote(app: App, source: string): Promise<string | null> {
// Extract target from wikilink syntax: [[Some note]] or [[Some note|alias]]
const target = source.replace(/^\[\[|\]\]$/g, '').split('|')[0].trim();
const file = app.metadataCache.getFirstLinkpathDest(target, '');
if (!file) return null;
const content = await app.vault.cachedRead(file);
const match = content.match(/```bases-chart\n([\s\S]*?)```/);
if (!match) return null;
return '```bases-chart\n' + match[1] + '```';
}
function renderError(container: HTMLElement, message: string): void {
container.createDiv({ cls: 'dashboard-widget-error', text: message });
}

View file

@ -0,0 +1,21 @@
import { App, Component, MarkdownRenderer } from 'obsidian';
import { EmbedWidget } from '../types';
/**
* Render an embed widget using Obsidian's native transclusion.
* The target should be a wikilink-style reference like "[[My note]]"
* or "[[My note#Section]]".
*/
export async function renderEmbedWidget(
container: HTMLElement,
widget: EmbedWidget,
app: App,
sourcePath: string,
component: Component,
): Promise<void> {
const target = widget.target.trim();
// Ensure it's wrapped in wikilink brackets for the embed syntax
const wikilink = target.startsWith('[[') ? target : `[[${target}]]`;
const markdown = `!${wikilink}`;
await MarkdownRenderer.render(app, markdown, container, sourcePath, component);
}

View file

@ -0,0 +1,7 @@
import { HeadingWidget } from '../types';
export function renderHeadingWidget(container: HTMLElement, widget: HeadingWidget): void {
const level = widget.level || 3;
const tag = `h${level}` as keyof HTMLElementTagNameMap;
container.createEl(tag, { cls: 'dashboard-heading', text: widget.text });
}

30
src/widgets/linkWidget.ts Normal file
View file

@ -0,0 +1,30 @@
import { App, TFile } from 'obsidian';
import { LinkWidget } from '../types';
export function renderLinkWidget(container: HTMLElement, widget: LinkWidget, app: App): void {
const target = widget.target.replace(/^\[\[|\]\]$/g, '').split('|')[0].trim();
const card = container.createEl('a', { cls: 'dashboard-link' });
card.setAttribute('href', target);
card.dataset.href = target;
card.createDiv({ cls: 'dashboard-link-title', text: target });
if (widget.description) {
card.createDiv({ cls: 'dashboard-link-description', text: widget.description });
} else {
const file = app.metadataCache.getFirstLinkpathDest(target, '');
if (file instanceof TFile) {
const cache = app.metadataCache.getFileCache(file);
const fm = cache?.frontmatter;
if (fm && typeof fm.description === 'string') {
card.createDiv({ cls: 'dashboard-link-description', text: fm.description });
}
}
}
card.addEventListener('click', (e) => {
e.preventDefault();
app.workspace.openLinkText(target, '', false);
});
}

View file

@ -0,0 +1,13 @@
import { App, Component, MarkdownRenderer } from 'obsidian';
import { MarkdownWidget } from '../types';
export async function renderMarkdownWidget(
container: HTMLElement,
widget: MarkdownWidget,
app: App,
sourcePath: string,
component: Component,
): Promise<void> {
const content = widget.content || '';
await MarkdownRenderer.render(app, content, container, sourcePath, component);
}

23
src/widgets/statWidget.ts Normal file
View file

@ -0,0 +1,23 @@
import { setIcon } from 'obsidian';
import { StatWidget } from '../types';
export function renderStatWidget(container: HTMLElement, widget: StatWidget): void {
const card = container.createDiv({ cls: 'dashboard-stat' });
if (widget.icon) {
const iconEl = card.createDiv({ cls: 'dashboard-stat-icon' });
setIcon(iconEl, widget.icon);
}
card.createDiv({ cls: 'dashboard-stat-value', text: widget.value });
card.createDiv({ cls: 'dashboard-stat-label', text: widget.label });
if (widget.trend) {
const trendCls = widget.trend.startsWith('-')
? 'dashboard-stat-trend is-negative'
: widget.trend.startsWith('+')
? 'dashboard-stat-trend is-positive'
: 'dashboard-stat-trend';
card.createDiv({ cls: trendCls, text: widget.trend });
}
}

413
styles.css Normal file
View file

@ -0,0 +1,413 @@
/*
Dashboard root
*/
.dashboard-root {
position: relative;
padding: 8px 0;
}
.dashboard-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 0 12px;
gap: 12px;
}
.dashboard-title {
font-size: 18px;
font-weight: 700;
color: var(--text-normal);
line-height: 1.3;
}
.dashboard-title--empty {
color: transparent;
font-size: 0;
}
.dashboard-mode-toggle {
border: none;
background: transparent;
padding: 4px 6px;
border-radius: 4px;
cursor: pointer;
color: var(--text-muted);
opacity: 0.5;
transition: opacity 0.2s, background 0.2s, color 0.2s;
}
.dashboard-mode-toggle:hover,
.dashboard-mode-toggle.is-active {
opacity: 1;
background: var(--background-modifier-hover);
color: var(--text-normal);
}
.dashboard-mode-toggle.is-active {
color: var(--interactive-accent);
}
/*
Rows & columns
*/
.dashboard-rows {
display: flex;
flex-direction: column;
gap: 12px;
}
.dashboard-row {
position: relative;
}
.dashboard-row-grid {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 12px;
}
.dashboard-row--sized .dashboard-row-grid {
height: var(--dashboard-row-height, auto);
}
.dashboard-column {
grid-column: span var(--dashboard-col-width, 12);
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.dashboard-column-body {
flex: 1;
min-height: 0;
overflow: auto;
}
.dashboard-column-empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 80px;
color: var(--text-faint);
background: var(--background-secondary);
border: 1px dashed var(--background-modifier-border);
border-radius: 6px;
font-size: var(--font-ui-small);
}
.dashboard-add-widget-btn {
background: transparent;
border: 1px dashed var(--background-modifier-border);
border-radius: 6px;
color: var(--text-muted);
padding: 8px 16px;
font-size: var(--font-ui-small);
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.dashboard-add-widget-btn:hover {
color: var(--interactive-accent);
border-color: var(--interactive-accent);
}
/* Mobile: stack everything full-width */
@media (max-width: 768px) {
.dashboard-column {
grid-column: span 12;
}
.dashboard-row--sized .dashboard-row-grid {
height: auto;
}
}
/*
Add row / empty state
*/
.dashboard-add-row {
display: none;
margin-top: 12px;
padding: 8px 12px;
background: transparent;
border: 1px dashed var(--background-modifier-border);
border-radius: 6px;
color: var(--text-muted);
cursor: pointer;
width: 100%;
font-size: var(--font-ui-small);
transition: color 0.15s, border-color 0.15s;
}
.dashboard-add-row:hover {
color: var(--interactive-accent);
border-color: var(--interactive-accent);
}
.dashboard-root.is-edit-mode .dashboard-add-row {
display: block;
}
.dashboard-empty {
padding: 24px;
text-align: center;
color: var(--text-faint);
font-size: var(--font-ui-small);
background: var(--background-secondary);
border-radius: 6px;
}
/*
Edit mode toolbars
*/
.dashboard-row-toolbar,
.dashboard-column-toolbar {
display: none;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
font-size: var(--font-ui-smaller);
color: var(--text-muted);
margin-bottom: 4px;
}
.dashboard-root.is-edit-mode .dashboard-row-toolbar,
.dashboard-root.is-edit-mode .dashboard-column-toolbar {
display: flex;
}
.dashboard-root.is-edit-mode .dashboard-row {
padding: 4px;
border: 1px dashed var(--background-modifier-border);
border-radius: 6px;
}
.dashboard-root.is-edit-mode .dashboard-column {
padding: 4px;
background: var(--background-primary-alt);
border-radius: 4px;
}
.dashboard-btn {
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
padding: 3px 8px;
cursor: pointer;
color: var(--text-normal);
font-size: var(--font-ui-smaller);
display: inline-flex;
align-items: center;
gap: 4px;
transition: background 0.15s, border-color 0.15s;
}
.dashboard-btn:hover {
background: var(--background-modifier-hover);
}
.dashboard-btn--danger:hover {
color: var(--text-error);
border-color: var(--text-error);
}
.dashboard-row-height-input {
width: 90px;
padding: 2px 6px;
font-size: var(--font-ui-smaller);
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
color: var(--text-normal);
}
.dashboard-column-label {
font-weight: 600;
margin-right: 4px;
}
.dashboard-column-width {
flex: 1;
max-width: 120px;
}
.dashboard-column-width-label {
font-variant-numeric: tabular-nums;
min-width: 36px;
}
.dashboard-widget-label {
font-size: var(--font-ui-smaller);
color: var(--text-faint);
padding: 2px 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Hide widget label in read mode */
.dashboard-root:not(.is-edit-mode) .dashboard-widget-label {
display: none;
}
/*
Widget base
*/
.dashboard-widget {
min-height: 40px;
}
.dashboard-widget-error {
padding: 12px;
background: var(--background-secondary);
color: var(--text-normal);
border-left: 3px solid var(--text-error);
border-radius: 4px;
font-size: var(--font-ui-small);
}
/*
Stat widget
*/
.dashboard-stat {
display: flex;
flex-direction: column;
justify-content: center;
padding: 16px;
background: var(--background-secondary);
border-radius: 8px;
height: 100%;
min-height: 100px;
position: relative;
}
.dashboard-stat-icon {
position: absolute;
top: 12px;
right: 12px;
color: var(--text-muted);
opacity: 0.5;
}
.dashboard-stat-value {
font-size: 32px;
font-weight: 700;
color: var(--text-normal);
line-height: 1.1;
}
.dashboard-stat-label {
font-size: var(--font-ui-small);
color: var(--text-muted);
margin-top: 4px;
}
.dashboard-stat-trend {
margin-top: 8px;
font-size: var(--font-ui-smaller);
color: var(--text-muted);
font-weight: 600;
}
.dashboard-stat-trend.is-positive {
color: var(--color-green, #4caf50);
}
.dashboard-stat-trend.is-negative {
color: var(--text-error);
}
/*
Link widget
*/
.dashboard-link {
display: block;
padding: 12px 16px;
background: var(--background-secondary);
border-radius: 8px;
text-decoration: none !important;
color: var(--text-normal);
transition: background 0.15s, transform 0.15s;
height: 100%;
}
.dashboard-link:hover {
background: var(--background-modifier-hover);
transform: translateY(-1px);
}
.dashboard-link-title {
font-weight: 600;
color: var(--interactive-accent);
margin-bottom: 4px;
}
.dashboard-link-description {
font-size: var(--font-ui-small);
color: var(--text-muted);
line-height: 1.4;
}
/*
Heading widget
*/
.dashboard-heading {
margin: 0;
color: var(--text-normal);
}
/*
Widget editor modal
*/
.dashboard-widget-editor-body {
margin: 12px 0;
}
.dashboard-widget-editor-textarea {
width: 100%;
min-height: 120px;
font-family: var(--font-interface);
font-size: var(--font-ui-small);
resize: vertical;
box-sizing: border-box;
}
.dashboard-widget-editor-code {
font-family: var(--font-monospace);
}
.dashboard-widget-editor-input {
width: 100%;
box-sizing: border-box;
}
.dashboard-widget-editor-buttons {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--background-modifier-border);
}
.dashboard-error {
padding: 12px;
background: var(--background-secondary);
color: var(--text-normal);
border-left: 3px solid var(--text-error);
border-radius: 4px;
font-size: var(--font-ui-small);
}

17
tsconfig.json Normal file
View file

@ -0,0 +1,17 @@
{
"compilerOptions": {
"baseUrl": ".",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES6",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "node",
"importHelpers": true,
"isolatedModules": true,
"strictNullChecks": true,
"lib": ["DOM", "ES5", "ES6", "ES7"]
},
"include": ["src/**/*.ts"]
}