@neanic/dashboard-vue
v0.7.1
Published
A reusable, ZED-inspired Vue 3 admin shell — installed as a plugin.
Readme
@neanic/dashboard-vue
A reusable, ZED-editor-inspired admin shell for Vue 3, installed as a plugin. It gives you a complete application frame — top bar, dockable side panels, a command palette, a customizable widget dashboard, theming and a settings system — and you fill it with self-contained feature modules. The backend is pluggable behind one transport-agnostic interface, so the same app runs against an in-process mock, HTTP+SSE or a WebSocket with no module changes.
import { createApp } from 'vue'
import { createDashboard } from '@neanic/dashboard-vue'
import '@neanic/dashboard-vue/style.css'
import App from './App.vue'
createApp(App).use(createDashboard()).mount('#app')That one call gives you a running admin app. Everything below is how to make it yours.
Highlights
- Plugin install — one
app.use(createDashboard(config)), configured by a single typed object. - Module system — a feature is one object: it contributes routes, navigation, dockable panels, commands, settings sections and dashboard widgets. The shell wires them in; no shell code changes.
- Backend-agnostic — modules code against
DashboardClient; swap the transport (mock / HTTP+SSE / WebSocket / your own) in one line. - Built-in surfaces — command palette (
⌘K), preferences (⌘,), a customizable widget dashboard, movable/resizable docks, dark/light/contrast themes, a responsive compact layout. - Embeddable — mount the whole shell under any base path (e.g.
/admin) inside a larger app. - Ships compiled CSS and full TypeScript declarations.
Installation
bun add @neanic/dashboard-vue
# peer dependencies — your app provides the shared copies:
bun add vue vue-router @tanstack/vue-query
# optional — the examples use Lucide icons; `icon` fields accept any component:
bun add @lucide/vue@neanic/dashboard-vue requires Vue 3, Vue Router and
@tanstack/vue-query as peer dependencies (it uses TanStack Query for module
data fetching). npm/pnpm work the same way.
Quick start
A consuming app needs three things: a root component that renders
<RouterView />, the plugin install, and the stylesheet.
<!-- App.vue — the root component -->
<template>
<RouterView />
</template>// main.ts
import { createApp } from 'vue'
import { createDashboard, createMockClient } from '@neanic/dashboard-vue'
import '@neanic/dashboard-vue/style.css'
import App from './App.vue'
createApp(App)
.use(createDashboard({ client: createMockClient([]) }))
.mount('#app')createDashboard registers the shell as a route layout and installs the
router, so the root component only renders <RouterView />. Out of the box you
get:
- a top bar and status bar,
- the Navigator panel (left dock),
- the command palette (
⌘K/Ctrl+K) and Preferences (⌘,), - the
/dashboardroute — a customizable widget canvas (/redirects to it), - the
/settingsroute, - dark / light / contrast themes with a configurable accent.
Configuration — createDashboard(config)
interface DashboardConfig {
/** Feature modules mounted on top of the built-in core. Default: none. */
modules?: AppModule[]
/** The backend adapter. Default: an empty in-process mock. */
client?: DashboardClient
/** An existing router to register the shell's routes on. Default: created. */
router?: Router
/** Path the shell mounts under — set e.g. `/admin` to embed. Default: `/`. */
basePath?: string
/** App identity — the name shown in the top bar. */
branding?: { name?: string }
/** Theme defaults and which themes the user may choose. */
theme?: {
/** Theme used until the user picks one. Default: `'dark'`. */
default?: 'dark' | 'light' | 'contrast'
/** Accent color used until the user picks one. */
accent?: string
/** Themes offered in Appearance settings. Default: all. */
available?: ('dark' | 'light' | 'contrast')[]
}
/** Optional built-in features, all off by default. */
features?: {
/** The Notes scratchpad panel in the right dock. Default: `false`. */
notesPanel?: boolean
}
/** The optional command queue — enabled by supplying a transport. */
commandQueue?: { transport: CommandTransport }
}Every field is optional — createDashboard() with no arguments is valid.
modules— your feature modules (see below). The built-incore,dashboardandsettingsmodules are always included.client— how the app reaches its backend (see Backends).router— pass your app's existingRouterand the shell adds its routes to it; omit it and the shell creates one.basePath— mount the shell under a sub-path. WithbasePath: '/admin'the dashboard lives at/admin,/admin/dashboard, … — useful for embedding the admin area inside a larger site. Nav links are prefixed automatically.branding.name— the app name shown in the top bar (defaults toDashboard).theme—defaultandaccentseed the look until the user changes it in Preferences;availablerestricts the themes Appearance offers. A user's saved choice always wins overdefault/accent.features— switches for optional built-ins the shell ships but does not enable.notesPanel: trueadds a Notes scratchpad panel to the right dock (saved to the browser'slocalStorage).commandQueue— supply aCommandTransportand the shell adds a Commands panel where queued write operations run through your transport and report their outcome (see Command queue).
Feature modules
A module is a plain object describing what a feature contributes. Build one
with defineModule (an identity helper that gives you full type-checking):
// modules/reports/index.ts
import { defineModule } from '@neanic/dashboard-vue'
import { FileBarChart } from '@lucide/vue'
import ReportsView from './ReportsView.vue'
import ReportsWidget from './ReportsWidget.vue'
export const reportsModule = defineModule({
id: 'reports',
title: 'Reports',
order: 20,
routes: [
{ path: '/reports', name: 'reports', component: ReportsView,
meta: { title: 'Reports' } },
],
nav: [
{ id: 'reports', label: 'Reports', to: '/reports', icon: FileBarChart,
order: 20 },
],
widgets: [
{ id: 'reports.summary', title: 'Reports summary', component: ReportsWidget,
defaultWidth: 2 },
],
})app.use(createDashboard({ modules: [reportsModule] }))What a module can contribute
interface AppModule {
id: string
title: string
order?: number // lower registers first
routes?: RouteRecordRaw[] // Vue Router routes
nav?: NavItem[]
panels?: PanelDef[]
commands?: Command[]
settings?: SettingsSection[]
widgets?: WidgetDef[]
}| Contribution | Surfaces as | Key fields |
|---|---|---|
| routes | center content, URL-addressable | standard Vue Router RouteRecordRaw |
| nav | entries in the Navigator panel | { id, label, to, icon?, order?, group? } |
| panels | dockable side panels | { id, title, component, icon?, defaultSide?, defaultOpen?, order? } |
| commands | command palette + keyboard shortcuts | { id, title, run, icon?, shortcut? } |
| settings | Preferences modal or /settings | { id, title, scope, component, icon?, order? } |
| widgets | tiles on the /dashboard canvas | { id, title, component, icon?, defaultWidth?, order? } |
navitems with the samegrouprender together under that heading.panelsdefaultSideis'left'or'right'; users can move and resize them at runtime.commandsshortcutusesmodfor ⌘/Ctrl, e.g.'mod+shift+k'.settingsscopeis'user'(renders in the Preferences modal) or'workspace'(renders in the/settingsroute).widgetsdefaultWidthis a 1–4 column span; users add, reorder and resize widgets and the layout persists tolocalStorage.
Every type is exported from @neanic/dashboard-vue for explicit annotation
(AppModule, NavItem, PanelDef, Command, SettingsSection,
WidgetDef, …).
Backends
Modules never talk to a transport directly — they use a DashboardClient:
interface DashboardClient {
initialize(): Promise<BackendManifest>
query<T>(module: string, op: string, params?: unknown): Promise<T>
subscribe<T>(module: string, topic: string, handler: (data: T) => void): Unsubscribe
}Inside a module, reach it with useDashboardClient():
import { useDashboardClient } from '@neanic/dashboard-vue'
const client = useDashboardClient()
const rows = await client.query<Report[]>('reports', 'list')
const stop = client.subscribe<Report[]>('reports', 'feed', (rows) => { /* … */ })Pick the transport with config.client — three adapters ship with the package:
import {
createMockClient, // in-process, no wire — great for development
createHttpClient, // HTTP for queries + Server-Sent Events for subscriptions
createWebSocketClient, // one duplex socket
} from '@neanic/dashboard-vue'
createDashboard({ client: createHttpClient({ baseUrl: 'https://api.example.com' }) })
createDashboard({ client: createWebSocketClient({ url: 'wss://api.example.com/ws' }) })The mock adapter is assembled from per-module in-process backends — the fastest way to develop against realistic data:
import { createMockClient, type MockBackendModule } from '@neanic/dashboard-vue'
const reportsBackend: MockBackendModule = {
manifest: { id: 'reports', version: 1, queries: ['list'], subscriptions: ['feed'] },
query: (op) => (op === 'list' ? loadReports() : Promise.reject({ code: 'not_found', message: op })),
subscribe: (topic, emit) => { /* … */ return () => {} },
}
createDashboard({ client: createMockClient([reportsBackend]) })Any backend that speaks the Dashboard Protocol works unchanged — a Node/Bun
service, a Rust crate, a .NET app. The wire contract, the SSE/WebSocket
formats, and how to write your own adapter are documented in
docs/backend-adapters.md. A complete reference
backend (HTTP + WebSocket, TypeScript) lives in
examples/reference-server/.
Command queue
An optional panel where write operations (saves, deletes, CQRS commands) queue up, run asynchronously, and report their outcome — with operation name, payload, queue/settle timestamps and a success or failure message per entry.
The shell owns the queue and the UI; you own the wire. Enabling it means
implementing one interface and passing it to createDashboard:
import type { CommandTransport } from '@neanic/dashboard-vue'
const transport: CommandTransport = {
async dispatch(command, report) {
// REST style — settle as soon as the request round-trips:
const res = await fetch(`/api/v1/${command.name}`, {
method: 'POST',
body: JSON.stringify(command.payload),
})
if (res.ok) report.complete('Saved.')
else report.fail(`HTTP ${res.status}`)
},
}
app.use(createDashboard({ commandQueue: { transport } }))Settlement is decoupled from dispatch, so async backends work the same way — send now, settle whenever the result arrives:
// CQRS over WebSockets — every command goes to one endpoint, results
// arrive later as frames correlated by command id.
const pending = new Map<string, CommandReporter>()
socket.onmessage = (msg) => {
const frame = JSON.parse(msg.data)
const report = pending.get(frame.commandId)
if (!report) return
frame.ok ? report.complete(frame.message) : report.fail(frame.message)
pending.delete(frame.commandId)
}
const transport: CommandTransport = {
dispatch(command, report) {
pending.set(command.id, report)
socket.send(JSON.stringify({ type: '_command', ...command }))
},
}report.complete() / report.fail() are first-call-wins; a rejected promise
or a synchronous throw from dispatch also settles the command as failed.
Modules enqueue work from anywhere. The optional third argument carries
presentation extras — a human-readable title the Commands panel shows
instead of the raw operation name:
import { useCommandQueueStore } from '@neanic/dashboard-vue'
const queue = useCommandQueueStore()
queue.enqueue('car.save', { id: 42, model: 'GT' })
queue.enqueue('car.save', { id: 42, model: 'GT' }, { title: 'Save car #42' })Each entry shows the queue time plus status and backend processing time
(max two decimals): a live pending for 0.42s while in flight, then
completed in 1.42s: Success / completed in 1.42s: Error once the
transport reports (14:03:12 · completed in 1.42s: Success). The message
line below is transport-supplied and only rendered when present — report
complete() without a message unless there is something worth saying.
Command history
The list is session-only by default. Pass a history adapter to keep it
across reloads — load() runs once at startup, save() gets the full list
after every change. The built-in adapter uses localStorage:
import { localStorageCommandHistory } from '@neanic/dashboard-vue'
app.use(
createDashboard({
commandQueue: {
transport,
history: localStorageCommandHistory(), // key/limit configurable
},
}),
)The seam is transport-agnostic on purpose: once your app has login, swap in a server-backed adapter that loads the user's history from your API and persists changes there — same interface, per-user across machines:
const serverHistory: CommandHistoryAdapter = {
load: () => fetch('/api/me/commands').then((r) => r.json()),
save: (items) =>
fetch('/api/me/commands', {
method: 'PUT',
body: JSON.stringify(items),
}),
}Restored commands that never settled (the page closed while they were in
flight) come back as failed with an "interrupted" message — retryable, and
never stuck showing Pending. Server-backed adapters that know the real
outcome should return entries already settled.
Deliberately out of scope: retries beyond the panel's manual Retry button, dedup/ordering guarantees, and offline persistence of the queue. Those are project policy — wrap your transport if you need them.
Built-in modules
Always included, regardless of config:
core— the Navigator panel, the command palette, the Preferences modal, dock-toggle commands, and the Appearance / Shortcuts / System settings sections. The Notes scratchpad panel joins whenfeatures.notesPanelis on.dashboard— the/dashboardroute: a customizable widget canvas. Widgets come from any module'swidgetscontribution.settings— the/settingsroute: hosts everyworkspace-scoped settings section.
Included only when configured:
commandQueue— the Commands panel (right dock), present when acommandQueue.transportis supplied.
Theming
Three themes ship — dark, light and contrast — plus an orthogonal
accent hue. Both are user preferences (set in the Preferences modal),
persisted to localStorage and applied before first paint. The package ships
one compiled stylesheet (@neanic/dashboard-vue/style.css); import it once.
TypeScript
The package is written in TypeScript and ships .d.ts declarations. All public
types — DashboardConfig, AppModule and the contribution types,
DashboardClient and the protocol frames — are exported from the entry point.
Working on the library itself
This repository is both the library and its dev harness.
src/ the library — published as @neanic/dashboard-vue
core/ module types, registry, router, built-in modules,
protocol + client adapters
components/ the shell — TopBar, StatusBar, Dock, OverlayHost, …
stores/ shell state — plain reactive composables
styles/app.pcss the single stylesheet (Tailwind v4 tokens + @apply)
index.ts the public entry point
install.ts createDashboard()
playground/ a faithful consumer app used to develop the library
examples/
reference-server/ a TypeScript backend speaking the Dashboard Protocol
docs/ backend-adapters.md, library-plan.mdbun run dev # run the playground (the dev app)
bun run build # type-check + build the library into dist/
bun run typecheck # type-check everything
bun run server # run the reference backend on :8787The playground defaults to the in-process mock; VITE_BACKEND=http bun run dev
or VITE_BACKEND=ws bun run dev point it at the reference server instead.
