npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@modufolio/panel

v0.10.0

Published

Schema-driven admin panel components for Inertia.js + Vue 3 — tables, blueprint forms, filters, actions, drawers, widgets.

Readme

@modufolio/panel

CI npm License: MIT

Schema-driven admin panel components for Inertia.js + Vue 3 — tables, blueprint forms, filters, actions, drawers, widgets. Tables can be driven entirely by a server-authored schema. The frontend companion to modufolio/appkit, usable with any Inertia-speaking backend.

Status: 0.x pre-release. APIs may move between minors; the barrel export list (src/index.ts) is the public API surface.

Install

npm install @modufolio/panel

Peer dependencies: vue ^3.5, @inertiajs/vue3 ^3, @heroicons/vue ^2, and (optional) @vueuse/core, vuedraggable, lodash, tus-js-client (only for useTusUploadQueue).

Setup

One plugin call configures everything:

import { createPanel } from '@modufolio/panel'

createApp({ render: () => h(App, props) })
  .use(createPanel({
    baseUrl: '/panel',                       // your backend mount path
    icons: { camera: CameraIcon },           // extend/override <Icon> names
    fields: {                                // app-specific blueprint fields
      'block-editor': () => import('./Fields/BlockEditorField.vue'),
    },
  }))
  .mount(el)

Every URL the panel builds goes through panelUrl() with that baseUrl — nothing in the package hardcodes a mount path (CI-enforced).

Failed requests

The panel reports a failed status itself: a toast for what the viewer can shrug off, a modal for what ends what they were doing (401, 403 and 5xx by default). Both are configuration:

createPanel({
  errorMessages: {
    403: 'Ask an admin for access.',                      // toast
    409: { as: 'modal', title: 'Out of date', message: 'Reload and try again.' },
    404: false,                                           // leave it alone
  },
})

AppLayout mounts the toast host and the modal; an app laying out its own chrome mounts <Toast /> and <ErrorModal /> once itself.

Generic resource pages

The pages the generated resource routes render ship with the package. Spread them into your Inertia resolver and a full-CRUD resource needs no Vue file of its own:

import { resourcePages } from '@modufolio/panel'

const pages = { ...resourcePages, ...appPages }   // app pages win on a clash

That covers Resource/Index, Resource/Create, Resource/Edit and Resource/Permissions. None of them declares a layout, so whatever your resolver assigns applies to them like any other page.

Styles (Tailwind CSS 4)

The package ships source .vue files and source CSS — your Tailwind build compiles both. In your CSS entry:

@import 'tailwindcss';
@import '@modufolio/panel/styles' layer(components);
@source "../node_modules/@modufolio/panel";

That entry pulls in the design tokens, the button and form rules, and components.css — the ui-* classes the components themselves emit. Import your own stylesheet after it to override any of them.

Theming

tokens.css is three layers:

  1. Palette — --color-primary-*, --color-success-*, … and the --color-media-* values. Literal, and identical in every theme.
  2. Semantics — --surface, --ink, --line, and five tokens per role (--danger, --danger-fill, --danger-on-fill, --danger-surface, --danger-on-surface). This is the only layer that differs between light and dark.
  3. Bridge — an @theme inline block turning layer 2 into utilities, so bg-surface, text-ink, border-line and text-danger-on-surface exist.

Components use layers 2 and 3 — bg-surface, not bg-white dark:bg-gray-900. Both themes therefore come from one set of classes, and anything built on top of the panel inherits dark mode without repeating the work. Override any value in your own @theme block to re-brand.

The --color-media-* tokens deliberately do not change with the theme: a photographer judging tone needs the same neutral surround whatever the chrome is doing. bg-media-surround is #4d4d4d in both themes, and is lighter than the dark chrome around it, the way professional raw developers mat an image.

Light and dark

Dark mode is a dark class on <html>, not a media query, so the user can override the OS. createPanel({ theme }) sets what the panel opens as — 'dark' (the default), 'light', or 'system' to follow the OS. A stored choice always wins; theme only decides the first load.

Read or change it with useTheme():

const { theme, preference, isDark, options, set } = useTheme()
set('light')            // 'system' | 'light' | 'dark', persisted to panel.theme

Add this to your document <head>, before any stylesheet. The composable applies the class as soon as it is imported, but that is still after the browser has painted once — this script runs first and removes the flash:

<script>
  try {
    var stored = localStorage.getItem('panel.theme')
    var dark = stored === 'dark' || (stored !== 'light' &&
      (stored === 'system'
        ? matchMedia('(prefers-color-scheme: dark)').matches
        : true))
    document.documentElement.classList.toggle('dark', dark)
  } catch (e) {}
</script>

The final : true is the panel's dark default; change it to matchMedia('(prefers-color-scheme: dark)').matches if you pass theme: 'system' to createPanel.

Quick example

<script setup>
import { Table, BlueprintForm } from '@modufolio/panel'

const columns = [
  { key: 'name', label: 'Name', sortable: true },
  { key: 'email', label: 'Email' },
]

const fields = [
  { type: 'text', key: 'name', label: 'Name', required: true, width: '1/2' },
  { type: 'text', key: 'email', label: 'Email', width: '1/2' },
  { type: 'toggle', key: 'active', label: 'Active', when: f => !!f.email },
]
</script>

<template>
  <Table :columns="columns" :records="users.data" />
  <BlueprintForm v-model="form" :fields="fields" label="Profile" />
</template>

In practice a page rarely writes either list by hand: the table's columns come from a server-authored schema (below), and the form's fields from the resource's blueprint.

Server-driven tables

SchemaTable renders columns, filters, groups, summaries and a query builder from a description the backend sends as a prop — no columns array in the page:

<SchemaTable
  :schema="table"
  :records="organizations.data"
  :summaries="organizations.meta?.summaries ?? {}"
  :filter-values="form"
  @update:filter="setFilter"
/>

It wraps Table, so pages using Table directly are unaffected, and any #cell-{key} or #filters slot you pass overrides the generated one. See docs/table-schema.md.

Generated resource pages

A PanelResource served by the package's generated routes needs no page of its own. The host registers one component for every listing and hands the server's props straight through:

<script setup lang="ts">
import { Head } from '@inertiajs/vue3'
import { ResourcePage, humanize } from '@modufolio/panel'
import Layout from '../Shared/Layout.vue'

defineOptions({ layout: Layout, inheritAttrs: false })
const props = defineProps<{ resource: { key: string } }>()
</script>

<template>
  <Head :title="humanize(props.resource.key)" />
  <ResourcePage v-bind="{ ...$attrs, ...props }" />
</template>

ResourcePage renders the table or board, filters, column toggle, export, the create action, the drawer stack with each record's tabs, and the over-drawer form that adds one row to a list — all from what ResourceListing sent. #cell-{key} slots replace one cell; any other slot is a drawer tab body. ResourceForm is the create/edit half. A page that wants its own markup calls useResourceListing() and keeps only what differs.

Async writes

Overlapping writes are normal in an admin panel — a toggle in a row, a debounced field, an optimistic star — and the failure modes are quiet. The package ships the ordering primitives rather than leaving each page to re-invent them:

import { optimistic, writeKey, usePendingKeys } from '@modufolio/panel'

const { isPending, run } = usePendingKeys()      // which row is busy

const toggle = (row) => run(row.id, () => optimistic(
  () => { const was = row.active; row.active = !was; return () => { row.active = was } },
  () => apiFetch(url, { method: 'PATCH', body: { active: row.active } }),
  writeKey('rows', row.id, 'active'),            // orders overlapping writes
))

Also here: useFieldSaver (debounced saves with ordered status), useUnsavedChangesWarning (prompt before a navigation discards edits, logout included), writeGate (the ordering itself) and moduleSingleton (state that survives a duplicated module copy). See docs/async-writes.md.

Extension seams

  • registerFieldType(type, loader) — add custom blueprint field types (rich-text editors, media pickers, ...) without forking the package. See docs/custom-fields.md.
  • registerIcons(map) — add or override icon names used by <Icon>.
  • setPanelBaseUrl('/admin') / panelUrl(path) — mount-path handling.

Server contract

Works with any Inertia backend. A few small server-side conventions:

Tests

npm test            # inside ui/

Specs live in tests/, one file per module or component, run by the package's own vitest.config.ts against happy-dom. Component specs mount with @vue/test-utils; field components resolve asynchronously through the registry, so wait for rendered output (vi.waitFor) rather than a fixed number of ticks.

License

MIT