+
+
+ Login
+ Enter your credentials to access your account
+
+
+
+
+
+
+
diff --git a/client/src/views/PageView.vue b/client/src/views/PageView.vue
new file mode 100644
index 00000000..44a33ea9
--- /dev/null
+++ b/client/src/views/PageView.vue
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
diff --git a/client/vite.config.ts b/client/vite.config.ts
index b50af815..b0cfb478 100644
--- a/client/vite.config.ts
+++ b/client/vite.config.ts
@@ -15,4 +15,21 @@ export default defineConfig({
outDir: path.resolve(import.meta.dirname, "../openadmin/__client__"),
emptyOutDir: true,
},
+ server: {
+ proxy: {
+ "/api": {
+ target: "http://localhost:8000/admin",
+ changeOrigin: true,
+ },
+ "/auth": {
+ target: "http://localhost:8000/admin",
+ changeOrigin: true,
+ },
+ // component data endpoints, e.g. /{sectionId}/{pageId}/stat/{statId}
+ "^/[^/]+/[^/]+/(stat|table|action|form|markdown|bar-chart|pie-chart)/[^/]+$": {
+ target: "http://localhost:8000/admin",
+ changeOrigin: true,
+ },
+ },
+ },
})
diff --git a/docs/.gitignore b/docs/.gitignore
new file mode 100644
index 00000000..f8a2596c
--- /dev/null
+++ b/docs/.gitignore
@@ -0,0 +1,25 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+config/.vitepress/cache
\ No newline at end of file
diff --git a/docs/assets/logo.png b/docs/assets/logo.png
index df937e7d..d71145fa 100644
Binary files a/docs/assets/logo.png and b/docs/assets/logo.png differ
diff --git a/docs/bun.lock b/docs/bun.lock
new file mode 100644
index 00000000..1645fb47
--- /dev/null
+++ b/docs/bun.lock
@@ -0,0 +1,273 @@
+{
+ "lockfileVersion": 1,
+ "configVersion": 1,
+ "workspaces": {
+ "": {
+ "devDependencies": {
+ "vitepress": "^2.0.0-alpha.19",
+ "vue": "^3.5.41",
+ },
+ },
+ },
+ "packages": {
+ "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
+
+ "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
+
+ "@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="],
+
+ "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
+
+ "@docsearch/css": ["@docsearch/css@4.7.0", "", {}, "sha512-Sk5xkdRFeE7PeWjG9l4AfTwdvMfr9wHiwNNCpHXT4v4SNyNMKdHGvEILc31BgaVFGDDNbv5u/a73tofRiwbEZw=="],
+
+ "@docsearch/js": ["@docsearch/js@4.7.0", "", {}, "sha512-x5lCqu1tetgsJFkjQ6VSocbHldsRkGEgwg5N98Vx21sq/V5wcmj4u226PY9k+TEpIgQ772zlYbPLTPicWyGnpA=="],
+
+ "@docsearch/sidepanel-js": ["@docsearch/sidepanel-js@4.7.0", "", {}, "sha512-A8r34jCU8kcIk2viECEn2msA28ojUF1BLi/3v5OWWc5G2N3jOuuumBXoeYjfr8dA0UxgFSy5R2bt12dnFJQSyA=="],
+
+ "@iconify-json/simple-icons": ["@iconify-json/simple-icons@1.2.93", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-/XhANjfGYOuqvSR3TmUnkQkINvQ4GVjVuukvymRbxtVFBvIq/yiXJqCDycKcQPT401OYT9H2vIY6ihAlz1QIAw=="],
+
+ "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
+
+ "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
+
+ "@oxc-project/types": ["@oxc-project/types@0.146.0", "", {}, "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA=="],
+
+ "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.5", "", { "os": "android", "cpu": "arm" }, "sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA=="],
+
+ "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.5", "", { "os": "android", "cpu": "arm64" }, "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig=="],
+
+ "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww=="],
+
+ "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A=="],
+
+ "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw=="],
+
+ "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.5", "", { "os": "linux", "cpu": "arm" }, "sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg=="],
+
+ "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA=="],
+
+ "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA=="],
+
+ "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA=="],
+
+ "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ=="],
+
+ "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.5", "", { "os": "linux", "cpu": "x64" }, "sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ=="],
+
+ "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.5", "", { "os": "linux", "cpu": "x64" }, "sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA=="],
+
+ "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.5", "", { "os": "none", "cpu": "arm64" }, "sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw=="],
+
+ "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw=="],
+
+ "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.5", "", { "os": "win32", "cpu": "x64" }, "sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw=="],
+
+ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
+
+ "@shikijs/core": ["@shikijs/core@4.4.3", "", { "dependencies": { "@shikijs/primitive": "4.4.3", "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5", "hast-util-to-html": "^9.0.5" } }, "sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg=="],
+
+ "@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ=="],
+
+ "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w=="],
+
+ "@shikijs/langs": ["@shikijs/langs@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3" } }, "sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A=="],
+
+ "@shikijs/primitive": ["@shikijs/primitive@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ=="],
+
+ "@shikijs/themes": ["@shikijs/themes@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3" } }, "sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw=="],
+
+ "@shikijs/transformers": ["@shikijs/transformers@4.4.3", "", { "dependencies": { "@shikijs/core": "4.4.3", "@shikijs/types": "4.4.3" } }, "sha512-oJSARV6NaWd+rnNJbtnpAdj3Zg0ZVyzsnMgb3vi3HA+35y8lBWUCpOnWsmyiXZIikY+x1BDqrQUgmxfzWh7Jvw=="],
+
+ "@shikijs/types": ["@shikijs/types@4.4.3", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g=="],
+
+ "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
+
+ "@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="],
+
+ "@types/linkify-it": ["@types/linkify-it@5.0.0", "", {}, "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="],
+
+ "@types/markdown-it": ["@types/markdown-it@14.2.0", "", { "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" } }, "sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ=="],
+
+ "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
+
+ "@types/mdurl": ["@types/mdurl@2.0.0", "", {}, "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="],
+
+ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
+
+ "@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="],
+
+ "@ungap/structured-clone": ["@ungap/structured-clone@1.3.3", "", {}, "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg=="],
+
+ "@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.8", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vue": "^3.2.25" } }, "sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew=="],
+
+ "@vue/compiler-core": ["@vue/compiler-core@3.5.41", "", { "dependencies": { "@babel/parser": "^7.29.8", "@vue/shared": "3.5.41", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg=="],
+
+ "@vue/compiler-dom": ["@vue/compiler-dom@3.5.41", "", { "dependencies": { "@vue/compiler-core": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw=="],
+
+ "@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.41", "", { "dependencies": { "@babel/parser": "^7.29.8", "@vue/compiler-core": "3.5.41", "@vue/compiler-dom": "3.5.41", "@vue/compiler-ssr": "3.5.41", "@vue/shared": "3.5.41", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.19", "source-map-js": "^1.2.1" } }, "sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ=="],
+
+ "@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.41", "", { "dependencies": { "@vue/compiler-dom": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A=="],
+
+ "@vue/devtools-api": ["@vue/devtools-api@8.2.1", "", { "dependencies": { "@vue/devtools-kit": "^8.2.1" } }, "sha512-6u4vXBlIBAC1wMplIZgpyPn7uh/s4Bf6F5bMzvLv+EdJ0aHs/+4B7Ygv864EStQSjRbsRzTko/kUG1A1IejQ3A=="],
+
+ "@vue/devtools-kit": ["@vue/devtools-kit@8.2.1", "", { "dependencies": { "@vue/devtools-shared": "^8.2.1", "birpc": "^2.6.1", "hookable": "^5.5.3", "perfect-debounce": "^2.0.0" } }, "sha512-FIGIuq3AWReEpbAHY/cRGeHDfI0qOb8OCQ3YjbEAX04uaxIDbGc9rhkbVcG7rnfHPXE3RsU5KrWOu9V/okd8AQ=="],
+
+ "@vue/devtools-shared": ["@vue/devtools-shared@8.2.1", "", {}, "sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g=="],
+
+ "@vue/reactivity": ["@vue/reactivity@3.5.41", "", { "dependencies": { "@vue/shared": "3.5.41" } }, "sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA=="],
+
+ "@vue/runtime-core": ["@vue/runtime-core@3.5.41", "", { "dependencies": { "@vue/reactivity": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg=="],
+
+ "@vue/runtime-dom": ["@vue/runtime-dom@3.5.41", "", { "dependencies": { "@vue/reactivity": "3.5.41", "@vue/runtime-core": "3.5.41", "@vue/shared": "3.5.41", "csstype": "^3.2.3" } }, "sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw=="],
+
+ "@vue/server-renderer": ["@vue/server-renderer@3.5.41", "", { "dependencies": { "@vue/compiler-ssr": "3.5.41", "@vue/runtime-dom": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ=="],
+
+ "@vue/shared": ["@vue/shared@3.5.41", "", {}, "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA=="],
+
+ "@vueuse/core": ["@vueuse/core@14.4.0", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "14.4.0", "@vueuse/shared": "14.4.0" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-X4WHz1HlCzCBoYXesUkifzzWBAcZgXG8Fi5iNPQg/epdzOB3gu8Fawj3hvuwYR1nGcXGnvxwYYcUC/71++svtQ=="],
+
+ "@vueuse/integrations": ["@vueuse/integrations@14.4.0", "", { "dependencies": { "@vueuse/core": "14.4.0", "@vueuse/shared": "14.4.0" }, "peerDependencies": { "async-validator": "^4", "axios": "^1", "change-case": "^5", "drauu": "^0.4", "focus-trap": "^7 || ^8", "fuse.js": "^7", "idb-keyval": "^6", "jwt-decode": "^4", "nprogress": "^0.2", "qrcode": "^1.5", "sortablejs": "^1", "universal-cookie": "^7 || ^8", "vue": "^3.5.0" }, "optionalPeers": ["async-validator", "axios", "change-case", "drauu", "focus-trap", "fuse.js", "idb-keyval", "jwt-decode", "nprogress", "qrcode", "sortablejs", "universal-cookie"] }, "sha512-oJz9qTgczvA7L1nXQFRU7h8tQbOCoiceqvMMhT9XYMyOGTqLJ2rEa09PON+nD2t48sZUfeOmg4eaWJXV4sZb/w=="],
+
+ "@vueuse/metadata": ["@vueuse/metadata@14.4.0", "", {}, "sha512-swx/255R6JyHZFJhx845iz5CRWDZdCfvkZOpACWc5+c5WHcG24mv8gUT1WIdFQaHt6dq79rvILd9QnCWiyVm9g=="],
+
+ "@vueuse/shared": ["@vueuse/shared@14.4.0", "", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-JRgY90Sz8DDtPMsaDflvPMp9xYk69JZAmbuDvAquUVXKr2gEjqtzGNTTthLfckH0BzBqvnu31gb4a8TGLRe79g=="],
+
+ "birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="],
+
+ "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
+
+ "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
+
+ "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
+
+ "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
+
+ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
+
+ "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
+
+ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
+
+ "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
+
+ "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
+
+ "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
+
+ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
+
+ "focus-trap": ["focus-trap@8.2.2", "", { "dependencies": { "tabbable": "^6.5.0" } }, "sha512-qV0g8hRYBqgACcFOH3f9wXc4zPKhr/0z9RI2a6ZijZ72EeBi4g8oBy8zAWuUR1TsMpOzwpUMFvjdasrC41Joug=="],
+
+ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+
+ "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
+
+ "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
+
+ "hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
+
+ "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
+
+ "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
+
+ "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
+
+ "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="],
+
+ "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="],
+
+ "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="],
+
+ "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="],
+
+ "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="],
+
+ "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="],
+
+ "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="],
+
+ "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="],
+
+ "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="],
+
+ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
+
+ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
+
+ "mark.js": ["mark.js@8.11.1", "", {}, "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ=="],
+
+ "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
+
+ "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
+
+ "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
+
+ "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
+
+ "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
+
+ "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
+
+ "minisearch": ["minisearch@7.2.0", "", {}, "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg=="],
+
+ "nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
+
+ "oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="],
+
+ "oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="],
+
+ "perfect-debounce": ["perfect-debounce@2.1.0", "", {}, "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g=="],
+
+ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
+
+ "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
+
+ "postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="],
+
+ "property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="],
+
+ "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
+
+ "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="],
+
+ "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
+
+ "rolldown": ["rolldown@1.2.5", "", { "dependencies": { "@oxc-project/types": "=0.146.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.5", "@rolldown/binding-android-arm64": "1.2.5", "@rolldown/binding-darwin-arm64": "1.2.5", "@rolldown/binding-darwin-x64": "1.2.5", "@rolldown/binding-freebsd-x64": "1.2.5", "@rolldown/binding-linux-arm-gnueabihf": "1.2.5", "@rolldown/binding-linux-arm64-gnu": "1.2.5", "@rolldown/binding-linux-arm64-musl": "1.2.5", "@rolldown/binding-linux-ppc64-gnu": "1.2.5", "@rolldown/binding-linux-s390x-gnu": "1.2.5", "@rolldown/binding-linux-x64-gnu": "1.2.5", "@rolldown/binding-linux-x64-musl": "1.2.5", "@rolldown/binding-openharmony-arm64": "1.2.5", "@rolldown/binding-win32-arm64-msvc": "1.2.5", "@rolldown/binding-win32-x64-msvc": "1.2.5" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA=="],
+
+ "shiki": ["shiki@4.4.3", "", { "dependencies": { "@shikijs/core": "4.4.3", "@shikijs/engine-javascript": "4.4.3", "@shikijs/engine-oniguruma": "4.4.3", "@shikijs/langs": "4.4.3", "@shikijs/themes": "4.4.3", "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g=="],
+
+ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
+
+ "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
+
+ "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
+
+ "tabbable": ["tabbable@6.5.0", "", {}, "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA=="],
+
+ "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
+
+ "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
+
+ "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
+
+ "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
+
+ "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
+
+ "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
+
+ "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
+
+ "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
+
+ "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
+
+ "vite": ["vite@8.2.2", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.26", "rolldown": "~1.2.4", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0 || ^0.5.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q=="],
+
+ "vitepress": ["vitepress@2.0.0-alpha.19", "", { "dependencies": { "@docsearch/css": "^4.7.0", "@docsearch/js": "^4.7.0", "@docsearch/sidepanel-js": "^4.7.0", "@iconify-json/simple-icons": "^1.2.92", "@shikijs/core": "^4.4.1", "@shikijs/transformers": "^4.4.1", "@shikijs/types": "^4.4.1", "@types/markdown-it": "^14.1.2", "@vitejs/plugin-vue": "^6.0.8", "@vue/devtools-api": "^8.2.1", "@vue/shared": "^3.5.40", "@vueuse/core": "^14.4.0", "@vueuse/integrations": "^14.4.0", "focus-trap": "^8.2.2", "mark.js": "8.11.1", "minisearch": "^7.2.0", "shiki": "^4.4.1", "vite": "^8.2.0", "vue": "^3.5.40" }, "peerDependencies": { "markdown-it-mathjax3": "^4", "postcss": "^8" }, "optionalPeers": ["markdown-it-mathjax3", "postcss"], "bin": { "vitepress": "bin/vitepress.js" } }, "sha512-WnBsb0Bwr43kXKyiis+lld/7ri3hnMbthS8N3hpFtjjwsdLO4IRmiAE08D7aud4q6oMDf9uwRowxzNqRFe/amw=="],
+
+ "vue": ["vue@3.5.41", "", { "dependencies": { "@vue/compiler-dom": "3.5.41", "@vue/compiler-sfc": "3.5.41", "@vue/runtime-dom": "3.5.41", "@vue/server-renderer": "3.5.41", "@vue/shared": "3.5.41" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg=="],
+
+ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
+ }
+}
diff --git a/docs/config/.vitepress/config.mts b/docs/config/.vitepress/config.mts
new file mode 100644
index 00000000..2693bc72
--- /dev/null
+++ b/docs/config/.vitepress/config.mts
@@ -0,0 +1,63 @@
+import { defineConfig } from 'vitepress'
+
+// https://vitepress.dev/reference/site-config
+export default defineConfig({
+ srcDir: "../content",
+ base: "/openadmin-py/",
+
+ title: "OpenAdmin",
+ description: "Admin panel for python",
+ themeConfig: {
+ // https://vitepress.dev/reference/default-theme-config
+ nav: [
+ { text: 'Home', link: '/' },
+ { text: 'Introduction', link: '/introduction/what-is-openadmin' },
+ { text: 'Components', link: '/components/' },
+ { text: 'Auth', link: '/auth/' },
+ { text: 'Cookbook', link: '/cookbook/implementing-auth' }
+ ],
+
+ sidebar: [
+ {
+ text: 'Introduction',
+ items: [
+ { text: 'What is OpenAdmin?', link: '/introduction/what-is-openadmin' },
+ { text: 'Getting Started', link: '/introduction/getting-started' }
+ ]
+ },
+ {
+ text: 'Components',
+ items: [
+ { text: 'Overview', link: '/components/' },
+ { text: 'Stat', link: '/components/stat' },
+ { text: 'Table', link: '/components/table' },
+ { text: 'Form', link: '/components/form' },
+ { text: 'Action', link: '/components/action' },
+ { text: 'Markdown', link: '/components/markdown' },
+ { text: 'Bar Chart', link: '/components/bar-chart' },
+ { text: 'Pie Chart', link: '/components/pie-chart' },
+ { text: 'Area Chart', link: '/components/area-chart' },
+ { text: 'Line Chart', link: '/components/line-chart' }
+ ]
+ },
+ {
+ text: 'Auth',
+ items: [
+ { text: 'Password Authentication', link: '/auth/' }
+ ]
+ },
+ {
+ text: 'Cookbook',
+ items: [
+ { text: 'Implementing Auth', link: '/cookbook/implementing-auth' },
+ { text: 'Implementing a Table', link: '/cookbook/implementing-table' },
+ { text: 'Implementing a Form with a Reference', link: '/cookbook/implementing-form-with-reference' }
+ ]
+ }
+ ],
+
+ socialLinks: [
+ { icon: 'github', link: 'https://github.com/openadmin-team/openadmin-py' }
+ ]
+ }
+})
diff --git a/docs/config/.vitepress/theme/index.ts b/docs/config/.vitepress/theme/index.ts
new file mode 100644
index 00000000..def4cfc8
--- /dev/null
+++ b/docs/config/.vitepress/theme/index.ts
@@ -0,0 +1,17 @@
+// https://vitepress.dev/guide/custom-theme
+import { h } from 'vue'
+import type { Theme } from 'vitepress'
+import DefaultTheme from 'vitepress/theme'
+import './style.css'
+
+export default {
+ extends: DefaultTheme,
+ Layout: () => {
+ return h(DefaultTheme.Layout, null, {
+ // https://vitepress.dev/guide/extending-default-theme#layout-slots
+ })
+ },
+ enhanceApp({ app, router, siteData }) {
+ // ...
+ }
+} satisfies Theme
diff --git a/docs/config/.vitepress/theme/style.css b/docs/config/.vitepress/theme/style.css
new file mode 100644
index 00000000..32d3997d
--- /dev/null
+++ b/docs/config/.vitepress/theme/style.css
@@ -0,0 +1,131 @@
+/**
+ * Customize default theme styling by overriding CSS variables:
+ * https://github.com/vuejs/vitepress/blob/main/src/client/theme-default/styles/vars.css
+ */
+
+/**
+ * Colors
+ *
+ * Each colors have exact same color scale system with 3 levels of solid
+ * colors with different brightness, and 1 soft color.
+ *
+ * - `XXX-1`: The most solid color used mainly for colored text. It must
+ * satisfy the contrast ratio against when used on top of `XXX-soft`.
+ *
+ * - `XXX-2`: The color used mainly for hover state of the button.
+ *
+ * - `XXX-3`: The color for solid background, such as bg color of the button.
+ * It must satisfy the contrast ratio with pure white (#ffffff) text on
+ * top of it.
+ *
+ * - `XXX-soft`: The color used for subtle background such as custom container
+ * or badges. It must satisfy the contrast ratio when putting `XXX-1` colors
+ * on top of it.
+ *
+ * The soft color must be semi transparent alpha channel. This is crucial
+ * because it allows adding multiple "soft" colors on top of each other
+ * to create an accent, such as when having inline code block inside
+ * custom containers.
+ *
+ * - `default`: The color used purely for subtle indication without any
+ * special meanings attached to it such as bg color for menu hover state.
+ *
+ * - `brand`: Used for primary brand colors, such as link text, button with
+ * brand theme, etc.
+ *
+ * - `tip`: Used to indicate useful information. The default theme uses the
+ * brand color for this by default.
+ *
+ * - `warning`: Used to indicate warning to the users. Used in custom
+ * container, badges, etc.
+ *
+ * - `danger`: Used to show error, or dangerous message to the users. Used
+ * in custom container, badges, etc.
+ * -------------------------------------------------------------------------- */
+
+:root {
+ --vp-c-default-1: var(--vp-c-gray-1);
+ --vp-c-default-2: var(--vp-c-gray-2);
+ --vp-c-default-3: var(--vp-c-gray-3);
+ --vp-c-default-soft: var(--vp-c-gray-soft);
+
+ --vp-c-brand-1: var(--vp-c-indigo-1);
+ --vp-c-brand-2: var(--vp-c-indigo-2);
+ --vp-c-brand-3: var(--vp-c-indigo-3);
+ --vp-c-brand-soft: var(--vp-c-indigo-soft);
+
+ --vp-c-tip-1: var(--vp-c-brand-1);
+ --vp-c-tip-2: var(--vp-c-brand-2);
+ --vp-c-tip-3: var(--vp-c-brand-3);
+ --vp-c-tip-soft: var(--vp-c-brand-soft);
+
+ --vp-c-warning-1: var(--vp-c-yellow-1);
+ --vp-c-warning-2: var(--vp-c-yellow-2);
+ --vp-c-warning-3: var(--vp-c-yellow-3);
+ --vp-c-warning-soft: var(--vp-c-yellow-soft);
+
+ --vp-c-danger-1: var(--vp-c-red-1);
+ --vp-c-danger-2: var(--vp-c-red-2);
+ --vp-c-danger-3: var(--vp-c-red-3);
+ --vp-c-danger-soft: var(--vp-c-red-soft);
+}
+
+/**
+ * Component: Button
+ * -------------------------------------------------------------------------- */
+
+:root {
+ --vp-button-brand-border: transparent;
+ --vp-button-brand-text: var(--vp-c-white);
+ --vp-button-brand-bg: var(--vp-c-brand-3);
+ --vp-button-brand-hover-border: transparent;
+ --vp-button-brand-hover-text: var(--vp-c-white);
+ --vp-button-brand-hover-bg: var(--vp-c-brand-2);
+ --vp-button-brand-active-border: transparent;
+ --vp-button-brand-active-text: var(--vp-c-white);
+ --vp-button-brand-active-bg: var(--vp-c-brand-1);
+}
+
+/**
+ * Component: Home
+ * -------------------------------------------------------------------------- */
+
+:root {
+ --vp-home-hero-name-color: transparent;
+ --vp-home-hero-name-background: -webkit-linear-gradient(
+ 120deg,
+ #bd34fe 30%,
+ #41d1ff
+ );
+
+ --vp-home-hero-image-background-image: linear-gradient(
+ -45deg,
+ #bd34fe 50%,
+ #47caff 50%
+ );
+ --vp-home-hero-image-filter: blur(44px);
+}
+
+@media (min-width: 640px) {
+ :root {
+ --vp-home-hero-image-filter: blur(56px);
+ }
+}
+
+@media (min-width: 960px) {
+ :root {
+ --vp-home-hero-image-filter: blur(68px);
+ }
+}
+
+/**
+ * Component: Custom Block
+ * -------------------------------------------------------------------------- */
+
+:root {
+ --vp-custom-block-tip-border: transparent;
+ --vp-custom-block-tip-text: var(--vp-c-text-1);
+ --vp-custom-block-tip-bg: var(--vp-c-brand-soft);
+ --vp-custom-block-tip-code-bg: var(--vp-c-brand-soft);
+}
+
diff --git a/docs/content/auth/index.md b/docs/content/auth/index.md
new file mode 100644
index 00000000..390b647e
--- /dev/null
+++ b/docs/content/auth/index.md
@@ -0,0 +1,100 @@
+# Authentication
+
+`AdminAuth` holds three hooks — login, authenticate, and logout — and `AdminPanel` wires them into the panel's routes for you.
+
+```python
+from openadmin.fastapi import AdminAuth
+
+auth = AdminAuth()
+```
+
+::: warning No protection by default
+An `AdminAuth()` instance's three hooks are no-op stubs until you override them: `login` and `logout` do nothing, and `authenticate` never raises — so it lets every request through. Passing `auth=None` to `AdminPanel` (the default) is equivalent: no `/auth/*` routes are even mounted, and `/api/*` is completely open. Either way, nothing is actually gated until you decorate all three hooks yourself.
+:::
+
+## The three hooks
+
+```python
+@auth.login()
+def login(req: Request, login_req: LoginReq) -> None: ...
+
+
+@auth.authenticate()
+def authenticate(req: Request) -> None: ...
+
+
+@auth.logout()
+def logout(req: Request) -> None: ...
+```
+
+Each decorator just stores the function you give it — it doesn't wrap or alter it, so the function can still be called or tested directly like any other. Each hook may be sync or async (`None | Awaitable[None]`).
+
+- **`login_func(req, login_req)`** — receives a `LoginReq` (`{username: str, password: str}`, a pydantic model). Raise an `HTTPException` to reject the credentials; return normally to accept them. This is where you'd typically write something into `req.session`.
+- **`authenticate_func(req)`** — runs as a dependency on **every** request under `/api/*`, i.e. every stat, table, form, action, chart, and markdown endpoint on every page. Raise an `HTTPException` (typically 401) to reject the request; return normally to allow it.
+- **`logout_func(req)`** — typically clears `req.session`. It runs behind `authenticate_func` itself, so a caller must already be authenticated to log out.
+
+## How `AdminPanel` wires them up
+
+Passing `auth=` to `AdminPanel(...)` does three things:
+
+1. Mounts `POST /auth/login`, calling your `login_func` and returning `204 No Content` on success.
+2. Mounts `POST /auth/logout`, calling your `logout_func`, itself gated behind `authenticate_func`.
+3. Adds `authenticate_func` as a router-level dependency on the entire `/api` router — so it runs before any component endpoint, panel-wide, with no per-page or per-component opt-in needed.
+
+The frontend's static assets (served at `/`) and the login endpoint itself are intentionally not gated, since a client needs to load the login screen and call `/auth/login` before it has anything to authenticate with.
+
+## Example
+
+```python
+# admin/auth.py
+from fastapi import HTTPException, Request, status
+
+from openadmin.fastapi import AdminAuth, LoginReq
+
+auth = AdminAuth()
+
+
+@auth.login()
+def login(req: Request, login_req: LoginReq) -> None:
+ if login_req.username == "admin" and login_req.password == "admin":
+ req.session.update({"token": "admin-token"})
+ else:
+ raise HTTPException(
+ status.HTTP_401_UNAUTHORIZED, "Invalid username or password"
+ )
+
+
+@auth.authenticate()
+def authenticate(req: Request) -> None:
+ if req.session.get("token") != "admin-token":
+ raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Unauthorized")
+
+
+@auth.logout()
+def logout(req: Request) -> None:
+ req.session.clear()
+```
+
+`req.session` comes from Starlette's `SessionMiddleware`, added on the *outer* application — not on `admin.app` — since middleware on the outer app also covers requests routed into the mounted sub-app:
+
+```python
+# main.py
+from fastapi import FastAPI
+from starlette.middleware.sessions import SessionMiddleware
+
+from openadmin.fastapi import AdminPanel
+
+from .admin.auth import auth
+
+app = FastAPI()
+app.add_middleware(SessionMiddleware, secret_key="change-me")
+
+admin = AdminPanel("My Admin", auth=auth)
+app.mount("/admin", admin.app)
+```
+
+::: danger
+Cookie-based sessions are only as secure as `secret_key`. Never hardcode it — load it from an environment variable or secret store — and compare credentials with a real user store and hashed passwords, not the plaintext check shown above.
+:::
+
+See [Implementing Auth](/cookbook/implementing-auth) for a full step-by-step recipe.
diff --git a/docs/content/components/action.md b/docs/content/components/action.md
new file mode 100644
index 00000000..0d2aca48
--- /dev/null
+++ b/docs/content/components/action.md
@@ -0,0 +1,105 @@
+# Action
+
+An action is a one-off button that calls an endpoint — standalone on a page, or attached to a table row.
+
+```python
+@page.action("Delete Book", method="delete", description="Remove a book by ID")
+async def delete_book(
+ session: AsyncSessionDep, book_id: int = Query(...)
+) -> spec.Action:
+ book = await session.get(models.Book, book_id)
+ if book:
+ await session.delete(book)
+ await session.commit()
+ return {"message": f"Deleted book #{book_id}"}
+```
+
+## Decorator
+
+```python
+page.action(
+ name: str,
+ *,
+ method: spec.HttpMethod = "post",
+ description: str | None = None,
+ is_hidden: bool = False,
+ icon: spec.Icon | None = None,
+ color: spec.Color | None = None,
+)
+```
+
+The decorated function is registered at ` //action/`, using whichever HTTP method you pass — `get`, `post` (the default), `put`, `patch`, `delete`, or `head`.
+
+## Return value
+
+```python
+class ActionResponse(TypedDict):
+ icon: NotRequired[Icon]
+ color: NotRequired[Color]
+ toast: NotRequired[str]
+ table: NotRequired[dict | object]
+ message: NotRequired[str]
+
+
+type Action = ActionResponse | None | str
+```
+
+Same shape as a [form](/components/form)'s response: return `None` for a silent success, a string as shorthand for `message`, or a full `ActionResponse` dict with `toast`/`message`/`table` for richer feedback.
+
+```python
+@page.action("Ping Service", method="get", icon="activity", color="green")
+async def ping_service(target: str = Query("api")) -> spec.Action:
+ return {
+ "toast": f"{target} responded in 12ms",
+ "message": f"Pinged '{target}' — reachable",
+ }
+```
+
+## Parameters by HTTP method
+
+Actions take parameters the same way any FastAPI endpoint does — `Query` for `get`/`delete`, a pydantic model or `Body(...)` for `post`/`put`/`patch`, `Form(...)` fields when appropriate:
+
+```python
+@page.action("Rotate API Key", method="patch", icon="key", color="amber")
+async def rotate_api_key(
+ key_name: str = Form(..., description="Key to rotate"),
+ expires_in_days: int = Form(30, description="Validity period for the new key"),
+) -> spec.Action: ...
+```
+
+## Attaching an action to a table row
+
+Give an action `is_hidden=True` when its only purpose is to be triggered from a row, then reference it from that row's `__actions__` with `reference_action`:
+
+```python
+from openadmin.fastapi import reference_action
+
+@page.action("Delete Author", is_hidden=True)
+async def delete_author(id: str) -> spec.Action:
+ ...
+ return {"toast": f"User with id {id} deleted"}
+
+
+@page.table("All Authors")
+async def get_all_authors(...) -> spec.Table:
+ return {
+ "data": [
+ {
+ "id": author.id,
+ "name": ...,
+ "__actions__": [
+ {
+ "label": "Delete this user",
+ "action": reference_action(delete_author),
+ "query": {"id": author.id},
+ "color": "red",
+ "icon": "trash",
+ },
+ ],
+ }
+ for author in authors
+ ],
+ }
+```
+
+`reference_action` reads the ID that the `@page.action(...)` decorator stamped onto `delete_author`, so `delete_author` must already be decorated before it's referenced this way — a plain top-to-bottom ordering requirement, not special behavior. See [Implementing a Table](/cookbook/implementing-table) for the full recipe.
diff --git a/docs/content/components/area-chart.md b/docs/content/components/area-chart.md
new file mode 100644
index 00000000..093a8727
--- /dev/null
+++ b/docs/content/components/area-chart.md
@@ -0,0 +1,25 @@
+# Area Chart
+
+::: warning Not yet available
+`AreaChart` is defined in `openadmin.spec` and included in the `Component` union, but `AdminPage` doesn't expose an `area_chart(...)` decorator yet — the implementation is present in the source but commented out. This page documents the spec shape as a preview of what's coming; there's no way to register one today.
+:::
+
+## Spec shape
+
+```python
+class AreaChartComponent(TypedDict):
+ type: Literal["area-chart"]
+ id: str
+ name: str
+ description: str | None
+ refresh: int | None
+ method: HttpMethod
+ form: JsonSchema | None
+ body: JsonSchema | None
+ query: JsonSchema | None
+
+
+type AreaChart = AreaChartComponent
+```
+
+In the meantime, a time-series trend can be approximated with a [bar chart](/components/bar-chart).
diff --git a/docs/content/components/bar-chart.md b/docs/content/components/bar-chart.md
new file mode 100644
index 00000000..34835aba
--- /dev/null
+++ b/docs/content/components/bar-chart.md
@@ -0,0 +1,72 @@
+# Bar Chart
+
+A bar chart for categorical data — one bar per category, with an optional legend.
+
+```python
+@page.bar_chart("Books per Genre", description="Absolute book count for each genre")
+async def get_books_per_genre(session: AsyncSessionDep):
+ ...
+ return [{"label": row.name, "value": row.count} for row in result]
+```
+
+## Decorator
+
+```python
+page.bar_chart(
+ name: str,
+ *,
+ description: str | None = None,
+ icon: spec.Icon | None = None,
+ color: spec.Color | None = None,
+ caption: str | None = None,
+ caption_description: str | None = None,
+ caption_icon: spec.Icon | None = None,
+ config: dict[str, spec.BarChartConfigValue] | None = None,
+ data_key: str | None = None,
+ refresh: timedelta | None = None,
+)
+```
+
+The decorated function is registered as `GET //bar-chart/`.
+
+## Return value
+
+```python
+type BarChartData = (
+ list[dict[str, int | float | str]]
+ | list[dict[Literal["data", "value"], int | float | str]]
+ | object
+)
+
+
+class BarChartResponce(TypedDict):
+ config: NotRequired[dict[str, BarChartConfigValue]]
+ icon: NotRequired[Icon]
+ color: NotRequired[Color]
+ refresh: NotRequired[int | None]
+ data: BarChartData
+
+
+type BarChart = BarChartData | BarChartResponce
+```
+
+The simplest shape is a bare list of `{"label": ..., "value": ...}` dicts:
+
+```python
+return [{"label": "Fiction", "value": 128}, {"label": "History", "value": 47}]
+```
+
+Return a `BarChartResponce` dict instead when you need to override `config`, `icon`, `color`, or `refresh` per response.
+
+## Config and legend
+
+`config` maps a data key to display metadata used for the legend:
+
+```python
+class BarChartConfigValue(TypedDict):
+ name: NotRequired[str]
+ color: NotRequired[Color]
+ icon: NotRequired[Icon]
+```
+
+`data_key` selects which field in your row dicts holds the series value, for shapes that don't already use the default `label`/`value` keys.
diff --git a/docs/content/components/form.md b/docs/content/components/form.md
new file mode 100644
index 00000000..21139d46
--- /dev/null
+++ b/docs/content/components/form.md
@@ -0,0 +1,113 @@
+# Form
+
+A form submits to your own endpoint using whatever request shape you declare — a pydantic body, `Query`/`Form`/`Body` parameters, or a mix.
+
+```python
+class AddBookBody(BaseModel):
+ title: str
+ author_id: int
+ published_year: int | None = None
+
+
+@page.form("Add Book", description="Add a new book to the catalog")
+async def add_book(body: AddBookBody, session: AsyncSessionDep) -> spec.Form:
+ book = models.Book(**body.model_dump())
+ session.add(book)
+ await session.commit()
+ await session.refresh(book)
+ return {"message": f"Added book '{book.title}'"}
+```
+
+## Decorator
+
+```python
+page.form(
+ name: str,
+ *,
+ method: spec.HttpMethod = "post",
+ fields: dict[str, spec.FieldConfig] | None = None,
+ description: str | None = None,
+ is_hidden: bool = False,
+ icon: spec.Icon | None = None,
+ color: spec.Color | None = None,
+)
+```
+
+The decorated function is registered at ` //form/`, using whichever HTTP method you pass — `get`, `post` (the default), `put`, `patch`, `delete`, or `head`.
+
+## Return value
+
+```python
+class FormResponse(TypedDict):
+ icon: NotRequired[Icon]
+ color: NotRequired[Color]
+ toast: NotRequired[str]
+ table: NotRequired[dict | object]
+ message: NotRequired[str]
+
+
+type Form = FormResponse | None | str
+```
+
+Return `None` for a bare success with no message, a plain string as a shorthand for `message`, or a `FormResponse` dict for full control:
+
+```python
+@page.form("Create Webhook", method="post")
+async def create_webhook(body: WebhookBody) -> spec.Form:
+ return {
+ "icon": "webhook",
+ "color": "teal",
+ "toast": "Webhook created",
+ "message": f"Would create a webhook for '{body.event}' -> {body.url}",
+ "table": {"id": 1, "url": body.url, "event": body.event},
+ }
+```
+
+`toast` is a brief transient notification; `message` is shown inline after submission; `table` is an arbitrary JSON payload echoed back to the user (typically the created or affected record) — it's for feedback, not for updating other components.
+
+## Reference fields
+
+`fields` maps a request field name to a `FieldConfig`, turning a plain input into a picker sourced from another table:
+
+```python
+class FieldConfig(TypedDict):
+ reference: NotRequired[str | None]
+ reference_field: NotRequired[str]
+ icon: NotRequired[Icon]
+ color: NotRequired[Color]
+```
+
+```python
+from openadmin.fastapi import reference_table
+
+
+@page.form(
+ "Add Author",
+ fields={
+ "friend": {
+ "reference": reference_table(get_all_authors),
+ "reference_field": "id",
+ "icon": "user",
+ "color": "blue",
+ }
+ },
+)
+async def add_author(body: AddAuthorBody, session: AsyncSessionDep) -> spec.Form: ...
+```
+
+`reference` is the ID of a table (obtained with `reference_table`, so `get_all_authors` must already be decorated with `@page.table(...)`); `reference_field` is the column of that table's rows to submit as the field's value. See [Implementing a Form with a Reference](/cookbook/implementing-form-with-reference) for the full walkthrough.
+
+## Other HTTP methods
+
+```python
+@page.form("Rename Environment", method="patch")
+async def rename_environment(
+ environment_id: int = Query(..., description="Environment to rename"),
+ new_name: str = Body(..., embed=True, description="New environment name"),
+) -> str:
+ return f"Renamed environment #{environment_id} to '{new_name}'"
+```
+
+## Hiding a form
+
+`is_hidden=True` keeps the endpoint and its spec entry but removes it from the visible page, the same as with tables and actions.
diff --git a/docs/content/components/index.md b/docs/content/components/index.md
new file mode 100644
index 00000000..0db833b5
--- /dev/null
+++ b/docs/content/components/index.md
@@ -0,0 +1,38 @@
+# Components
+
+A component is a single widget on an `AdminPage`. Every component is registered with a decorator, and every decorator does the same two things: it registers a real FastAPI route, and it records the widget's metadata into the page's spec.
+
+| Component | Decorator | Purpose |
+| --- | --- | --- |
+| [Stat](/components/stat) | `@page.stat(...)` | A single value — a count, a percentage, a boolean |
+| [Table](/components/table) | `@page.table(...)` | A paginated, searchable grid with optional per-row actions |
+| [Form](/components/form) | `@page.form(...)` | A structured form that submits to your own endpoint |
+| [Action](/components/action) | `@page.action(...)` | A one-off button that calls an endpoint |
+| [Bar Chart](/components/bar-chart) | `@page.bar_chart(...)` | A categorical bar chart |
+| [Pie Chart](/components/pie-chart) | `@page.pie_chart(...)` | A proportional breakdown chart |
+| [Markdown](/components/markdown) | `@page.markdown(...)` | A static or dynamic rich text block |
+| [Area Chart](/components/area-chart) | — not yet available | Defined in the spec, no decorator yet |
+| [Line Chart](/components/line-chart) | — not yet available | Defined in the spec, no decorator yet |
+
+## Shared behavior
+
+A few things apply to every component, not just one:
+
+- **Parameters are inferred from the function signature.** `Query(...)`, `Body(...)`, `Form(...)`, pydantic models, and `Depends(...)` all work exactly as they do in any FastAPI route. OpenAdmin walks the resolved dependency tree and turns the query, body, and form parameters into JSON Schema, which becomes part of the component's spec.
+- **`icon` and `color`** accept any value from the shared `Icon` and `Color` literal types in `openadmin.spec` (Lucide icon names and Tailwind-style color names, respectively).
+- **`refresh`** (on `stat`, `table`, `markdown`, `bar_chart`, `pie_chart`) takes a `datetime.timedelta`. When set, the frontend polls the component's endpoint on that interval.
+- **`is_hidden`** (on `table`, `action`, `form`) registers the endpoint and includes it in the spec, but keeps it out of the visible page layout. This is the mechanism behind row actions and reference fields — see [Implementing a Table](/cookbook/implementing-table) and [Implementing a Form with a Reference](/cookbook/implementing-form-with-reference).
+- **`method`** (on `action`, `form`) is any of `get`, `post`, `put`, `patch`, `delete`, `head`, and maps directly to the matching FastAPI router method.
+
+## Referencing one component from another
+
+Two helpers let one component point at another by its generated ID, instead of hardcoding route paths:
+
+```python
+from openadmin.fastapi import reference_action, reference_table
+```
+
+- `reference_table(table_func)` — returns the ID of a `@page.table(...)`-decorated function. Used in a form field's `reference` to source a value picker from that table's rows.
+- `reference_action(action_func)` — returns the ID of a `@page.action(...)`-decorated function. Used in a table row's `__actions__` to attach that action as a row button.
+
+Both simply read an attribute (`__openadmin_table_id__` / `__openadmin_action_id__`) that the decorator stamped onto the function, so the referenced function must already be decorated — but not necessarily called — before you reference it.
diff --git a/docs/content/components/line-chart.md b/docs/content/components/line-chart.md
new file mode 100644
index 00000000..2f09ac01
--- /dev/null
+++ b/docs/content/components/line-chart.md
@@ -0,0 +1,25 @@
+# Line Chart
+
+::: warning Not yet available
+`LineChart` is defined in `openadmin.spec` and included in the `Component` union, but `AdminPage` doesn't expose a `line_chart(...)` decorator yet — the implementation is present in the source but commented out. This page documents the spec shape as a preview of what's coming; there's no way to register one today.
+:::
+
+## Spec shape
+
+```python
+class LineChartComponent(TypedDict):
+ type: Literal["line-chart"]
+ id: str
+ name: str
+ description: str | None
+ refresh: int | None
+ method: HttpMethod
+ form: JsonSchema | None
+ body: JsonSchema | None
+ query: JsonSchema | None
+
+
+type LineChart = LineChartComponent
+```
+
+In the meantime, a trend can be approximated with a [bar chart](/components/bar-chart).
diff --git a/docs/content/components/markdown.md b/docs/content/components/markdown.md
new file mode 100644
index 00000000..0045ae1e
--- /dev/null
+++ b/docs/content/components/markdown.md
@@ -0,0 +1,88 @@
+# Markdown
+
+A markdown component renders static or dynamic rich text on a page — guides, changelogs, release notes, or any contextual copy.
+
+```python
+@page.markdown("Release Notes", icon="scroll-text", color="indigo")
+def release_notes() -> spec.Markdown:
+ return """
+# 1.4.0
+
+- Added CSV export to tables
+- Fixed pagination off-by-one on the last page
+"""
+```
+
+## Decorator
+
+```python
+page.markdown(
+ name: str,
+ *,
+ description: str | None = None,
+ color: spec.Color | None = None,
+ icon: spec.Icon | None = None,
+ refresh: timedelta | None = None,
+)
+```
+
+The decorated function is registered as `GET //markdown/`.
+
+## Return value
+
+```python
+type MarkdownContent = str
+
+
+class MarkdownResponse(TypedDict):
+ icon: NotRequired[Icon]
+ color: NotRequired[Color]
+ refresh: NotRequired[int | None]
+ content: MarkdownContent
+
+
+type Markdown = MarkdownContent | MarkdownResponse
+```
+
+Return a bare string for the common case, or a `MarkdownResponse` dict to override `icon`/`color`/`refresh` per response:
+
+```python
+@page.markdown("Status", refresh=timedelta(seconds=30))
+async def status_markdown(session: AsyncSessionDep) -> spec.MarkdownResponse:
+ healthy = await check_health(session)
+ return {
+ "content": "All systems operational."
+ if healthy
+ else "**Degraded** — investigating.",
+ "color": "emerald" if healthy else "amber",
+ }
+```
+
+## Supported syntax
+
+The renderer supports GitHub-flavored Markdown plus a handful of inline HTML extras:
+
+- Headings (`#` through `######`), emphasis (`**bold**`, `*italic*`, `~~strikethrough~~`), `highlight`
+- Inline code and fenced code blocks with syntax highlighting (` ```python `, ` ```bash `, ` ```json `, ...)
+- Inline, reference-style, and bare autolinks
+- Superscript/subscript (``, ``), ``, ``
+- Ordered, unordered, and task lists (`- [x]`), including nesting
+- Definition lists (`
`/`
`/`
`)
+- Blockquotes, including nested blockquotes
+- Tables, with left/center/right column alignment
+- Images (``)
+- Disclosures (``/``)
+
+```markdown
+## Task lists
+
+- [x] Write the first draft
+- [ ] Ship it
+
+
+Click to expand
+
+Hidden by default.
+
+
+```
diff --git a/docs/content/components/pie-chart.md b/docs/content/components/pie-chart.md
new file mode 100644
index 00000000..548fdb3c
--- /dev/null
+++ b/docs/content/components/pie-chart.md
@@ -0,0 +1,79 @@
+# Pie Chart
+
+A pie chart for showing a proportional breakdown across categories.
+
+```python
+@page.pie_chart("Genre Distribution", description="Share of books across all genres")
+async def get_genre_distribution(session: AsyncSessionDep) -> spec.PieChart:
+ ...
+ return {"data": [{"name": row.name, "value": row.count} for row in result]}
+```
+
+## Decorator
+
+```python
+page.pie_chart(
+ name: str,
+ *,
+ description: str | None = None,
+ config: dict[str, spec.PieChartConfigValue] | None = None,
+ icon: spec.Icon | None = None,
+ name_key: str | None = None,
+ value_key: str | None = None,
+ color: spec.Color | None = None,
+ caption: str | None = None,
+ caption_description: str | None = None,
+ caption_icon: spec.Icon | None = None,
+ refresh: timedelta | None = None,
+)
+```
+
+The decorated function is registered as `GET //pie-chart/`.
+
+## Return value
+
+```python
+type PieChartData = (
+ list[dict[str, int | float | str]]
+ | list[dict[Literal["name", "value"], int | float | str]]
+ | object
+)
+
+
+class PieChartResponce(TypedDict):
+ config: NotRequired[dict[str, PieChartConfigValue]]
+ icon: NotRequired[Icon]
+ color: NotRequired[Color]
+ data: PieChartData
+ refresh: NotRequired[int | None]
+
+
+type PieChart = PieChartData | PieChartResponce
+```
+
+There are two ways to shape the data. Use the default `name`/`value` keys and return a bare list:
+
+```python
+@page.pie_chart("Genre Distribution")
+async def get_genre_distribution(session: AsyncSessionDep) -> spec.PieChart:
+ return {"data": [{"name": g.name, "value": count} for g, count in rows]}
+```
+
+Or use your own field names and tell the chart which ones to read with `name_key`/`value_key`:
+
+```python
+@page.pie_chart("Genre Distribution", name_key="genre", value_key="count")
+async def get_genre_distribution(session: AsyncSessionDep):
+ return [{"genre": g.name, "count": count} for g, count in rows]
+```
+
+## Config and legend
+
+`config` maps a data key to display metadata used for the legend, the same shape as on [bar charts](/components/bar-chart):
+
+```python
+class PieChartConfigValue(TypedDict):
+ name: NotRequired[str]
+ color: NotRequired[Color]
+ icon: NotRequired[Icon]
+```
diff --git a/docs/content/components/stat.md b/docs/content/components/stat.md
new file mode 100644
index 00000000..fdbcedc4
--- /dev/null
+++ b/docs/content/components/stat.md
@@ -0,0 +1,83 @@
+# Stat
+
+A stat displays a single value — a count, a percentage, a boolean, or plain text.
+
+```python
+@page.stat("Total Users")
+async def total_users() -> spec.Stat:
+ return 1_024
+```
+
+## Decorator
+
+```python
+page.stat(
+ name: str,
+ *,
+ icon: spec.Icon | None = None,
+ color: spec.Color | None = None,
+ description: str | None = None,
+ refresh: timedelta | None = None,
+)
+```
+
+The decorated function is registered as `GET //stat/`.
+
+## Return value
+
+```python
+type StatValue = str | int | float | bool | None
+
+
+class StatResponse(TypedDict):
+ value: StatValue
+ refresh: NotRequired[int | None]
+ icon: NotRequired[Icon]
+ color: NotRequired[Color]
+
+
+type Stat = StatValue | StatResponse
+```
+
+Return the bare value when you have nothing else to say:
+
+```python
+@page.stat("Active Sessions")
+async def active_sessions() -> spec.Stat:
+ return 42
+```
+
+Return a `StatResponse` dict to override the icon, color, or refresh interval per-response (useful when they depend on the value itself, e.g. a red icon when a count is above a threshold):
+
+```python
+@page.stat("Total Authors")
+async def get_total_authors(session: AsyncSessionDep) -> spec.StatResponse:
+ result = await session.execute(select(func.count(models.Author.id)))
+ return {"value": result.scalar_one(), "icon": "users", "color": "indigo"}
+```
+
+## Auto-refreshing
+
+Pass `refresh` to poll the stat's endpoint automatically:
+
+```python
+from datetime import timedelta
+
+
+@page.stat("Random Number", icon="sun", color="yellow", refresh=timedelta(seconds=1))
+def random_number():
+ return randint(100, 1000)
+```
+
+## Parameters
+
+Like every component, a stat function can take `Query`, `Body`, `Depends`, and pydantic parameters — they're inferred from the signature and become part of the component's spec, same as any other FastAPI endpoint:
+
+```python
+@page.stat("Books Without Publisher")
+async def get_books_without_publisher(session: AsyncSessionDep) -> int:
+ result = await session.execute(
+ select(func.count(models.Book.id)).where(models.Book.publisher_id.is_(None))
+ )
+ return result.scalar_one()
+```
diff --git a/docs/content/components/table.md b/docs/content/components/table.md
new file mode 100644
index 00000000..ba345b29
--- /dev/null
+++ b/docs/content/components/table.md
@@ -0,0 +1,146 @@
+# Table
+
+A table is a paginated, searchable grid, with optional styled columns and per-row actions.
+
+```python
+@page.table("Recent Users")
+async def recent_users() -> spec.Table:
+ return {
+ "data": [
+ {"id": 1, "name": "Alice", "role": "admin"},
+ {"id": 2, "name": "Bob", "role": "viewer"},
+ ]
+ }
+```
+
+## Decorator
+
+```python
+page.table(
+ name: str,
+ *,
+ description: str | None = None,
+ is_hidden: bool = False,
+ columns: dict[str, spec.ColumnConfigValue] | None = None,
+ icon: spec.Icon | None = None,
+ color: spec.Color | None = None,
+ refresh: timedelta | None = None,
+)
+```
+
+The decorated function is registered as `GET //table/`. Unlike actions and forms, a table's method is always `get`.
+
+## Return value
+
+```python
+class TableResponse(TypedDict):
+ data: TableData
+ icon: NotRequired[Icon]
+ color: NotRequired[Color]
+ refresh: NotRequired[int | None]
+ total: NotRequired[int]
+
+
+type Table = TableData | TableResponse
+```
+
+`TableData` is an iterable of row dicts. The simplest table returns a bare list:
+
+```python
+@page.table("Books by Author", description="Number of books per author")
+async def get_books_by_author(session: AsyncSessionDep):
+ ...
+ return [{"author": name, "book_count": count} for name, count in rows]
+```
+
+Return a `TableResponse` dict instead when you need `total` for pagination (see below) or want to override `icon`/`color`/`refresh` per response.
+
+## Columns
+
+`columns` maps a field name in your row dicts to display metadata:
+
+```python
+class ColumnConfigValue(TypedDict):
+ style: NotRequired[Literal["image", "badge", "link", "file"]]
+ label: NotRequired[str]
+ icon: NotRequired[Icon]
+ color: NotRequired[Color]
+```
+
+```python
+@page.table(
+ "All Books",
+ columns={
+ "id": {"label": "ID", "icon": "hash", "color": "slate"},
+ "cover": {"style": "image", "label": "Cover", "icon": "image", "color": "sky"},
+ "title": {"label": "Title", "icon": "book-text", "color": "blue"},
+ "status": {"style": "badge", "label": "Status", "icon": "badge-check", "color": "emerald"},
+ "reference": {"style": "link", "label": "Reference", "icon": "external-link", "color": "indigo"},
+ "attachment": {"style": "file", "label": "Attachment", "icon": "file", "color": "amber"},
+ },
+)
+async def get_all_books(...) -> spec.Table: ...
+```
+
+A column left out of `columns` is still shown, using its raw key as the label.
+
+## Pagination and search
+
+`openadmin.fastapi.deps` ships two dependencies for the common case:
+
+```python
+from openadmin.fastapi.deps import PageDep, SearchDep
+
+
+@page.table("All Books")
+async def get_all_books(
+ session: AsyncSessionDep, pagination: PageDep, search: SearchDep
+) -> spec.Table:
+ stmt = (
+ select(models.Book)
+ .offset((pagination.page - 1) * pagination.per_page)
+ .limit(pagination.per_page)
+ )
+ if search:
+ stmt = stmt.where(models.Book.title.ilike(f"%{search}%"))
+ ...
+```
+
+`PageDep` resolves to a `PaginationParams(page: int, per_page: int)` read from the `page`/`per_page` query parameters (default `page=1`, `per_page=10`). `SearchDep` resolves to `str | None` from a `search` query parameter. Return `total` in a `TableResponse` so the frontend knows how many pages exist:
+
+```python
+return {"data": [...], "total": total_count}
+```
+
+## Per-row keys
+
+A row dict can include three special keys alongside your own columns:
+
+- **`__view__`** — a compact label used to represent the whole row elsewhere (for example, when the row is offered as an option in a form's reference picker — see [Implementing a Form with a Reference](/cookbook/implementing-form-with-reference)).
+- **`__actions__`** — a list of buttons attached to that row. Each entry references an action registered elsewhere on the page via `reference_action`:
+
+ ```python
+ from openadmin.fastapi import reference_action
+
+ "__actions__": [
+ {
+ "label": "Delete this user",
+ "action": reference_action(delete_author),
+ "query": {"id": author.id},
+ "color": "red",
+ "icon": "trash",
+ },
+ ],
+ ```
+
+- **`__values__`** — per-cell overrides of `style`/`label`/`icon`/`color` for that row's specific columns, when a single column's styling depends on the row's data:
+
+ ```python
+ "__values__": {
+ "status": {"color": "green" if flag["enabled"] else "red", "icon": "antenna"},
+ },
+ ```
+
+## Hiding a table
+
+`is_hidden=True` keeps the route and spec entry but removes the table from the visible page. This is how a table can act purely as a data source for a form's reference field, without also cluttering the page it's declared on. See [Implementing a Table](/cookbook/implementing-table) for the full pattern.
diff --git a/docs/content/cookbook/implementing-auth.md b/docs/content/cookbook/implementing-auth.md
new file mode 100644
index 00000000..cad2a1ca
--- /dev/null
+++ b/docs/content/cookbook/implementing-auth.md
@@ -0,0 +1,95 @@
+# Implementing Auth
+
+A step-by-step recipe for gating a panel behind a login screen. Background on how the pieces fit together is in [Authentication](/auth/).
+
+## 1. Add session support to the outer app
+
+OpenAdmin doesn't ship a session store — use Starlette's `SessionMiddleware`, added to the app you mount the panel onto (not to `admin.app` itself):
+
+```python
+# main.py
+from fastapi import FastAPI
+from starlette.middleware.sessions import SessionMiddleware
+
+app = FastAPI()
+app.add_middleware(SessionMiddleware, secret_key=os.environ["SESSION_SECRET"])
+```
+
+## 2. Create an `AdminAuth` instance
+
+```python
+# admin/auth.py
+from openadmin.fastapi import AdminAuth
+
+auth = AdminAuth()
+```
+
+## 3. Implement login
+
+Validate credentials against your real user store, then write a session marker:
+
+```python
+from fastapi import HTTPException, Request, status
+
+from openadmin.fastapi import LoginReq
+from .users import verify_password, get_user_by_username
+
+
+@auth.login()
+async def login(req: Request, login_req: LoginReq) -> None:
+ user = await get_user_by_username(login_req.username)
+ if user is None or not verify_password(login_req.password, user.password_hash):
+ raise HTTPException(
+ status.HTTP_401_UNAUTHORIZED, "Invalid username or password"
+ )
+
+ req.session["user_id"] = user.id
+```
+
+`LoginReq` is a pydantic model with `username: str` and `password: str`. Raising `HTTPException` here is what makes `POST /auth/login` return an error instead of `204`.
+
+## 4. Implement authenticate
+
+This runs before every request under `/api/*`, so keep it cheap — a session lookup, not a full user fetch, if you can avoid it:
+
+```python
+@auth.authenticate()
+def authenticate(req: Request) -> None:
+ if "user_id" not in req.session:
+ raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Unauthorized")
+```
+
+## 5. Implement logout
+
+```python
+@auth.logout()
+def logout(req: Request) -> None:
+ req.session.clear()
+```
+
+## 6. Wire it into the panel
+
+```python
+# admin/panel.py
+from openadmin.fastapi import AdminPanel
+
+from .auth import auth
+
+admin = AdminPanel("My Admin", auth=auth)
+```
+
+At this point every `/api/*` call — every stat, table, form, and action on every page — requires a valid session, and `/admin/` still serves the frontend so a logged-out visitor can reach the login screen at all.
+
+## Alternative: token-based auth
+
+`authenticate_func` just receives the `Request`, so nothing ties you to cookies. To check a bearer token instead:
+
+```python
+@auth.authenticate()
+def authenticate(req: Request) -> None:
+ token = req.headers.get("authorization", "").removeprefix("Bearer ")
+ if not is_valid_token(token):
+ raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Unauthorized")
+```
+
+In that case `login_func` can simply be left as a no-op (or removed from your flow entirely) if tokens are issued out of band, since nothing requires you to use `/auth/login` at all.
diff --git a/docs/content/cookbook/implementing-form-with-reference.md b/docs/content/cookbook/implementing-form-with-reference.md
new file mode 100644
index 00000000..b077f3ee
--- /dev/null
+++ b/docs/content/cookbook/implementing-form-with-reference.md
@@ -0,0 +1,86 @@
+# Implementing a Form with a Reference
+
+A step-by-step recipe for a form field that picks its value from another table's rows — for example, choosing a book's author from a list of authors instead of typing an author ID by hand. Full option reference is in [Form](/components/form) and [Table](/components/table).
+
+::: info
+`AsyncSessionDep`, `Author`, `Book`, and the `select` calls below stand in for your own database session dependency and models — OpenAdmin has no opinion on persistence.
+:::
+
+## 1. Define the table the field will reference
+
+The referenced table has to be a normal `@page.table(...)`, decorated before you reference it. It doesn't need to live on the same page as the form:
+
+```python
+# admin/authors.py
+from openadmin import spec
+from openadmin.fastapi import AdminPage
+
+page = AdminPage("Authors", icon="users")
+
+
+@page.table(
+ "All Authors",
+ columns={"id": {"label": "ID"}, "name": {"label": "Name"}},
+)
+async def get_all_authors(session: AsyncSessionDep) -> spec.Table:
+ authors = (await session.execute(select(Author))).scalars().all()
+ return {
+ "data": [{"id": a.id, "name": a.name, "__view__": a.name} for a in authors],
+ }
+```
+
+`__view__` is what the reference picker displays for each row — set it to whatever best identifies the row to a human, here the author's name instead of their raw ID.
+
+## 2. Define the form's request body
+
+```python
+# admin/books.py
+from pydantic import BaseModel
+
+
+class AddBookBody(BaseModel):
+ title: str
+ author_id: int
+```
+
+## 3. Point the field at the table
+
+```python
+from openadmin.fastapi import AdminPage, reference_table
+
+from .authors import get_all_authors
+
+page = AdminPage("Books", icon="book")
+
+
+@page.form(
+ "Add Book",
+ fields={
+ "author_id": {
+ "reference": reference_table(get_all_authors),
+ "reference_field": "id",
+ "icon": "user-pen",
+ "color": "violet",
+ }
+ },
+)
+async def add_book(body: AddBookBody, session: AsyncSessionDep) -> spec.Form:
+ book = Book(**body.model_dump())
+ session.add(book)
+ await session.commit()
+ await session.refresh(book)
+ return {"message": f"Added book '{book.title}'"}
+```
+
+- **`reference`** is the referenced table's generated ID, obtained with `reference_table(get_all_authors)` — pass the function itself, not a string, so a rename of the table doesn't silently break the link.
+- **`reference_field`** is the column of the referenced table's rows to actually submit as `author_id`'s value. The picker shows each row's `__view__` label, but writes `row[reference_field]` when one is chosen — here, the author's `id`.
+
+Because `reference_table` reads an attribute the `@page.table(...)` decorator stamps directly onto `get_all_authors`, that function must already be decorated by the time `add_book` is defined — in practice, just make sure the module defining the table is imported before the module defining the form runs, as in the example above.
+
+## 4. Cross-page references
+
+The referenced table doesn't have to live on the same `AdminPage` as the form — import the decorated function from wherever it's defined, same as the `from .authors import get_all_authors` above. Only the function object matters; the pages don't need any other relationship.
+
+## Referencing an action instead of a table
+
+The equivalent helper for attaching an action to a table row is `reference_action`, covered in [Implementing a Table](/cookbook/implementing-table). Both helpers work the same way — they read an ID the decorator stamped onto an already-decorated function.
diff --git a/docs/content/cookbook/implementing-table.md b/docs/content/cookbook/implementing-table.md
new file mode 100644
index 00000000..b2540d62
--- /dev/null
+++ b/docs/content/cookbook/implementing-table.md
@@ -0,0 +1,132 @@
+# Implementing a Table
+
+A step-by-step recipe for a searchable, paginated table with styled columns and a per-row delete action. Full option reference is in [Table](/components/table) and [Action](/components/action).
+
+::: info
+`AsyncSessionDep`, `User`, and the `select`/`func` calls below stand in for your own database session dependency and models — OpenAdmin has no opinion on persistence. `PageDep`/`SearchDep` are the only dependencies that come from OpenAdmin itself.
+:::
+
+## 1. Define the page
+
+```python
+# admin/users.py
+from openadmin import spec
+from openadmin.fastapi import AdminPage, reference_action
+from openadmin.fastapi.deps import PageDep, SearchDep
+
+page = AdminPage("Users", icon="users")
+```
+
+## 2. Add pagination and search
+
+`PageDep` and `SearchDep` cover the common case — `page`/`per_page` and `search` query parameters, parsed for you:
+
+```python
+@page.table(
+ "All Users",
+ description="Browse users with search and pagination",
+ icon="users",
+ color="blue",
+ columns={
+ "id": {"label": "ID", "icon": "hash", "color": "slate"},
+ "name": {"label": "Name", "icon": "user", "color": "indigo"},
+ "role": {
+ "style": "badge",
+ "label": "Role",
+ "icon": "shield",
+ "color": "emerald",
+ },
+ },
+)
+async def get_all_users(
+ session: AsyncSessionDep, pagination: PageDep, search: SearchDep
+) -> spec.Table:
+ stmt = (
+ select(User)
+ .offset((pagination.page - 1) * pagination.per_page)
+ .limit(pagination.per_page)
+ )
+ count_stmt = select(func.count(User.id))
+ if search:
+ stmt = stmt.where(User.name.ilike(f"%{search}%"))
+ count_stmt = count_stmt.where(User.name.ilike(f"%{search}%"))
+
+ users = (await session.execute(stmt)).scalars().all()
+ total = (await session.execute(count_stmt)).scalar_one()
+
+ return {
+ "data": [{"id": u.id, "name": u.name, "role": u.role} for u in users],
+ "total": total,
+ }
+```
+
+Returning `total` is what lets the frontend render correct page controls — without it, pagination still works, but it can't show a page count.
+
+`columns` controls display, not data: `style: "badge"` renders the `role` cell as a badge; leave a column out of `columns` and it still shows, using its raw key as the label. See [Table](/components/table) for the other styles (`image`, `link`, `file`).
+
+## 3. Add a delete action
+
+Declare the action with `is_hidden=True` — it exists purely to be triggered from a row, not to be shown on the page as its own button:
+
+```python
+@page.action("Delete User", method="delete", is_hidden=True)
+async def delete_user(
+ session: AsyncSessionDep, user_id: int = Query(...)
+) -> spec.Action:
+ user = await session.get(User, user_id)
+ if user:
+ await session.delete(user)
+ await session.commit()
+ return {
+ "toast": f"User #{user_id} deleted" if user else f"User #{user_id} not found"
+ }
+```
+
+## 4. Attach it to each row
+
+Reference the action by function, not by string ID, so a rename doesn't silently break the link:
+
+```python
+@page.table("All Users", ...)
+async def get_all_users(...) -> spec.Table:
+ ...
+ return {
+ "data": [
+ {
+ "id": u.id,
+ "name": u.name,
+ "role": u.role,
+ "__actions__": [
+ {
+ "label": "Delete",
+ "action": reference_action(delete_user),
+ "query": {"user_id": u.id},
+ "icon": "trash",
+ "color": "red",
+ },
+ ],
+ }
+ for u in users
+ ],
+ "total": total,
+ }
+```
+
+`reference_action` reads the ID that `@page.action(...)` stamped onto `delete_user`, so `delete_user` needs to be defined (decorated) above `get_all_users` in the file, or at least imported before this function runs — plain Python name resolution, nothing OpenAdmin-specific.
+
+The action's own `query`/`body`/`form` schema (inferred from `Query(...)` on `user_id`) still applies — the `query` dict in `__actions__` is what actually gets sent as that action's parameters when the row's button is clicked.
+
+## 5. Per-cell overrides
+
+When a single column's icon or color depends on the row's data rather than being fixed in `columns`, use `__values__`:
+
+```python
+"__values__": {
+ "role": {"color": "amber" if u.role == "admin" else "slate"},
+},
+```
+
+## Next
+
+- Use a table as the source for a form's reference picker: [Implementing a Form with a Reference](/cookbook/implementing-form-with-reference).
+- Full column style and per-row key reference: [Table](/components/table).
diff --git a/docs/content/index.md b/docs/content/index.md
new file mode 100644
index 00000000..c190fd35
--- /dev/null
+++ b/docs/content/index.md
@@ -0,0 +1,28 @@
+---
+# https://vitepress.dev/reference/default-theme-home-page
+layout: home
+
+hero:
+ name: "OpenAdmin"
+ text: "Admin panel for python"
+ tagline: Build admin dashboards as FastAPI routes — stats, tables, charts, and actions, all in pure Python.
+ actions:
+ - theme: brand
+ text: What is OpenAdmin?
+ link: /introduction/what-is-openadmin
+ - theme: alt
+ text: Getting Started
+ link: /introduction/getting-started
+
+features:
+ - icon: 📊
+ title: Stats, Tables & Charts
+ details: Single-value stats, paginated searchable tables, and bar/pie charts — each just a decorated FastAPI endpoint.
+ - icon: 📝
+ title: Forms & Actions
+ details: Structured forms and one-off action buttons that submit to your own endpoints, with typed parameters inferred automatically.
+ - icon: 🔌
+ title: FastAPI-native
+ details: A page is a router, a widget is an endpoint. Full dependency injection, OpenAPI docs, and middleware compatibility for free.
+---
+
diff --git a/docs/content/introduction/getting-started.md b/docs/content/introduction/getting-started.md
new file mode 100644
index 00000000..2d108825
--- /dev/null
+++ b/docs/content/introduction/getting-started.md
@@ -0,0 +1,96 @@
+# Getting Started
+
+This walks through building a minimal admin panel from scratch: one page, one stat, one table, mounted onto a FastAPI app.
+
+## Requirements
+
+- Python 3.14+
+- FastAPI
+
+## Installation
+
+```bash
+pip install openadmin-py
+```
+
+or with `uv`:
+
+```bash
+uv add openadmin-py
+```
+
+## Build a page
+
+An `AdminPage` holds the widgets for one page in the navigation. Widgets are added by decorating functions with `@page.stat`, `@page.table`, `@page.form`, and `@page.action`.
+
+```python
+# app/admin/dashboard.py
+from openadmin import spec
+from openadmin.fastapi import AdminPage
+
+page = AdminPage("Dashboard", icon="layout-dashboard")
+
+
+@page.stat("Total Users")
+async def total_users() -> spec.Stat:
+ return 1_024
+
+
+@page.table("Recent Users")
+async def recent_users() -> spec.Table:
+ return {
+ "data": [
+ {"id": 1, "name": "Alice", "role": "admin"},
+ {"id": 2, "name": "Bob", "role": "viewer"},
+ ]
+ }
+```
+
+Each decorated function is a normal `async def` (or sync `def`) handler — it can take `Query`/`Body` parameters, use `Depends(...)` for a database session, and return whatever your return type declares. The decorator infers the parameter schema from the function signature the same way FastAPI does.
+
+## Assemble the panel
+
+`AdminPanel` is the top-level object. Pages are grouped into named sections:
+
+```python
+# app/admin/panel.py
+from openadmin.fastapi import AdminPanel
+
+from .dashboard import page as dashboard_page
+
+admin = AdminPanel("My Admin", description="Internal operations panel")
+
+admin.section("General", icon="layout-grid", pages=[dashboard_page])
+```
+
+## Mount it on your app
+
+`AdminPanel.app` is a real `FastAPI()` instance, so it mounts like any sub-application:
+
+```python
+# app/main.py
+from fastapi import FastAPI
+
+from .admin.panel import admin
+
+app = FastAPI()
+app.mount("/admin", admin.app)
+```
+
+## Run it
+
+```bash
+fastapi dev app/main.py
+```
+
+Visit `http://localhost:8000/admin/` for the UI, or `http://localhost:8000/admin/api/openadmin.json` to see the raw spec that drives it.
+
+::: tip
+The panel is unprotected by default — anyone who can reach `/admin` can view and act on every widget. See [Authentication](/auth/) before deploying anywhere reachable by untrusted users.
+:::
+
+## Next steps
+
+- [Components](/components/) — every widget type, with its full option list.
+- [Authentication](/auth/) — add a login screen.
+- [Cookbook](/cookbook/implementing-table) — worked recipes for common patterns like reference fields and row actions.
diff --git a/docs/content/introduction/what-is-openadmin.md b/docs/content/introduction/what-is-openadmin.md
new file mode 100644
index 00000000..01d9cef9
--- /dev/null
+++ b/docs/content/introduction/what-is-openadmin.md
@@ -0,0 +1,58 @@
+# What is OpenAdmin?
+
+OpenAdmin is a FastAPI-native library for building admin dashboards. You define pages as plain Python functions decorated with typed helpers — no templates, no separate frontend project, no configuration files to maintain. Every page is a FastAPI router; every widget on that page is just an endpoint.
+
+```python
+@page.stat("Total Users")
+async def total_users() -> spec.Stat:
+ return 1_024
+```
+
+That function is a normal `async def` FastAPI handler. It also happens to describe a stat tile that shows up in the admin UI, because OpenAdmin inspects its signature and return type to build a specification the bundled frontend renders.
+
+## The core idea
+
+An admin panel is a tree of four kinds of objects:
+
+- **`AdminPanel`** — the panel itself. It is a small wrapper around a `FastAPI()` app, meant to be mounted onto your real application with `app.mount("/admin", admin.app)`.
+- **Sections** — top-level groups in the navigation, registered on the panel with `admin.section(...)`. A section is just a name plus a list of pages.
+- **`AdminPage`** — a page inside a section. Internally it holds a FastAPI `APIRouter`, so everything you already know about routers, dependencies, and path operations applies.
+- **Components** — the widgets on a page: stats, tables, forms, actions, charts, and markdown blocks. Each one is registered with a decorator on an `AdminPage` (`@page.stat(...)`, `@page.table(...)`, etc.) and becomes both a real HTTP endpoint and an entry in the page's spec.
+
+```
+AdminPanel
+└── section("Library")
+ └── AdminPage("Books")
+ ├── @page.stat("Total Books")
+ ├── @page.table("All Books")
+ ├── @page.form("Add Book")
+ └── @page.action("Delete Book")
+```
+
+See [Components](/components/) for a full walkthrough of each widget type.
+
+## How it fits together
+
+Every decorator does two things at once:
+
+1. It registers your function as a real route on the page's router — with FastAPI's usual dependency injection, `Query`/`Body`/`Form` parameters, pydantic models, and OpenAPI docs.
+2. It records metadata (name, icon, color, the parameters it accepts, and so on) into a `Spec` — a plain nested `TypedDict` structure (`openadmin.spec.Spec` → `Section` → `Page` → `Component`) describing the whole panel.
+
+The panel exposes that structure at `GET /api/openadmin.json`. The bundled frontend (served from the same panel at `/`) fetches this spec once to build the navigation and layout, then calls each component's own endpoint — the same one your decorator wrapped — to fetch or refresh its data. Because it's the same endpoint either way, you can `curl` any widget directly, exercise it from FastAPI's `/docs`, or write ordinary tests against it.
+
+## What's included
+
+- **Stats** — a single value: a count, a percentage, a boolean.
+- **Tables** — paginated, searchable grids with per-row actions and styled columns (badges, images, links, files).
+- **Forms** — structured forms that submit to your own endpoint, with optional reference fields that look values up from another table.
+- **Actions** — one-off buttons that call an endpoint, either standalone or attached to a table row.
+- **Charts** — bar and pie charts today, with area and line chart types defined in the spec for future support.
+- **Markdown** — static or dynamic rich text blocks for guides, changelogs, or contextual notes.
+
+Because a page is a router and a widget is an endpoint, everything FastAPI already gives you — dependency injection, background tasks, database sessions, response validation, middleware — works unchanged. There is no separate query language or templating layer to learn.
+
+## Next steps
+
+- [Getting Started](/introduction/getting-started) — build a minimal panel end to end.
+- [Components](/components/) — the full reference for every widget type.
+- [Authentication](/auth/) — gate the panel behind a login screen.
diff --git a/docs/package.json b/docs/package.json
new file mode 100644
index 00000000..c3cee989
--- /dev/null
+++ b/docs/package.json
@@ -0,0 +1,12 @@
+{
+ "dependencies": {},
+ "devDependencies": {
+ "vitepress": "^2.0.0-alpha.19",
+ "vue": "^3.5.41"
+ },
+ "scripts": {
+ "dev": "vitepress dev config",
+ "build": "vitepress build config",
+ "preview": "vitepress preview config"
+ }
+}
\ No newline at end of file
diff --git a/examples/admin/auth.py b/examples/admin/auth.py
new file mode 100644
index 00000000..fab2147f
--- /dev/null
+++ b/examples/admin/auth.py
@@ -0,0 +1,32 @@
+# SPDX-FileCopyrightText: 2026 OpenAdmin
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+from fastapi import HTTPException, Request, status
+
+from openadmin.fastapi import AdminAuth, LoginReq
+
+auth = AdminAuth()
+
+
+@auth.login()
+def login(req: Request, login_req: LoginReq) -> None:
+ if login_req.username == "admin" and login_req.password == "admin":
+ req.session.update({"token": "admin-token"})
+ else:
+ raise HTTPException(
+ status.HTTP_401_UNAUTHORIZED, "Invalid username or password"
+ )
+
+
+@auth.authenticate()
+def authenticate(req: Request) -> None:
+ token = req.session.get("token")
+
+ if not token == "admin-token":
+ raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Unauthorized")
+
+
+@auth.logout()
+def logout(req: Request) -> None:
+ req.session.clear()
diff --git a/examples/admin/authors.py b/examples/admin/authors.py
index 82aaef8c..09f35069 100644
--- a/examples/admin/authors.py
+++ b/examples/admin/authors.py
@@ -6,8 +6,8 @@
from sqlalchemy import func, select
from openadmin import spec
-from openadmin.fastapi import AdminPage
-from openadmin.fastapi.deps import PageDep, SearchQueryDep
+from openadmin.fastapi import AdminPage, reference_action, reference_table
+from openadmin.fastapi.deps import PageDep, SearchDep
from ..lib import models
from ..lib.database import AsyncSessionDep
@@ -38,6 +38,14 @@ async def get_authors_with_bio(session: AsyncSessionDep):
return result.scalar_one()
+@page.action(
+ "Delete Author",
+ is_hidden=True,
+)
+async def delete_author(id: str) -> spec.Action:
+ return {"toast": f"User with id {id} deleted"}
+
+
@page.stat(
"Avg Books per Author",
icon="library",
@@ -59,9 +67,15 @@ async def get_avg_books_per_author(session: AsyncSessionDep) -> float:
description="Browse authors with search and book counts",
icon="user-pen",
color="indigo",
+ columns={
+ "id": {"label": "ID", "icon": "hash", "color": "slate"},
+ "name": {"label": "Name", "icon": "user", "color": "indigo"},
+ "bio": {"label": "Bio", "icon": "notepad-text", "color": "gray"},
+ "book_count": {"label": "Book Count", "icon": "book-copy", "color": "emerald"},
+ },
)
async def get_all_authors(
- session: AsyncSessionDep, pagination: PageDep, search: SearchQueryDep
+ session: AsyncSessionDep, pagination: PageDep, search: SearchDep
) -> spec.Table:
stmt = (
select(models.Author, func.count(models.Book.id).label("book_count"))
@@ -70,22 +84,57 @@ async def get_all_authors(
.offset((pagination.page - 1) * pagination.per_page)
.limit(pagination.per_page)
)
+ count_stmt = select(func.count(models.Author.id))
if search:
full_name = models.Author.first_name + " " + models.Author.last_name
stmt = stmt.where(full_name.ilike(f"%{search}%"))
+ count_stmt = count_stmt.where(full_name.ilike(f"%{search}%"))
result = await session.execute(stmt)
- return [
- {
- "id": author.id,
- "name": f"{author.first_name} {author.last_name}",
- "bio": (author.bio[:80] + "...")
- if author.bio and len(author.bio) > 80
- else author.bio,
- "book_count": count,
- "__view__": f"{author.first_name} {author.last_name}",
- }
- for author, count in result.all()
- ]
+ total = await session.execute(count_stmt)
+ return {
+ "data": [
+ {
+ "id": author.id,
+ "name": f"{author.first_name} {author.last_name}",
+ "bio": (author.bio[:80] + "...")
+ if author.bio and len(author.bio) > 80
+ else author.bio,
+ "book_count": count,
+ "__view__": f"{author.first_name} {author.last_name}",
+ "__actions__": [
+ {
+ "label": "Delete this user",
+ "action": reference_action(delete_author),
+ "query": {
+ "id": author.id,
+ },
+ "color": "red",
+ "icon": "trash",
+ },
+ {
+ "label": "Bun this user",
+ "action": reference_action(delete_author),
+ "query": {
+ "id": author.id,
+ },
+ "color": "yellow",
+ "icon": "alarm-clock-check",
+ },
+ {
+ "label": "Unban this user",
+ "action": reference_action(delete_author),
+ "query": {
+ "id": author.id,
+ },
+ "color": "green",
+ "icon": "leaf",
+ },
+ ],
+ }
+ for author, count in result.all()
+ ],
+ "total": total.scalar_one(),
+ }
@page.bar_chart(
@@ -119,7 +168,7 @@ class AddAuthorBody(BaseModel):
description="Register a new author in the catalog",
fields={
"friend": {
- "reference": get_all_authors,
+ "reference": reference_table(get_all_authors),
"icon": "user",
"color": "blue",
"reference_field": "id",
diff --git a/examples/admin/books.py b/examples/admin/books.py
index 69ce1e9a..df70669b 100644
--- a/examples/admin/books.py
+++ b/examples/admin/books.py
@@ -2,13 +2,16 @@
#
# SPDX-License-Identifier: AGPL-3.0-or-later
+import asyncio
+from urllib.parse import quote
+
from fastapi import Query
from pydantic import BaseModel
from sqlalchemy import func, select
from openadmin import spec
from openadmin.fastapi import AdminPage
-from openadmin.fastapi.deps import PageDep, SearchQueryDep
+from openadmin.fastapi.deps import PageDep, SearchDep
from ..lib import models
from ..lib.database import AsyncSessionDep
@@ -38,15 +41,53 @@ async def get_books_without_publisher(session: AsyncSessionDep) -> int:
return result.scalar_one()
+BOOK_COVER_URL = "https://img.magnific.com/free-photo/beautiful-tropical-beach-sea-ocean-with-white-cloud-blue-sky-copyspace_74190-8663.jpg?semt=ais_hybrid&w=740&q=80"
+
+
@page.table(
"All Books",
description="Browse all books with search and pagination",
icon="book",
color="blue",
+ columns={
+ "id": {"label": "ID", "icon": "hash", "color": "slate"},
+ "cover": {
+ "style": "image",
+ "label": "Cover",
+ "icon": "image",
+ "color": "sky",
+ },
+ "title": {"label": "Title", "icon": "book-text", "color": "blue"},
+ "author": {"label": "Author", "icon": "user-pen", "color": "violet"},
+ "published_year": {
+ "label": "Published Year",
+ "icon": "puzzle",
+ "color": "blue",
+ },
+ "status": {
+ "style": "badge",
+ "label": "Status",
+ "icon": "badge-check",
+ "color": "emerald",
+ },
+ "reference": {
+ "style": "link",
+ "label": "Reference",
+ "icon": "external-link",
+ "color": "indigo",
+ },
+ "attachment": {
+ "style": "file",
+ "label": "Attachment",
+ "icon": "file",
+ "color": "amber",
+ },
+ },
)
async def get_all_books(
- session: AsyncSessionDep, pagination: PageDep, search: SearchQueryDep
-):
+ session: AsyncSessionDep, pagination: PageDep, search: SearchDep
+) -> spec.Table:
+ await asyncio.sleep(5) # TEMP: simulate latency for manual UX testing
stmt = (
select(models.Book, models.Author)
.join(models.Author, models.Author.id == models.Book.author_id)
@@ -56,15 +97,25 @@ async def get_all_books(
if search:
stmt = stmt.where(models.Book.title.ilike(f"%{search}%"))
result = await session.execute(stmt)
- return [
- {
- "id": book.id,
- "title": book.title,
- "author": f"{author.first_name} {author.last_name}",
- "published_year": book.published_year,
- }
- for book, author in result.all()
- ]
+ return {
+ "data": [
+ {
+ "id": book.id,
+ "cover": BOOK_COVER_URL,
+ "title": book.title,
+ "author": f"{author.first_name} {author.last_name}",
+ "published_year": book.published_year,
+ "status": "Published" if book.published_year else "Draft",
+ "reference": f"https://www.google.com/search?tbm=bks&q={quote(book.title)}",
+ "attachment": BOOK_COVER_URL,
+ "__values__": {
+ "reference": {"label": "Link to the book"},
+ "attachment": {"label": "Book in pdf"},
+ },
+ }
+ for book, author in result.all()
+ ],
+ }
class AddBookBody(BaseModel):
@@ -80,6 +131,10 @@ class AddBookBody(BaseModel):
description="Number of books per author",
icon="users-round",
color="violet",
+ columns={
+ "author": {"label": "Author", "icon": "user-pen", "color": "violet"},
+ "book_count": {"label": "Book Count", "icon": "book-copy", "color": "blue"},
+ },
)
async def get_books_by_author(session: AsyncSessionDep, pagination: PageDep):
stmt = (
diff --git a/examples/admin/control_panel.py b/examples/admin/control_panel.py
index 7ded3f16..334d0622 100644
--- a/examples/admin/control_panel.py
+++ b/examples/admin/control_panel.py
@@ -2,13 +2,17 @@
#
# SPDX-License-Identifier: AGPL-3.0-or-later
+from datetime import UTC, datetime, timedelta
+from random import randint
+from time import sleep
from typing import Annotated
from fastapi import Body, Depends, Form, Query
from pydantic import BaseModel
from openadmin import spec
-from openadmin.fastapi import AdminPage
+from openadmin.fastapi import AdminPage, reference_action
+from openadmin.fastapi.deps import PageDep, SearchDep
page = AdminPage(
"Control Panel",
@@ -128,6 +132,302 @@ async def purge_temp_files(
}
+# ---------------------------------------------------------------------------
+# Feature flags — a searchable table with a badge-styled status column and
+# per-row Enable/Disable actions. Held in memory for the demo.
+# ---------------------------------------------------------------------------
+
+
+FEATURE_FLAGS: list[dict[str, str | bool]] = [
+ {
+ "name": "new-dashboard",
+ "description": "Enables the redesigned analytics dashboard",
+ "enabled": True,
+ "created_by": "grace.hopper",
+ "created_at": "2026-01-12T09:30:00Z",
+ "last_changed_at": "2026-06-02T14:05:00Z",
+ },
+ {
+ "name": "beta-search",
+ "description": "Turns on the experimental full-text search backend",
+ "enabled": False,
+ "created_by": "alan.turing",
+ "created_at": "2026-03-04T11:00:00Z",
+ "last_changed_at": "2026-03-04T11:00:00Z",
+ },
+ {
+ "name": "dark-mode-v2",
+ "description": "Rolls out the refreshed dark theme palette",
+ "enabled": True,
+ "created_by": "ada.lovelace",
+ "created_at": "2025-11-20T16:45:00Z",
+ "last_changed_at": "2026-07-30T08:12:00Z",
+ },
+ {
+ "name": "checkout-retry",
+ "description": "Automatically retries failed checkout payment attempts",
+ "enabled": False,
+ "created_by": "margaret.hamilton",
+ "created_at": "2026-05-08T13:20:00Z",
+ "last_changed_at": "2026-05-15T09:40:00Z",
+ },
+ {
+ "name": "notif-batching",
+ "description": "Batches push notifications instead of sending one at a time",
+ "enabled": True,
+ "created_by": "grace.hopper",
+ "created_at": "2025-09-02T10:15:00Z",
+ "last_changed_at": "2026-04-11T17:30:00Z",
+ },
+ {
+ "name": "ai-recommendations",
+ "description": "Shows AI-generated product recommendations on the home feed",
+ "enabled": False,
+ "created_by": "katherine.johnson",
+ "created_at": "2026-02-18T08:00:00Z",
+ "last_changed_at": "2026-02-18T08:00:00Z",
+ },
+ {
+ "name": "gdpr-export-v2",
+ "description": "Uses the streaming exporter for user data download requests",
+ "enabled": True,
+ "created_by": "alan.turing",
+ "created_at": "2025-12-01T09:00:00Z",
+ "last_changed_at": "2026-07-05T12:45:00Z",
+ },
+ {
+ "name": "multi-currency",
+ "description": "Allows prices to be displayed in the shopper's local currency",
+ "enabled": False,
+ "created_by": "ada.lovelace",
+ "created_at": "2026-04-22T15:10:00Z",
+ "last_changed_at": "2026-04-22T15:10:00Z",
+ },
+ {
+ "name": "lazy-image-loading",
+ "description": "Defers off-screen image loading on catalog pages",
+ "enabled": True,
+ "created_by": "margaret.hamilton",
+ "created_at": "2025-10-14T13:40:00Z",
+ "last_changed_at": "2026-01-09T09:55:00Z",
+ },
+ {
+ "name": "two-factor-required",
+ "description": "Requires two-factor authentication for admin accounts",
+ "enabled": True,
+ "created_by": "grace.hopper",
+ "created_at": "2025-08-19T11:20:00Z",
+ "last_changed_at": "2026-06-30T16:00:00Z",
+ },
+ {
+ "name": "cart-abandonment-email",
+ "description": "Sends a reminder email 24 hours after an abandoned cart",
+ "enabled": False,
+ "created_by": "katherine.johnson",
+ "created_at": "2026-03-27T09:45:00Z",
+ "last_changed_at": "2026-03-27T09:45:00Z",
+ },
+ {
+ "name": "graphql-gateway",
+ "description": "Routes catalog reads through the new GraphQL gateway",
+ "enabled": True,
+ "created_by": "alan.turing",
+ "created_at": "2026-01-30T14:00:00Z",
+ "last_changed_at": "2026-05-22T10:10:00Z",
+ },
+ {
+ "name": "warehouse-auto-routing",
+ "description": "Automatically routes orders to the nearest warehouse",
+ "enabled": False,
+ "created_by": "ada.lovelace",
+ "created_at": "2026-05-19T08:30:00Z",
+ "last_changed_at": "2026-05-19T08:30:00Z",
+ },
+ {
+ "name": "session-replay",
+ "description": "Records anonymized session replays for support debugging",
+ "enabled": False,
+ "created_by": "margaret.hamilton",
+ "created_at": "2025-11-03T10:00:00Z",
+ "last_changed_at": "2026-02-14T11:25:00Z",
+ },
+ {
+ "name": "loyalty-points-v3",
+ "description": "Switches loyalty point accrual to the new rewards engine",
+ "enabled": True,
+ "created_by": "grace.hopper",
+ "created_at": "2026-02-09T16:20:00Z",
+ "last_changed_at": "2026-07-18T13:15:00Z",
+ },
+ {
+ "name": "server-side-cart",
+ "description": "Moves cart state from local storage to the server",
+ "enabled": True,
+ "created_by": "katherine.johnson",
+ "created_at": "2025-07-22T09:00:00Z",
+ "last_changed_at": "2026-03-01T09:00:00Z",
+ },
+ {
+ "name": "vendor-payout-automation",
+ "description": "Automates weekly payouts to third-party vendors",
+ "enabled": False,
+ "created_by": "alan.turing",
+ "created_at": "2026-06-11T12:00:00Z",
+ "last_changed_at": "2026-06-11T12:00:00Z",
+ },
+ {
+ "name": "accessibility-audit-banner",
+ "description": "Shows an in-app banner linking to the accessibility audit report",
+ "enabled": True,
+ "created_by": "ada.lovelace",
+ "created_at": "2025-09-28T14:30:00Z",
+ "last_changed_at": "2026-01-16T15:40:00Z",
+ },
+ {
+ "name": "price-match-guarantee",
+ "description": "Enables the automated price-match request flow",
+ "enabled": False,
+ "created_by": "margaret.hamilton",
+ "created_at": "2026-04-03T10:50:00Z",
+ "last_changed_at": "2026-04-03T10:50:00Z",
+ },
+ {
+ "name": "edge-caching",
+ "description": "Serves catalog pages from the CDN edge cache",
+ "enabled": True,
+ "created_by": "grace.hopper",
+ "created_at": "2025-06-30T08:15:00Z",
+ "last_changed_at": "2026-05-27T09:35:00Z",
+ },
+]
+
+
+def _find_feature_flag(name: str) -> dict[str, str | bool] | None:
+ return next((flag for flag in FEATURE_FLAGS if flag["name"] == name), None)
+
+
+@page.action(
+ "Enable Feature Flag",
+ method="post",
+ description="Turn a feature flag on for every user",
+ icon="toggle-right",
+ color="green",
+ is_hidden=True,
+)
+async def enable_feature_flag(
+ name: str = Query(..., description="Flag name"),
+) -> spec.Action:
+ flag = _find_feature_flag(name)
+ if flag is None:
+ return {"toast": f"No such flag '{name}'"}
+ flag["enabled"] = True
+ flag["last_changed_at"] = datetime.now(UTC).isoformat()
+ return {"toast": f"'{name}' enabled"}
+
+
+@page.action(
+ "Disable Feature Flag",
+ method="post",
+ description="Turn a feature flag off for every user",
+ icon="toggle-left",
+ color="red",
+ is_hidden=True,
+)
+async def disable_feature_flag(
+ name: str = Query(..., description="Flag name"),
+) -> spec.Action:
+ flag = _find_feature_flag(name)
+ if flag is None:
+ return {"toast": f"No such flag '{name}'"}
+ flag["enabled"] = False
+ flag["last_changed_at"] = datetime.now(UTC).isoformat()
+ return {"toast": f"'{name}' disabled"}
+
+
+@page.table(
+ "Feature Flags",
+ description="Search feature flags and enable or disable them",
+ icon="flag",
+ color="violet",
+ columns={
+ "name": {"label": "Name", "icon": "flag", "color": "violet"},
+ "description": {
+ "label": "Description",
+ "icon": "notepad-text",
+ "color": "gray",
+ },
+ "status": {
+ "style": "badge",
+ "label": "Status",
+ "icon": "toggle-right",
+ "color": "emerald",
+ },
+ "created_by": {"label": "Created By", "icon": "user", "color": "indigo"},
+ "created_at": {
+ "label": "Created At",
+ "icon": "calendar-plus",
+ "color": "slate",
+ },
+ "last_changed_at": {
+ "label": "Last Changed At",
+ "icon": "history",
+ "color": "amber",
+ },
+ },
+)
+async def get_feature_flags(search: SearchDep, pagination: PageDep) -> spec.Table:
+ flags = FEATURE_FLAGS
+ if search:
+ needle = search.lower()
+ flags = [
+ flag
+ for flag in flags
+ if needle in str(flag["name"]).lower()
+ or needle in str(flag["description"]).lower()
+ ]
+
+ start = (pagination.page - 1) * pagination.per_page
+ page_flags = flags[start : start + pagination.per_page]
+
+ return {
+ "data": [
+ {
+ "name": flag["name"],
+ "description": flag["description"],
+ "status": "on" if flag["enabled"] else "off",
+ "created_by": flag["created_by"],
+ "created_at": flag["created_at"],
+ "last_changed_at": flag["last_changed_at"],
+ "__actions__": [
+ {
+ "label": "Disable",
+ "action": reference_action(disable_feature_flag),
+ "query": {"name": flag["name"]},
+ "icon": "toggle-left",
+ "color": "red",
+ }
+ if flag["enabled"]
+ else {
+ "label": "Enable",
+ "action": reference_action(enable_feature_flag),
+ "query": {"name": flag["name"]},
+ "icon": "toggle-right",
+ "color": "green",
+ },
+ ],
+ "__values__": {
+ "status": {
+ "color": "green" if flag["enabled"] else "red",
+ "icon": "antenna",
+ }
+ },
+ }
+ for flag in page_flags
+ ],
+ "total": len(flags),
+ }
+
+
# ---------------------------------------------------------------------------
# Forms — one per HTTP verb (post/put/patch/delete demoed here).
# ---------------------------------------------------------------------------
@@ -211,3 +511,21 @@ async def schedule_config_reset(
config_id: int = Query(..., description="Config entry to reset"),
) -> spec.Form:
return None
+
+
+@page.stat("Random number 1", icon="sun", color="yellow", refresh=timedelta(seconds=1))
+def random_number_1():
+ sleep(1)
+ return randint(100, 1000)
+
+
+@page.stat("Random number 2", icon="moon", color="blue")
+def random_number_2():
+ sleep(1)
+ return randint(100, 1000)
+
+
+@page.stat("Random number 3", icon="mars", color="red")
+def random_number_3():
+ sleep(1)
+ return randint(100, 1000)
diff --git a/examples/admin/docs.py b/examples/admin/docs.py
index 3a816385..1099dfa1 100644
--- a/examples/admin/docs.py
+++ b/examples/admin/docs.py
@@ -7,33 +7,166 @@
page = fastapi.AdminPage("Docs", icon="dock")
-@page.markdown("Company policy 1", icon="podcast", color="blue")
-def company_policy_1() -> spec.Markdown:
+@page.markdown("Markdown Guide - Part 1", icon="heading", color="indigo")
+def markdown_guide_part_1() -> spec.Markdown:
return """
-# Hello World
+# Markdown Guide - Part 1: Text & Headings
-This is new text
+This is a long-form guide that exercises most of the Markdown syntax the
+*typeset* renderer supports. It is split across three components so each
+one stays a manageable size.
+
+## Headings
+
+# Heading level 1
+## Heading level 2
+### Heading level 3
+#### Heading level 4
+##### Heading level 5
+###### Heading level 6
+
+## Emphasis
+
+Text can be **bold**, *italic*, or ***bold and italic*** at once. Outdated
+text can be ~~struck through~~, and important phrases can be
+highlighted so they stand out.
+
+Inline code looks like `const answer = 42` when you need to reference a
+symbol without pulling in a full code block.
+
+## Links
+
+- An inline link: [OpenAdmin documentation](https://example.com/docs)
+- A reference-style link: [the CommonMark spec][commonmark]
+- A bare autolink:
+
+[commonmark]: https://example.com/commonmark "CommonMark Spec"
+
+## Small print
+
+Formulas need superscripts, like E = mc2, and subscripts, like
+H2O.
+
+The HTML spec defines this
+as an inline element.
+
+To save your work, press Ctrl + S.
+
+---
+
+Part 2 continues with lists, blockquotes, and tables.
+"""
+
+
+@page.markdown("Markdown Guide - Part 2", icon="list-checks", color="teal")
+def markdown_guide_part_2() -> spec.Markdown:
+ return """
+# Markdown Guide - Part 2: Lists, Blockquotes & Tables
+
+## Unordered lists
+
+- First item
+- Second item
+ - Nested item one
+ - Nested item two
+- Third item
+
+## Ordered lists
+
+1. Preheat the oven
+2. Mix the batter
+3. Bake for 25 minutes
+ 1. Check at the 20 minute mark
+ 2. Rotate the pan if it browns unevenly
+
+## Task lists
+
+- [x] Write the first draft
+- [x] Add code samples
+- [ ] Proofread everything
+- [ ] Ship it
+
+## Definition lists
+
+
+
Markdown
+
A lightweight markup language for formatting plain text.
+
CommonMark
+
A strongly defined, highly compatible specification of Markdown.