From 89674b45585dec71bf2583c7c4051a6471b0eecb Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Fri, 17 Oct 2025 10:07:44 -0500 Subject: [PATCH 01/21] Initial inertia rendering --- composer.json | 1 + composer.lock | 72 ++++++- package-lock.json | 153 +++++++++++++ package.json | 2 + resources/css/compat.css | 11 + resources/css/cp.css | 10 + resources/css/forms.css | 78 +++++++ resources/icons/custom-icons/c-outline.svg | 8 +- resources/js/components/CpSidebar.vue | 110 ++++++++++ resources/js/components/DevModeIndicator.vue | 26 +++ resources/js/components/EditionInfo.vue | 42 ++++ resources/js/components/MainNav.vue | 38 ++++ resources/js/components/SystemInfo.vue | 39 ++++ resources/js/components/VarDump.vue | 20 ++ resources/js/composables/useCraftData.ts | 33 +++ resources/js/cp.ts | 21 +- resources/js/layout/AppLayout.vue | 144 +++++++++++++ resources/js/pages/Dashboard.vue | 132 ++++++++++++ resources/templates/_layouts/base.twig | 2 - resources/views/app.blade.php | 12 ++ routes/cp.php | 4 +- src/CP/Navigation.php | 201 ++++++++++++++++++ .../Dashboard/DashboardController.php | 23 +- src/Http/Middleware/HandleInertiaRequests.php | 89 ++++++++ src/Providers/AppServiceProvider.php | 4 + vite.config.js | 16 +- 26 files changed, 1262 insertions(+), 29 deletions(-) create mode 100644 resources/css/compat.css create mode 100644 resources/css/forms.css create mode 100644 resources/js/components/CpSidebar.vue create mode 100644 resources/js/components/DevModeIndicator.vue create mode 100644 resources/js/components/EditionInfo.vue create mode 100644 resources/js/components/MainNav.vue create mode 100644 resources/js/components/SystemInfo.vue create mode 100644 resources/js/components/VarDump.vue create mode 100644 resources/js/composables/useCraftData.ts create mode 100644 resources/js/layout/AppLayout.vue create mode 100644 resources/js/pages/Dashboard.vue create mode 100644 resources/views/app.blade.php create mode 100644 src/CP/Navigation.php create mode 100644 src/Http/Middleware/HandleInertiaRequests.php diff --git a/composer.json b/composer.json index af7dbd495ec..1c4e74db38b 100644 --- a/composer.json +++ b/composer.json @@ -45,6 +45,7 @@ "elvanto/litemoji": "~4.3.0", "enshrined/svg-sanitize": "~0.22.0", "guzzlehttp/guzzle": "^7.2.0", + "inertiajs/inertia-laravel": "^2.0", "laravel/framework": "^12.21.0", "league/uri": "^7.0", "moneyphp/money": "^4.0", diff --git a/composer.lock b/composer.lock index 8aaf95e5802..9eef21fab4a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "cf407efe540224cafe642174d07625e5", + "content-hash": "c7f5549f7455a2635ffd2525832c102a", "packages": [ { "name": "bacon/bacon-qr-code", @@ -2125,6 +2125,76 @@ ], "time": "2025-08-22T14:27:06+00:00" }, + { + "name": "inertiajs/inertia-laravel", + "version": "v2.0.10", + "source": { + "type": "git", + "url": "https://github.com/inertiajs/inertia-laravel.git", + "reference": "07da425d58a3a0e3ace9c296e67bd897a6e47009" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/inertiajs/inertia-laravel/zipball/07da425d58a3a0e3ace9c296e67bd897a6e47009", + "reference": "07da425d58a3a0e3ace9c296e67bd897a6e47009", + "shasum": "" + }, + "require": { + "ext-json": "*", + "laravel/framework": "^10.0|^11.0|^12.0", + "php": "^8.1.0", + "symfony/console": "^6.2|^7.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.2", + "larastan/larastan": "^3.0", + "laravel/pint": "^1.16", + "mockery/mockery": "^1.3.3", + "orchestra/testbench": "^8.0|^9.2|^10.0", + "phpunit/phpunit": "^10.4|^11.5", + "roave/security-advisories": "dev-master" + }, + "suggest": { + "ext-pcntl": "Recommended when running the Inertia SSR server via the `inertia:start-ssr` artisan command." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Inertia\\ServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "./helpers.php" + ], + "psr-4": { + "Inertia\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jonathan Reinink", + "email": "jonathan@reinink.ca", + "homepage": "https://reinink.ca" + } + ], + "description": "The Laravel adapter for Inertia.js.", + "keywords": [ + "inertia", + "laravel" + ], + "support": { + "issues": "https://github.com/inertiajs/inertia-laravel/issues", + "source": "https://github.com/inertiajs/inertia-laravel/tree/v2.0.10" + }, + "time": "2025-09-28T21:21:36+00:00" + }, { "name": "laravel/framework", "version": "v12.32.5", diff --git a/package-lock.json b/package-lock.json index ffaa359a66a..79dffb8d6e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "packages/*" ], "dependencies": { + "@craftcms/cp": "file:../npm-packages/packages/cp", "@inertiajs/vue3": "^2.2.7", "laravel-vite-plugin": "^2.0.1", "vue": "^3.5.22" @@ -20,6 +21,7 @@ "@tailwindcss/vite": "^4.1.14", "@total-typescript/tsconfig": "^1.0.4", "@vitejs/plugin-vue": "^6.0.1", + "@vueuse/core": "^13.9.0", "husky": "^9.1.7", "lint-staged": "^16.1.6", "lit": "^3.3.1", @@ -42,6 +44,44 @@ "@awesome.me/kit-ddaed3f5c5": "^1.0.41" } }, + "../npm-packages/packages/cp": { + "name": "@craftcms/cp", + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "axios": "^1.12.2" + }, + "devDependencies": { + "@arethetypeswrong/cli": "^0.18.2", + "@awesome.me/webawesome": "^3.0.0-beta.4", + "@chromatic-com/storybook": "^4.0.1", + "@lion/ui": "^0.14.2", + "@shoelace-style/shoelace": "^2.20.1", + "@storybook/addon-a11y": "^9.1.5", + "@storybook/addon-docs": "^9.1.5", + "@storybook/addon-themes": "^9.1.5", + "@storybook/addon-vitest": "^9.1.5", + "@storybook/web-components-vite": "^9.1.5", + "@total-typescript/tsconfig": "^1.0.4", + "@types/jquery": "^3.5.32", + "@types/node": "^24.0.10", + "@vitest/browser": "^3.2.4", + "@vitest/coverage-v8": "^3.2.4", + "globby": "^14.1.0", + "happy-dom": "^18.0.1", + "playwright": "^1.53.2", + "plop": "^4.0.1", + "prettier": "^3.6.2", + "rimraf": "^6.0.1", + "storybook": "^9.1.5", + "typescript": "^5.8.3", + "vite": "^7.1.5", + "vitest": "^3.2.4" + }, + "peerDependencies": { + "lit": "3.x" + } + }, "node_modules/@aashutoshrathi/word-wrap": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", @@ -1746,6 +1786,10 @@ "resolved": "packages/craftcms-browserslist-config", "link": true }, + "node_modules/@craftcms/cp": { + "resolved": "../npm-packages/packages/cp", + "link": true + }, "node_modules/@craftcms/graphiql": { "resolved": "packages/craftcms-graphiql", "link": true @@ -4828,6 +4872,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.5.10", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz", @@ -5357,6 +5408,47 @@ "integrity": "sha512-F4yc6palwq3TT0u+FYf0Ns4Tfl9GRFURDN2gWG7L1ecIaS/4fCIuFOjMTnCyjsu/OK6vaDKLCrGAa+KvvH+h4w==", "license": "MIT" }, + "node_modules/@vueuse/core": { + "version": "13.9.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-13.9.0.tgz", + "integrity": "sha512-ts3regBQyURfCE2BcytLqzm8+MmLlo5Ln/KLoxDVcsZ2gzIwVNnQpQOL/UKV8alUqjSZOlpFZcRNsLRqj+OzyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "13.9.0", + "@vueuse/shared": "13.9.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/metadata": { + "version": "13.9.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-13.9.0.tgz", + "integrity": "sha512-1AFRvuiGphfF7yWixZa0KwjYH8ulyjDCC0aFgrGRz8+P4kvDFSdXLVfTk5xAN9wEuD1J6z4/myMoYbnHoX07zg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "13.9.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-13.9.0.tgz", + "integrity": "sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", @@ -19539,6 +19631,37 @@ "@craftcms/browserslist-config": { "version": "file:packages/craftcms-browserslist-config" }, + "@craftcms/cp": { + "version": "file:../npm-packages/packages/cp", + "requires": { + "@arethetypeswrong/cli": "^0.18.2", + "@awesome.me/webawesome": "^3.0.0-beta.4", + "@chromatic-com/storybook": "^4.0.1", + "@lion/ui": "^0.14.2", + "@shoelace-style/shoelace": "^2.20.1", + "@storybook/addon-a11y": "^9.1.5", + "@storybook/addon-docs": "^9.1.5", + "@storybook/addon-themes": "^9.1.5", + "@storybook/addon-vitest": "^9.1.5", + "@storybook/web-components-vite": "^9.1.5", + "@total-typescript/tsconfig": "^1.0.4", + "@types/jquery": "^3.5.32", + "@types/node": "^24.0.10", + "@vitest/browser": "^3.2.4", + "@vitest/coverage-v8": "^3.2.4", + "axios": "^1.12.2", + "globby": "^14.1.0", + "happy-dom": "^18.0.1", + "playwright": "^1.53.2", + "plop": "^4.0.1", + "prettier": "^3.6.2", + "rimraf": "^6.0.1", + "storybook": "^9.1.5", + "typescript": "^5.8.3", + "vite": "^7.1.5", + "vitest": "^3.2.4" + } + }, "@craftcms/graphiql": { "version": "file:packages/craftcms-graphiql", "requires": { @@ -21381,6 +21504,12 @@ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "dev": true }, + "@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "dev": true + }, "@types/ws": { "version": "8.5.10", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz", @@ -21793,6 +21922,30 @@ "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.22.tgz", "integrity": "sha512-F4yc6palwq3TT0u+FYf0Ns4Tfl9GRFURDN2gWG7L1ecIaS/4fCIuFOjMTnCyjsu/OK6vaDKLCrGAa+KvvH+h4w==" }, + "@vueuse/core": { + "version": "13.9.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-13.9.0.tgz", + "integrity": "sha512-ts3regBQyURfCE2BcytLqzm8+MmLlo5Ln/KLoxDVcsZ2gzIwVNnQpQOL/UKV8alUqjSZOlpFZcRNsLRqj+OzyA==", + "dev": true, + "requires": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "13.9.0", + "@vueuse/shared": "13.9.0" + } + }, + "@vueuse/metadata": { + "version": "13.9.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-13.9.0.tgz", + "integrity": "sha512-1AFRvuiGphfF7yWixZa0KwjYH8ulyjDCC0aFgrGRz8+P4kvDFSdXLVfTk5xAN9wEuD1J6z4/myMoYbnHoX07zg==", + "dev": true + }, + "@vueuse/shared": { + "version": "13.9.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-13.9.0.tgz", + "integrity": "sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g==", + "dev": true, + "requires": {} + }, "@webassemblyjs/ast": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", diff --git a/package.json b/package.json index 90e171e4263..b9c6aca51a2 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@craftcms/playwright": "file:packages/craftcms-playwright", "@craftcms/webpack": "file:packages/craftcms-webpack", "@playwright/test": "^1.52.0", + "@vueuse/core": "^13.9.0", "husky": "^9.1.7", "lint-staged": "^16.1.6", "lit": "^3.3.1", @@ -45,6 +46,7 @@ "@awesome.me/kit-ddaed3f5c5": "^1.0.41" }, "dependencies": { + "@craftcms/cp": "file:../npm-packages/packages/cp", "@inertiajs/vue3": "^2.2.7", "laravel-vite-plugin": "^2.0.1", "vue": "^3.5.22" diff --git a/resources/css/compat.css b/resources/css/compat.css new file mode 100644 index 00000000000..224d823e570 --- /dev/null +++ b/resources/css/compat.css @@ -0,0 +1,11 @@ +/** +Maps our old custom utility classes to tailwind. + */ + +.fullwidth { + @apply tw:w-full; +} + +.visually-hidden { + @apply tw:sr-only; +} diff --git a/resources/css/cp.css b/resources/css/cp.css index 946ef1453ae..64477dcea7d 100644 --- a/resources/css/cp.css +++ b/resources/css/cp.css @@ -2,9 +2,19 @@ CP Styles */ @import 'tailwindcss' prefix(tw); +/*@import "./compat.css";*/ +/*@import './forms.css';*/ @source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; @source '../../storage/framework/views/*.php'; @source '../**/*.blade.php'; @source '../**/*.js'; @source '../**/*.twig'; + +@theme { + --color-subtle: var(--c-border-subtle); +} + +:root { + --cp-sidebar-width: calc(240rem / 16); +} diff --git a/resources/css/forms.css b/resources/css/forms.css new file mode 100644 index 00000000000..ef1bb43f32a --- /dev/null +++ b/resources/css/forms.css @@ -0,0 +1,78 @@ +/** +Legacy styles to catch forms and fields not using the new components. + */ +input, +select, +textarea { + font: inherit; + color: inherit; +} + +label { + font-weight: bold; +} + +select { + min-width: 50%; +} + +input, +textarea { + background-color: var(--c-input-bg); + background-clip: padding-box; + border: var(--c-input-border); + border-radius: var(--c-input-radius); + min-height: var(--c-form-control-height); + padding-inline: var(--c-input-spacing-inline); + padding-block: var(--c-input-spacing-block); +} + +.select:not(.selectize) { + max-width: 100%; + position: relative; + border-radius: var(--c-input-radius); + white-space: nowrap; + display: inline-block; + + select { + appearance: none; + background-color: var(--c-form-control-bg); + border: var(--c-input-border); + display: block; + font-size: 14px; + line-height: 20px; + max-width: 100%; + padding-block: var(--c-input-spacing-block); + padding-inline: var(--c-input-spacing-inline) + calc(var(--c-input-spacing-inline) + 1.5rem); + position: relative; + white-space: pre; + } + + &::after { + border: solid; + border-width: 0 0.125rem 0.125rem 0; + color: var(--ui-control-color); + content: ''; + display: block; + font-size: 0; + height: 0.4375rem; + inset-block-start: calc(50% - 5px); + inset-inline-end: 9px; + opacity: 0.8; + pointer-events: none; + position: absolute; + transform: rotate(45deg); + user-select: none; + width: 0.4375rem; + z-index: 1; + } +} + +.input { + margin-block-start: var(--c-spacing-sm); +} + +.instructions { + color: var(--c-fg-muted); +} diff --git a/resources/icons/custom-icons/c-outline.svg b/resources/icons/custom-icons/c-outline.svg index 8224b6f288d..3c403d68c40 100644 --- a/resources/icons/custom-icons/c-outline.svg +++ b/resources/icons/custom-icons/c-outline.svg @@ -1,7 +1 @@ - - - Craft icon - - - - +Craft icon diff --git a/resources/js/components/CpSidebar.vue b/resources/js/components/CpSidebar.vue new file mode 100644 index 00000000000..15ad0fe1b2b --- /dev/null +++ b/resources/js/components/CpSidebar.vue @@ -0,0 +1,110 @@ + + + + + diff --git a/resources/js/components/DevModeIndicator.vue b/resources/js/components/DevModeIndicator.vue new file mode 100644 index 00000000000..435e3f18ebd --- /dev/null +++ b/resources/js/components/DevModeIndicator.vue @@ -0,0 +1,26 @@ + + + + + diff --git a/resources/js/components/EditionInfo.vue b/resources/js/components/EditionInfo.vue new file mode 100644 index 00000000000..632d48e32d4 --- /dev/null +++ b/resources/js/components/EditionInfo.vue @@ -0,0 +1,42 @@ + + + + + diff --git a/resources/js/components/MainNav.vue b/resources/js/components/MainNav.vue new file mode 100644 index 00000000000..b884270419f --- /dev/null +++ b/resources/js/components/MainNav.vue @@ -0,0 +1,38 @@ + + + + + diff --git a/resources/js/components/SystemInfo.vue b/resources/js/components/SystemInfo.vue new file mode 100644 index 00000000000..43ac7363907 --- /dev/null +++ b/resources/js/components/SystemInfo.vue @@ -0,0 +1,39 @@ + + + + + diff --git a/resources/js/components/VarDump.vue b/resources/js/components/VarDump.vue new file mode 100644 index 00000000000..fd9bdca62f8 --- /dev/null +++ b/resources/js/components/VarDump.vue @@ -0,0 +1,20 @@ + + + + + diff --git a/resources/js/composables/useCraftData.ts b/resources/js/composables/useCraftData.ts new file mode 100644 index 00000000000..4465e1c8ac4 --- /dev/null +++ b/resources/js/composables/useCraftData.ts @@ -0,0 +1,33 @@ +import {usePage} from '@inertiajs/vue3'; + +export interface CraftData { + system: { + name: string; + icon: string; + }; + app: { + version: string; + edition: 'Solo' | 'Team' | 'Pro' | 'Enterprise'; + }; + site: { + url: string; + }; + currentUser: any; + nav: any[]; + [key: string]: any; +} + +export default function useCraftData() { + const page = usePage<{ + craft: CraftData; + }>(); + + // return page.props.craft; + + // This is what Statamic does, I'm not sure if it's smart or overly complicated + return new Proxy({} as CraftData, { + get(target, prop: string) { + return page.props.craft?.[prop]; + }, + }); +} diff --git a/resources/js/cp.ts b/resources/js/cp.ts index bb797900b80..c242015b0c5 100644 --- a/resources/js/cp.ts +++ b/resources/js/cp.ts @@ -1 +1,20 @@ -console.log('hello from cp.ts') +import {createApp, h} from 'vue'; +import {resolvePageComponent} from 'laravel-vite-plugin/inertia-helpers'; +import type {DefineComponent} from 'vue'; +import {createInertiaApp} from '@inertiajs/vue3'; +import '@craftcms/cp/cp.css'; +import '@craftcms/cp'; + +// noinspection JSIgnoredPromiseFromCall +createInertiaApp({ + resolve: (name) => + resolvePageComponent( + `./pages/${name}.vue`, + import.meta.glob('./pages/**/*.vue') + ), + setup({el, App, props, plugin}) { + createApp({render: () => h(App, props)}) + .use(plugin) + .mount(el); + }, +}); diff --git a/resources/js/layout/AppLayout.vue b/resources/js/layout/AppLayout.vue new file mode 100644 index 00000000000..2edb94c273d --- /dev/null +++ b/resources/js/layout/AppLayout.vue @@ -0,0 +1,144 @@ + + + + + diff --git a/resources/js/pages/Dashboard.vue b/resources/js/pages/Dashboard.vue new file mode 100644 index 00000000000..717adadd9b6 --- /dev/null +++ b/resources/js/pages/Dashboard.vue @@ -0,0 +1,132 @@ + + + + + diff --git a/resources/templates/_layouts/base.twig b/resources/templates/_layouts/base.twig index daf6a2df5a8..825751d4fb6 100644 --- a/resources/templates/_layouts/base.twig +++ b/resources/templates/_layouts/base.twig @@ -60,7 +60,5 @@ {% include '_layouts/components/notifications' %} {% block foot %}{% endblock %} {{ endBody() }} - - {{ vite(['resources/css/cp.css', 'resources/js/cp.ts']) }} diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php new file mode 100644 index 00000000000..56e75b25d33 --- /dev/null +++ b/resources/views/app.blade.php @@ -0,0 +1,12 @@ + + + + + + @vite(['resources/css/cp.css', 'resources/js/cp.ts']) + @inertiaHead + + + @inertia + + diff --git a/routes/cp.php b/routes/cp.php index 8c695e3090b..e454a899f3b 100644 --- a/routes/cp.php +++ b/routes/cp.php @@ -11,6 +11,7 @@ use CraftCms\Cms\Http\Controllers\Settings\SitesController; use CraftCms\Cms\Http\Controllers\Updates\UpdaterController; use CraftCms\Cms\Http\Controllers\Utilities\UtilitiesController; +use CraftCms\Cms\Http\Middleware\HandleInertiaRequests; use CraftCms\Cms\Http\Middleware\RequireAdmin; use CraftCms\Cms\Http\Middleware\RequireAdminChanges; @@ -23,7 +24,8 @@ * Admin requests that require a login */ Route::middleware('auth')->group(function () { - Route::get('dashboard', DashboardController::class); + Route::get('dashboard', DashboardController::class) + ->middleware([HandleInertiaRequests::class]); Route::get('utilities', [UtilitiesController::class, 'index']); Route::get('utilities/{id}', [UtilitiesController::class, 'show']); diff --git a/src/CP/Navigation.php b/src/CP/Navigation.php new file mode 100644 index 00000000000..54528789407 --- /dev/null +++ b/src/CP/Navigation.php @@ -0,0 +1,201 @@ +getUser()->getIsAdmin(); + $generalConfig = app(GeneralConfig::class); + + $navItems = [ + [ + 'label' => t('Dashboard'), + 'url' => 'dashboard', + 'icon' => 'gauge', + ], + ]; + + if (Craft::$app->getEntries()->getTotalEditableSections()) { + $navItems[] = [ + 'label' => t('Entries'), + 'url' => 'entries', + 'icon' => 'newspaper', + ]; + } + + if (! empty(Craft::$app->getGlobals()->getEditableSets())) { + $navItems[] = [ + 'label' => t('Globals'), + 'url' => 'globals', + 'icon' => 'globe', + ]; + } + + if (Craft::$app->getCategories()->getEditableGroupIds()) { + $navItems[] = [ + 'label' => t('Categories'), + 'url' => 'categories', + 'icon' => 'sitemap', + ]; + } + + if (Craft::$app->getVolumes()->getTotalViewableVolumes()) { + $navItems[] = [ + 'label' => t('Assets'), + 'url' => 'assets', + 'icon' => 'image', + ]; + } + + if ( + Edition::get() !== Edition::Solo && + Craft::$app->getUser()->checkPermission('viewUsers') + ) { + $navItems[] = [ + 'label' => t('Users'), + 'url' => 'users', + 'icon' => 'user-group', + ]; + } + + // Add any Plugin nav items + $plugins = app(Plugins::class)->getAllPlugins(); + + foreach ($plugins as $plugin) { + if ( + $plugin->hasCpSection && + Craft::$app->getUser()->checkPermission('accessPlugin-'.$plugin->handle) && + ($pluginNavItem = $plugin->getCpNavItem()) !== null + ) { + $navItems[] = $pluginNavItem; + } + } + + if ($isAdmin) { + if ($generalConfig->enableGql) { + $subNavItems = []; + + if ($generalConfig->allowAdminChanges) { + $subNavItems['schemas'] = [ + 'label' => t('Schemas'), + 'url' => 'graphql/schemas', + ]; + } + + $subNavItems['tokens'] = [ + 'label' => t('Tokens'), + 'url' => 'graphql/tokens', + ]; + + $subNavItems['graphiql'] = [ + 'label' => 'GraphiQL', + 'url' => 'graphiql', + 'external' => true, + ]; + + $navItems[] = [ + 'label' => 'GraphQL', + 'url' => 'graphql', + 'icon' => 'graphql', + 'subnav' => $subNavItems, + ]; + } + } + + $utilities = app(Utilities::class)->getAuthorizedUtilityTypes(); + + if (! empty($utilities)) { + $badgeCount = 0; + + foreach ($utilities as $class) { + /** @var Utility $class */ + $badgeCount += $class::badgeCount(); + } + + $navItems[] = [ + 'url' => 'utilities', + 'label' => t('Utilities'), + 'icon' => 'wrench', + 'badgeCount' => $badgeCount, + ]; + } + + if ($isAdmin) { + $navItems[] = [ + 'url' => 'settings', + 'label' => t('Settings'), + 'icon' => app(GeneralConfig::class)->allowAdminChanges ? 'gear' : 'gear-slash', + ]; + + $navItems[] = [ + 'url' => 'plugin-store', + 'label' => t('Plugin Store'), + 'icon' => 'plug', + ]; + } + + // Fire a 'registerCpNavItems' event + // @TODO Bring this back + // if ($this->hasEventHandlers(self::EVENT_REGISTER_CP_NAV_ITEMS)) { + // $event = new RegisterCpNavItemsEvent(['navItems' => $navItems]); + // $this->trigger(self::EVENT_REGISTER_CP_NAV_ITEMS, $event); + // $navItems = $event->navItems; + // } + + // Figure out which item is selected, and normalize the items + $path = Craft::$app->getRequest()->getPathInfo(); + + if ($path === 'myaccount' || str_starts_with($path, 'myaccount/')) { + $path = 'users'; + } + + $foundSelectedItem = false; + + foreach ($navItems as &$item) { + if (! $foundSelectedItem && ($item['url'] == $path || str_starts_with($path, $item['url'].'/'))) { + $item['sel'] = true; + if (! isset($item['subnav'])) { + $item['subnav'] = false; + } + $foundSelectedItem = true; + + // Modify aria-current value for exact page vs. subpages + $item['linkAttributes']['aria']['current'] = $item['url'] === $path ? 'page' : 'true'; + } else { + $item['sel'] = false; + if (! isset($item['subnav'])) { + $item['subnav'] = false; + } + } + + if (! isset($item['id'])) { + $item['id'] = 'nav-'.preg_replace('/[^\w\-_]/', '', $item['url']); + } + + $item['url'] = UrlHelper::url($item['url']); + + if (! isset($item['external'])) { + $item['external'] = false; + } + + if (! isset($item['badgeCount'])) { + $item['badgeCount'] = 0; + } + } + + return $navItems; + } +} diff --git a/src/Http/Controllers/Dashboard/DashboardController.php b/src/Http/Controllers/Dashboard/DashboardController.php index 0f9c65f3113..3a3f7807dcc 100644 --- a/src/Http/Controllers/Dashboard/DashboardController.php +++ b/src/Http/Controllers/Dashboard/DashboardController.php @@ -9,6 +9,7 @@ use CraftCms\Cms\Support\Json; use Illuminate\Container\Attributes\Give; use Illuminate\Support\Collection; +use Inertia\Inertia; final readonly class DashboardController { @@ -83,16 +84,20 @@ public function __invoke() $allWidgetJs .= $widgetJs ? $widgetJs."\n" : ''; }); - // Include all the JS and CSS stuff - $this->view->registerAssetBundle(DashboardAsset::class); - $this->view->registerJsWithVars( - fn ($widgetTypeInfo) => "window.dashboard = new Craft.Dashboard($widgetTypeInfo)", - [$widgetTypeInfo] - ); - $this->view->registerJs($allWidgetJs); - $variables['widgetTypes'] = $widgetTypeInfo; - return $this->view->renderPageTemplate('dashboard/_index.twig', $variables); + if (request()->has('legacy')) { + // Include all the JS and CSS stuff + $this->view->registerAssetBundle(DashboardAsset::class); + $this->view->registerJsWithVars( + fn ($widgetTypeInfo) => "window.dashboard = new Craft.Dashboard($widgetTypeInfo)", + [$widgetTypeInfo] + ); + $this->view->registerJs($allWidgetJs); + + return $this->view->renderPageTemplate('dashboard/_index.twig', $variables); + } + + return Inertia::render('Dashboard', $variables); } } diff --git a/src/Http/Middleware/HandleInertiaRequests.php b/src/Http/Middleware/HandleInertiaRequests.php new file mode 100644 index 00000000000..cd75c2afb36 --- /dev/null +++ b/src/Http/Middleware/HandleInertiaRequests.php @@ -0,0 +1,89 @@ + + */ + public function share(Request $request): array + { + $sitesService = Craft::$app->getSites(); + $currentSite = $sitesService->getCurrentSite(); + $isInstalled = Craft::$app->getIsInstalled(); + $updates = app(Updates::class); + + if ($isInstalled && ! $updates->isCraftUpdatePending()) { + $currentUser = Craft::$app->getUser()->getIdentity(); + + if (! $currentUser) { + $user = Auth::user(); + + if ($user) { + Craft::$app->getUser()->setIdentity(Craft::$app->getUsers()->getUserById($user->id)); + $currentUser = Craft::$app->getUser()->getIdentity(); + } + } + } + + $systemIcon = Cp::iconSvg('c-outline'); + + if ($rebrand = app(Rebrand::class)) { + $systemIcon = $rebrand->isIconUploaded() ? $rebrand->getIcon()->getUrl() : $systemIcon; + } + + return [ + ...parent::share($request), + 'craft' => [ + 'system' => [ + 'name' => Craft::$app->getSystemName(), + 'icon' => $systemIcon, + ], + 'app' => [ + 'version' => Craft::$app->getVersion(), + 'edition' => Edition::get()->name, + ], + 'site' => [ + 'url' => $currentSite->getBaseUrl(), + ], + 'currentUser' => [ + ], + 'nav' => Navigation::getItems(), + ], + ]; + } +} diff --git a/src/Providers/AppServiceProvider.php b/src/Providers/AppServiceProvider.php index 01ebc1f15e0..2376ea0962c 100644 --- a/src/Providers/AppServiceProvider.php +++ b/src/Providers/AppServiceProvider.php @@ -36,6 +36,7 @@ use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Vite; use Illuminate\Support\ServiceProvider; use IntlDateFormatter; use IntlException; @@ -106,6 +107,9 @@ public function boot(): void $this->bootMiddleware(); $this->loadRoutesFrom("{$this->root}/routes/routes.php"); + $this->loadViewsFrom("{$this->root}/resources/views", 'c'); + + Vite::useHotFile("{$this->root}/resources/hot"); $this->app->booted(function () { if (Info::isInstalled() && ! Updates::isCraftUpdatePending()) { diff --git a/vite.config.js b/vite.config.js index cdd325716ad..0beb93caaa0 100644 --- a/vite.config.js +++ b/vite.config.js @@ -13,14 +13,14 @@ export default defineConfig(({mode}) => { const server = url.hostname.includes('.ddev.site') ? { - host, - cors: url.toString(), - hmr: {host}, - https: { - key: fs.readFileSync(env.VITE_SERVER_HTTPS_PATH_KEY), - cert: fs.readFileSync(env.VITE_SERVER_HTTPS_PATH_CERT), - }, - } + host, + cors: url.toString(), + hmr: {host}, + https: { + key: fs.readFileSync(env.VITE_SERVER_HTTPS_PATH_KEY), + cert: fs.readFileSync(env.VITE_SERVER_HTTPS_PATH_CERT), + }, + } : undefined; return { From 1a5668c876a2fab56056cf036b995f2160636ffe Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Mon, 20 Oct 2025 16:55:33 -0500 Subject: [PATCH 02/21] A lot of foot work for rendering the dashboard --- package-lock.json | 1 + package.json | 1 + resources/css/compat.css | 8 ++ .../js/components/DynamicHtmlRenderer.vue | 14 +++ resources/js/composables/useUpdatesService.ts | 117 ++++++++++++++++++ resources/js/cp.ts | 14 ++- resources/js/pages/Dashboard.vue | 10 +- resources/js/widgets/UpdatesWidget.vue | 89 +++++++++++++ .../_components/widgets/Updates/body.twig | 17 +-- src/Dashboard/Widgets/Updates.php | 14 +-- vite.config.js | 1 + 11 files changed, 256 insertions(+), 30 deletions(-) create mode 100644 resources/js/components/DynamicHtmlRenderer.vue create mode 100644 resources/js/composables/useUpdatesService.ts create mode 100644 resources/js/widgets/UpdatesWidget.vue diff --git a/package-lock.json b/package-lock.json index 79dffb8d6e4..a633ff8543b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@craftcms/cp": "file:../npm-packages/packages/cp", "@inertiajs/vue3": "^2.2.7", + "axios": "^1.12.2", "laravel-vite-plugin": "^2.0.1", "vue": "^3.5.22" }, diff --git a/package.json b/package.json index b9c6aca51a2..bc4d726b8e0 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "dependencies": { "@craftcms/cp": "file:../npm-packages/packages/cp", "@inertiajs/vue3": "^2.2.7", + "axios": "^1.12.2", "laravel-vite-plugin": "^2.0.1", "vue": "^3.5.22" } diff --git a/resources/css/compat.css b/resources/css/compat.css index 224d823e570..1354ee222a5 100644 --- a/resources/css/compat.css +++ b/resources/css/compat.css @@ -6,6 +6,14 @@ Maps our old custom utility classes to tailwind. @apply tw:w-full; } +.nowrap { + @apply tw:whitespace-nowrap; +} + +.centeralign { + @apply tw:text-center; +} + .visually-hidden { @apply tw:sr-only; } diff --git a/resources/js/components/DynamicHtmlRenderer.vue b/resources/js/components/DynamicHtmlRenderer.vue new file mode 100644 index 00000000000..eb207aaaebe --- /dev/null +++ b/resources/js/components/DynamicHtmlRenderer.vue @@ -0,0 +1,14 @@ + + diff --git a/resources/js/composables/useUpdatesService.ts b/resources/js/composables/useUpdatesService.ts new file mode 100644 index 00000000000..6ebe4cb17f7 --- /dev/null +++ b/resources/js/composables/useUpdatesService.ts @@ -0,0 +1,117 @@ +import {actionClient, apiClient} from '@craftcms/cp'; +import {ref} from 'vue'; +import axios, {AxiosError, type AxiosResponse} from 'axios'; + +interface UpdatesResponseData { + cms: { + status: 'breakpoint' | 'eligible' | 'expired'; + releases: Array; + renewalUrl?: string; + renewalPrice?: string; + renewalCurrency?: string; + phpConstraint?: string; + packageName?: string; + }; + plugins: Record; +} + +async function cacheUpdates( + updates: UpdatesResponseData, + includeDetails = false +) { + const data = { + updates, + includeDetails, + }; + + return actionClient.post('app/cache-updates', data); +} + +async function getUpdates(includeDetails = false) { + const {data} = await apiClient.get('updates', { + data: { + onlyIfCached: false, + includeDetails, + }, + }); + + return cacheUpdates(data, includeDetails); +} + +async function _checkForUpdates(forceRefresh = false, includeDetails = false) { + if (!forceRefresh) { + // Check if we have cached info first + const {data: info} = await actionClient.post('app/check-for-updates', { + onlyIfCached: true, + includeDetails, + }); + + + if (info.cached) { + return info; + } + + return getUpdates(includeDetails); + } else { + return getUpdates(includeDetails); + } +} + +export function useUpdatesCheck( + options: { + enabled?: boolean; + initialData?: Record; + } = {} +) { + const {initialData = {}, enabled = true} = options; + + const state = ref( + enabled ? null : 'success' + ); + const data = ref | null>(initialData); + const response = ref | null>(null); + const error = ref(null); + + async function checkForUpdates(forceRefresh = false) { + if (state.value === 'pending') { + return; + } + + state.value = 'pending'; + try { + const response = await _checkForUpdates(forceRefresh); + state.value = 'success'; + data.value = response.data; + error.value = null; + } catch (e: AxiosError | unknown) { + state.value = 'error'; + if (axios.isAxiosError(e)) { + if (e.response) { + error.value = + e.response.data?.message || + `Request failed with status ${e.response.status}`; + } else if (e.request) { + error.value = e.request; + } else { + error.value = e.message; + } + } else { + error.value = e; + } + } + } + + const refetch = checkForUpdates; + + if (enabled) { + checkForUpdates(); + } + + return { + state, + data, + error, + refetch, + response, + }; +} diff --git a/resources/js/cp.ts b/resources/js/cp.ts index c242015b0c5..4302d4359f8 100644 --- a/resources/js/cp.ts +++ b/resources/js/cp.ts @@ -5,6 +5,11 @@ import {createInertiaApp} from '@inertiajs/vue3'; import '@craftcms/cp/cp.css'; import '@craftcms/cp'; +import Updates from '@/widgets/UpdatesWidget.vue'; + +// @ts-ignore @TODO +window.Craft = window.Craft || {}; + // noinspection JSIgnoredPromiseFromCall createInertiaApp({ resolve: (name) => @@ -13,8 +18,11 @@ createInertiaApp({ import.meta.glob('./pages/**/*.vue') ), setup({el, App, props, plugin}) { - createApp({render: () => h(App, props)}) - .use(plugin) - .mount(el); + const app = createApp({render: () => h(App, props)}); + + app.component('updates-widget', Updates); + + app.use(plugin); + app.mount(el); }, }); diff --git a/resources/js/pages/Dashboard.vue b/resources/js/pages/Dashboard.vue index 717adadd9b6..1fe6ef68cf4 100644 --- a/resources/js/pages/Dashboard.vue +++ b/resources/js/pages/Dashboard.vue @@ -3,6 +3,7 @@ import {usePage} from '@inertiajs/vue3'; import VarDump from '@/components/VarDump.vue'; import {computed, ref} from 'vue'; + import DynamicHtmlRenderer from '@/components/DynamicHtmlRenderer.vue'; interface Widget { id: number; @@ -35,7 +36,7 @@ }; }>(); - const widgets = ref(props.widgets) + const widgets = ref(props.widgets); const openDrawer = (id: string) => { const drawer = document.querySelector(`#${id}`); @@ -59,7 +60,7 @@ settingsHtml: info.settingsHtml, settingsJs: info.settingsJs, settings: {}, - }) + }); } @@ -97,12 +98,13 @@ -
+ + -
+