mirror of
https://github.com/kevinmcaleer/obsidian-dashboards.git
synced 2026-07-22 06:06:04 +00:00
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:
commit
0c85108dbc
24 changed files with 3469 additions and 0 deletions
51
.github/workflows/release.yml
vendored
Normal file
51
.github/workflows/release.yml
vendored
Normal 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
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
node_modules/
|
||||
main.js
|
||||
*.map
|
||||
.DS_Store
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
180
README.md
Normal 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.
|
||||
|
||||

|
||||
|
||||
## 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 (1–12 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
17
esbuild.config.mjs
Normal 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
23
eslint.config.mjs
Normal 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
9
manifest.json
Normal 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
1763
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
21
package.json
Normal file
21
package.json
Normal 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
49
scripts/release.mjs
Normal 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
130
src/configSerializer.ts
Normal 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
225
src/dashboardRenderer.ts
Normal 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
82
src/main.ts
Normal 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
64
src/types.ts
Normal 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
201
src/widgetEditor.ts
Normal 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
50
src/widgetRenderer.ts
Normal 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}`;
|
||||
}
|
||||
}
|
||||
55
src/widgets/chartWidget.ts
Normal file
55
src/widgets/chartWidget.ts
Normal 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 });
|
||||
}
|
||||
21
src/widgets/embedWidget.ts
Normal file
21
src/widgets/embedWidget.ts
Normal 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);
|
||||
}
|
||||
7
src/widgets/headingWidget.ts
Normal file
7
src/widgets/headingWidget.ts
Normal 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
30
src/widgets/linkWidget.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
13
src/widgets/markdownWidget.ts
Normal file
13
src/widgets/markdownWidget.ts
Normal 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
23
src/widgets/statWidget.ts
Normal 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
413
styles.css
Normal 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
17
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Reference in a new issue