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 3cd6bb3c1ac..dfb6124efd7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,8 @@ "dependencies": { "@craftcms/cp": "file:packages/craftcms-cp", "@inertiajs/vue3": "^2.2.7", + "@vueuse/core": "^13.9.0", + "axios": "^1.12.2", "laravel-vite-plugin": "^2.0.1", "lit": "^3.3.1", "tailwindcss": "^4.1.14", @@ -6205,6 +6207,12 @@ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "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==", + "license": "MIT" + }, "node_modules/@types/whatwg-mimetype": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", @@ -6947,6 +6955,44 @@ "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==", + "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==", + "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==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, "node_modules/@wc-toolkit/storybook-helpers": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/@wc-toolkit/storybook-helpers/-/storybook-helpers-9.0.1.tgz", @@ -28669,6 +28715,11 @@ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" }, + "@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==" + }, "@types/whatwg-mimetype": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", @@ -29220,6 +29271,27 @@ "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==", + "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==" + }, + "@vueuse/shared": { + "version": "13.9.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-13.9.0.tgz", + "integrity": "sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g==", + "requires": {} + }, "@wc-toolkit/storybook-helpers": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/@wc-toolkit/storybook-helpers/-/storybook-helpers-9.0.1.tgz", diff --git a/package.json b/package.json index 3dc60adbb39..eaffc1247d6 100644 --- a/package.json +++ b/package.json @@ -4,11 +4,12 @@ "main": "webpack.config.js", "type": "module", "scripts": { - "check-prettier": "prettier --check ./resources", - "fix-prettier": "prettier --write ./resources", + "prettier:check": "prettier --check ./resources", + "prettier:fix": "prettier --write ./resources", "prepare": "husky", - "prebuild": "npm run fix-prettier", + "prebuild": "npm run prettier:fix", "build": "vite build", + "types:check": "vue-tsc --noEmit", "dev": "vite", "build:bundles": "npm run build -w @craftcms/asset-bundles", "dev:bundles": "npm run dev -w @craftcms/asset-bundles", @@ -50,6 +51,8 @@ "dependencies": { "@craftcms/cp": "file:packages/craftcms-cp", "@inertiajs/vue3": "^2.2.7", + "@vueuse/core": "^13.9.0", + "axios": "^1.12.2", "laravel-vite-plugin": "^2.0.1", "lit": "^3.3.1", "tailwindcss": "^4.1.14", diff --git a/resources/css/cp.css b/resources/css/cp.css index e5c2dfcd34d..1edf32e8521 100644 --- a/resources/css/cp.css +++ b/resources/css/cp.css @@ -1,7 +1,11 @@ /** CP Styles */ -@import '@craftcms/cp'; +@layer theme, base, cp, components, utilities; +@import 'tailwindcss/theme.css' layer(theme) prefix(tw); +@import 'tailwindcss/utilities.css' layer(utilities) prefix(tw); + +@import '@craftcms/cp' layer(cp); @import './global-sidebar.css'; @@ -9,3 +13,7 @@ CP Styles --global-sidebar-width: calc(226rem / 16); --header-height: calc(44rem / 16); } + +:root { + --cp-sidebar-width: calc(240rem / 16); +} 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/Pages/Dashboard.vue b/resources/js/Pages/Dashboard.vue new file mode 100644 index 00000000000..484befb7fde --- /dev/null +++ b/resources/js/Pages/Dashboard.vue @@ -0,0 +1,348 @@ + + + + + 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/DynamicHtmlRenderer.vue b/resources/js/components/DynamicHtmlRenderer.vue new file mode 100644 index 00000000000..6c5e1d2f3ec --- /dev/null +++ b/resources/js/components/DynamicHtmlRenderer.vue @@ -0,0 +1,14 @@ + + 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/useAxios.ts b/resources/js/composables/useAxios.ts new file mode 100644 index 00000000000..ca5b7f8e024 --- /dev/null +++ b/resources/js/composables/useAxios.ts @@ -0,0 +1,193 @@ +import { + ref, + computed, + watch, + unref, + type Ref, + type ComputedRef, + type UnwrapRef, +} from 'vue'; +import axios, { + type AxiosError, + type AxiosRequestConfig, + type AxiosResponse, + type CancelTokenSource, +} from 'axios'; + +// Type for URL parameter - can be string, ref, or computed +type MaybeRef = T | Ref | ComputedRef; + +// Options interface +interface UseAxiosOptions + extends Omit { + immediate?: boolean; + refetch?: boolean; + params?: MaybeRef>; + transform?: (data: T) => T; + enabled?: MaybeRef; + debounce?: number; + onSuccess?: (data: T, response: AxiosResponse) => void; + onError?: (error: any) => void; + initialData?: T | null; +} + +// Return type interface +interface UseAxiosReturn { + data: Ref | null>; + error: any; + state: Ref; + isLoading: ComputedRef; + isSuccess: ComputedRef; + isError: ComputedRef; + refetch: () => Promise; + abort: () => void; +} + +export type AxiosFetchState = + | 'idle' + | 'loading' + | 'success' + | 'error' + | 'aborted'; + +export function useAxios( + url: MaybeRef, + options: UseAxiosOptions = {} +): UseAxiosReturn { + + // Options with defaults + const { + immediate = true, + refetch: refetchOption = true, + params, + enabled = true, + debounce = 0, + transform = (data: any) => data as T, + onSuccess, + onError, + initialData = null, + ...axiosOptions + } = options; + + // Reactive state + const data = ref(initialData) as Ref | null>; + const state = ref('idle'); + const error = ref(null); + + const isLoading = computed(() => state.value === 'loading'); + const isSuccess = computed(() => state.value === 'success'); + const isError = computed(() => state.value === 'error'); + + const computedUrl = computed(() => unref(url)); + const computedEnabled = computed(() => unref(enabled)); + const computedParams = computed | undefined>(() => + unref(params) + ); + + // Axios cancel token + let cancelTokenSource: CancelTokenSource | null = null; + let debounceTimer: number | null = null; + + // The actual fetch function + const execute = async (): Promise => { + if (!computedUrl.value || !computedEnabled.value) return; + + // Cancel previous request + if (cancelTokenSource) { + cancelTokenSource.cancel('Request superseded by new request'); + } + + cancelTokenSource = axios.CancelToken.source(); + state.value = 'loading'; + error.value = null; + + try { + const response = await axios({ + url: computedUrl.value, + params: computedParams.value, + cancelToken: cancelTokenSource.token, + ...axiosOptions, + }); + + const transformedData = transform(response.data); + state.value = 'success'; + data.value = transformedData as UnwrapRef; + onSuccess?.(transformedData, response); + } catch (err: AxiosError | any) { + if (axios.isCancel(err)) { + state.value = 'aborted'; + } else if (axios.isAxiosError(err)) { + console.log('Axios error:', err.response?.data); + state.value = 'error'; + error.value = err.response?.data || err.message || 'Unknown error'; + onError?.(err); + } else { + console.log('Unkown error:', err.message); + state.value = 'error'; + error.value = err.message || 'Unknown error'; + } + } + }; + + const debouncedExecute = (): void => { + // Clear existing timer + if (debounceTimer) { + clearTimeout(debounceTimer); + } + + if (debounce > 0) { + debounceTimer = setTimeout(() => { + execute(); + }, debounce); + } else { + execute(); + } + }; + + // Watch for changes in URL, params, and enabled state + if (refetchOption) { + watch( + [computedUrl, computedParams, computedEnabled], + () => { + if (computedEnabled.value) { + debouncedExecute(); + } else { + // Clear debounce timer and cancel request when disabled + if (debounceTimer) { + clearTimeout(debounceTimer); + } + if (cancelTokenSource) { + cancelTokenSource.cancel('Request disabled'); + } + } + }, + {immediate, deep: true} + ); + } else if (immediate && computedEnabled.value) { + debouncedExecute(); + } + + // Manual refetch function + const refetch = (): Promise => execute(); + + // Cancel function + const abort = (): void => { + if (debounceTimer) { + clearTimeout(debounceTimer); + } + if (cancelTokenSource) { + cancelTokenSource.cancel('Request cancelled by user'); + } + }; + + return { + data, + error, + state, + isLoading, + isSuccess, + isError, + refetch, + abort, + }; +} diff --git a/resources/js/composables/useCraftData.ts b/resources/js/composables/useCraftData.ts new file mode 100644 index 00000000000..3d0b7aaf478 --- /dev/null +++ b/resources/js/composables/useCraftData.ts @@ -0,0 +1,55 @@ +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; +} + +/** + * @TODO move to NPM package + */ +export function useHelpers() { + const craftData = useCraftData(); + + return { + // @TODO move to NPM package + getActionUrl(action: string) { + const url = new URL(craftData.actionUrl); + const cleanPath = action.startsWith('/') ? action.slice(1) : action; + url.pathname = `${url.pathname}/${cleanPath}`; + return url.toString(); + }, + // @TODO move to NPM package + getCpUrl(action: string) { + const url = new URL(craftData.cpUrl); + const cleanPath = action.startsWith('/') ? action.slice(1) : action; + url.pathname = `${url.pathname}/${cleanPath}`; + return url.toString(); + } + }; +} + +export default function useCraftData(): CraftData { + const page = usePage<{ + craft: CraftData; + }>(); + + // 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/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 da873f5fe65..acdb64851bc 100644 --- a/resources/js/cp.ts +++ b/resources/js/cp.ts @@ -1,5 +1,36 @@ +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'; +import SupportWidget from '@/widgets/SupportWidget.vue'; +import UpdatesWidget from '@/widgets/UpdatesWidget.vue'; +import FeedWidget from '@/widgets/FeedWidget.vue'; + +// @ts-ignore @TODO +window.Craft = window.Craft || {}; + +// noinspection JSIgnoredPromiseFromCall +createInertiaApp({ + resolve: (name) => + resolvePageComponent( + `./Pages/${name}.vue`, + import.meta.glob('./Pages/**/*.vue') + ), + setup({el, App, props, plugin}) { + const app = createApp({render: () => h(App, props)}); + + app.component('updates-widget', UpdatesWidget); + app.component('support-widget', SupportWidget); + app.component('feed-widget', FeedWidget); + + app.use(plugin); + app.mount(el); + }, +}); + /** * Components */ diff --git a/resources/js/layout/AppLayout.vue b/resources/js/layout/AppLayout.vue new file mode 100644 index 00000000000..c425734af43 --- /dev/null +++ b/resources/js/layout/AppLayout.vue @@ -0,0 +1,145 @@ + + + + + diff --git a/resources/js/types/index.ts b/resources/js/types/index.ts new file mode 100644 index 00000000000..0b8f20d501c --- /dev/null +++ b/resources/js/types/index.ts @@ -0,0 +1,30 @@ +export interface Widget { + id: number; + type: string; + colspan: number; + title: string | null; + subtitle: string | null; + name: string | null; + bodyHtml: string; + settingsHtml: string; + settingsJs: string; + settings: { + [key: string]: any; + }; +} + +export interface WidgetType { + iconSvg: string; + name: string; + maxColspan: null | number; + settingsHtml: string; + settingsJs: string; + selectable: boolean; +} + +export type CompleteWidget = Widget & { + view?: 'settings' | 'default'; + mode?: 'edit' | 'view'; + new?: boolean; + settingsNamespace?: string; +}; diff --git a/resources/js/widgets/BaseWidget.vue b/resources/js/widgets/BaseWidget.vue new file mode 100644 index 00000000000..c8c801b8c19 --- /dev/null +++ b/resources/js/widgets/BaseWidget.vue @@ -0,0 +1,152 @@ + + + + + diff --git a/resources/js/widgets/FeedWidget.vue b/resources/js/widgets/FeedWidget.vue new file mode 100644 index 00000000000..9c27647b53f --- /dev/null +++ b/resources/js/widgets/FeedWidget.vue @@ -0,0 +1,58 @@ + + + + + diff --git a/resources/js/widgets/SupportWidget.vue b/resources/js/widgets/SupportWidget.vue new file mode 100644 index 00000000000..90c64b6cb39 --- /dev/null +++ b/resources/js/widgets/SupportWidget.vue @@ -0,0 +1,131 @@ + + + + + diff --git a/resources/js/widgets/UpdatesWidget.vue b/resources/js/widgets/UpdatesWidget.vue new file mode 100644 index 00000000000..a616c429b38 --- /dev/null +++ b/resources/js/widgets/UpdatesWidget.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/resources/js/widgets/support/DeveloperSupportScreen.vue b/resources/js/widgets/support/DeveloperSupportScreen.vue new file mode 100644 index 00000000000..09efcd9add9 --- /dev/null +++ b/resources/js/widgets/support/DeveloperSupportScreen.vue @@ -0,0 +1,166 @@ + + + + + diff --git a/resources/js/widgets/support/FeedbackScreen.vue b/resources/js/widgets/support/FeedbackScreen.vue new file mode 100644 index 00000000000..75c770d0b8c --- /dev/null +++ b/resources/js/widgets/support/FeedbackScreen.vue @@ -0,0 +1,117 @@ + + + + + diff --git a/resources/js/widgets/support/HelpScreen.vue b/resources/js/widgets/support/HelpScreen.vue new file mode 100644 index 00000000000..8a4c750ece1 --- /dev/null +++ b/resources/js/widgets/support/HelpScreen.vue @@ -0,0 +1,121 @@ + + + + + diff --git a/resources/js/widgets/support/SimilarItem.vue b/resources/js/widgets/support/SimilarItem.vue new file mode 100644 index 00000000000..59a12c64775 --- /dev/null +++ b/resources/js/widgets/support/SimilarItem.vue @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/js/widgets/support/SimilarItems.vue b/resources/js/widgets/support/SimilarItems.vue new file mode 100644 index 00000000000..aac0f3514e1 --- /dev/null +++ b/resources/js/widgets/support/SimilarItems.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/resources/js/widgets/support/SupportResources.vue b/resources/js/widgets/support/SupportResources.vue new file mode 100644 index 00000000000..b7cb148c271 --- /dev/null +++ b/resources/js/widgets/support/SupportResources.vue @@ -0,0 +1,138 @@ + + + + + diff --git a/resources/templates/_components/widgets/CraftSupport/body.twig b/resources/templates/_components/widgets/CraftSupport/body.twig index 66e9d165c88..7f903d1cd5f 100644 --- a/resources/templates/_components/widgets/CraftSupport/body.twig +++ b/resources/templates/_components/widgets/CraftSupport/body.twig @@ -1,197 +1,5 @@ -{% import "_includes/links" as links %} - -{% macro resourceLink(config) %} - -

- {{ config.title }} -

-

{{ config.description }} {{ links.externalLinkIcon() }}

-
-{% endmacro %} - -{% macro screen(widget, showBackupOption, bundleUrl, screen, placeholder, resultsIcon, resultsHeading, formAction, submitText) %} - {% import "_includes/forms" as forms %} - {% set idPrefix = 'cs-'~screen~random() %} - {% set buttonWrapperId = "form-container-" ~ screen %} - -
- {{ tag('h2', { - text: submitText, - class: 'cs-heading' - }) }} - {{ forms.textareaField({ - first: true, - class: 'cs-body-text', - label: placeholder|t('app'), - rows: 5 - }) }} - -
-
-
- {% tag 'div' with { - class: 'cs-button-wrapper', - id: buttonWrapperId - } %} - - {% if CraftEdition >= CraftPro %} -

{{ "or send to Developer Support"|t('app')|raw }}

- {% endif %} - {{ tag('button', { - class: 'btn fullwidth cancel', - type: 'button', - text: 'Cancel'|t('app'), - }) }} - {% endtag %} -
-

{{ 'More Resources'|t('app') }}

-
- {{ _self.resourceLink({ - link: 'https://craftcms.com/partners', - iconPath: '/logos/craft-partners.svg', - title: 'Craft Partners', - description: 'Find an official Craft Partner'|t('app'), - bundleUrl: bundleUrl, - }) }} - {{ _self.resourceLink({ - link: 'https://craftcms.com/discord', - iconPath: '/logos/discord.svg', - title: 'Discord', - description: 'Meet the Craft community'|t('app'), - bundleUrl: bundleUrl, - }) }} - {{ _self.resourceLink({ - link: 'https://craftquest.io', - iconPath: '/logos/craftquest.svg', - title: 'CraftQuest', - description: 'Unlimited video training'|t('app'), - bundleUrl: bundleUrl, - }) }} -
-
- {% set documentationLinkHtml %} - {{ iconSvg('book') }} - {{ 'Documentation'|t('app') }} - {% endset %} - {% set knowledgeBaseLinkHtml %} - {{ iconSvg('magnifying-glass') }} - {{ 'Knowledge Base'|t('app') }} - {% endset %} - {{ links.externalLink({ - link: 'https://craftcms.com/docs/5.x/', - html: documentationLinkHtml - }) }} - {{ links.externalLink({ - link: 'https://craftcms.com/knowledge-base', - html: knowledgeBaseLinkHtml - }) }} -
-
- -
- -
-{% endmacro %} - -{% from _self import screen %} - - -
- - -
- -{{ screen( - widget, - showBackupOption, - bundleUrl, - 'help', - 'Briefly describe your question.'|t('app'), - iconSvg('craft-stack-exchange'), - 'Similar questions on Stack Exchange'|t('app'), - 'https://craftcms.stackexchange.com/questions/ask', - 'Ask on Stack Exchange'|t('app'), -) }} - -{{ screen( - widget, - showBackupOption, - bundleUrl, - 'feedback', - 'Briefly describe your issue or idea.'|t('app'), - iconSvg('github'), - 'Similar issues on GitHub'|t('app'), - 'https://github.com/craftcms/cms/issues/new', - 'Post on GitHub'|t('app'), -) }} + diff --git a/resources/templates/_components/widgets/Feed/body.twig b/resources/templates/_components/widgets/Feed/body.twig index 289f637aee7..467526dd29f 100644 --- a/resources/templates/_components/widgets/Feed/body.twig +++ b/resources/templates/_components/widgets/Feed/body.twig @@ -1,22 +1,5 @@ -{% import "_includes/links" as links %} - -
    - {% for item in feed.items %} -
  1. - {% if item.permalink ?? false %} - {{ links.externalLink({ - link: item.permalink, - text: item.title, - }) }} - {% if item.date ?? false %} - {{ tag('span', { - class: 'light nowrap', - text: item.date|timestamp('short'), - }) }} - {% endif %} - {% else %} -   - {% endif %} -
  2. - {% endfor %} -
+ diff --git a/resources/templates/_components/widgets/Feed/settings.twig b/resources/templates/_components/widgets/Feed/settings.twig index 81d7fbf35fc..e831ca1120b 100644 --- a/resources/templates/_components/widgets/Feed/settings.twig +++ b/resources/templates/_components/widgets/Feed/settings.twig @@ -1,31 +1,53 @@ {% import "_includes/forms" as forms %} + + {% if widget.getErrors('url') %} +
+ {% include '_inclues/forms/errorList.twig' with { + 'id': '', + 'errors': widget.getErrors('url') + } only %} +
+ {% endif %} +
-{{ forms.textField({ - label: "URL"|t('app'), - id: 'url', - name: 'url', - value: widget.url, - required: true, - errors: widget.getErrors('url') -}) }} + + {% if widget.getErrors('title') %} +
+ {% include '_inclues/forms/errorList.twig' with { + 'id': '', + 'errors': widget.getErrors('title') + } only %} +
+ {% endif %} +
- -{{ forms.textField({ - label: "Title"|t('app'), - id: 'title', - name: 'title', - value: widget.title, - required: true, - errors: widget.getErrors('title') -}) }} - - -{{ forms.textField({ - label: "Limit"|t('app'), - id: 'limit', - name: 'limit', - value: widget.limit, - size: 2, - errors: widget.getErrors('limit') -}) }} + + {% if widget.getErrors('limit') %} +
+ {% include '_inclues/forms/errorList.twig' with { + 'id': '', + 'errors': widget.getErrors('limit') + } only %} +
+ {% endif %} +
diff --git a/resources/templates/_components/widgets/RecentEntries/body.twig b/resources/templates/_components/widgets/RecentEntries/body.twig index 8275f2cfe63..51417552791 100644 --- a/resources/templates/_components/widgets/RecentEntries/body.twig +++ b/resources/templates/_components/widgets/RecentEntries/body.twig @@ -1,17 +1,18 @@ -
- {% if entries|length %} -
    - {% for entry in entries %} -
  1. - {{ entry.title }} - - {{ entry.dateCreated|timestamp('short') }} - {%- if CraftEdition != CraftSolo and entry.getAuthor() %}, {{ entry.getAuthor().username }}{% endif -%} - -
  2. - {% endfor %} -
- {% else %} -

{{ "No entries exist yet."|t('app') }}

- {% endif %} +
+ {% if entries|length %} +
    + {% for entry in entries %} +
  1. + {{ entry.title }} + + {{ entry.dateCreated|timestamp('short') }} + + {%- if CraftEdition != CraftSolo and entry.getAuthor() %}, {{ entry.getAuthor().username }}{% endif -%} + +
  2. + {% endfor %} +
+ {% else %} +

{{ "No entries exist yet."|t('app') }}

+ {% endif %}
diff --git a/resources/templates/_components/widgets/RecentEntries/settings.twig b/resources/templates/_components/widgets/RecentEntries/settings.twig index 8de4017d612..c538b834c96 100644 --- a/resources/templates/_components/widgets/RecentEntries/settings.twig +++ b/resources/templates/_components/widgets/RecentEntries/settings.twig @@ -1,50 +1,38 @@ -{% import "_includes/forms" as forms %} - {% if craft.sites.isMultiSite() %} - {% set editableSites = craft.sites.getEditableSites() %} - - {% if editableSites|length > 1 %} - {% set siteInput %} -
- -
- {% endset %} + {% set editableSites = craft.sites.getEditableSites() %} - {{ forms.field({ - id: 'site-id', - label: "Site"|t('app') - }, siteInput) }} - {% endif %} + {% if editableSites|length > 1 %} + + {% for site in editableSites %} + {{ site.name|t('site') }} + {% endfor %} + + {% endif %} {% endif %} -{% set sectionInput %} -
- -
-{% endset %} - -{{ forms.field({ - label: "Section"|t('app'), - instructions: "Which section do you want to pull recent entries from?"|t('app'), - id: 'section', -}, sectionInput) }} + + {{ "All"|t('app') }} + {% for section in craft.app.entries.getAllSections() %} + {% if section.type != 'single' %} + {{ section.name|t('site') }} + {% endif %} + {% endfor %} + -{{ forms.textField({ - label: "Limit"|t('app'), - id: 'limit', - name: 'limit', - value: widget.limit, - size: 2, - errors: widget.getErrors('limit') -}) }} + + {% if widget.getErrors('limit') %} +
    + {% for error in widget.getErrors('limit') %} +
  • + {{ 'Error:'|t('app') }} + {{ error|md(inlineOnly=true, encode=true)|raw }} +
  • + {% endfor %} +
+ {% endif %} +
diff --git a/resources/templates/_components/widgets/Updates/body.twig b/resources/templates/_components/widgets/Updates/body.twig index d22bb9a307d..e2edd50d88d 100644 --- a/resources/templates/_components/widgets/Updates/body.twig +++ b/resources/templates/_components/widgets/Updates/body.twig @@ -1,15 +1,2 @@ -{% if total %} -

- {% if total == 1 %} - {{ "One update available!"|t('app') }} - {% else %} - {{ "{total} updates available!"|t('app', { total: total }) }} - {% endif %} - {{ "Go to Updates"|t('app') }} -

-{% else %} -

{{ "Congrats! You’re up to date."|t('app') }}

-

- -

-{% endif %} + + 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/translations/en/app.php b/resources/translations/en/app.php index 0c7a13b1f0e..45217dc9bbd 100644 --- a/resources/translations/en/app.php +++ b/resources/translations/en/app.php @@ -1,5 +1,7 @@ ' and ', '"{attribute}" does not support operator "{operator}".' => '"{attribute}" does not support operator "{operator}".', 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/actions.php b/routes/actions.php index f2a9231af1c..3c0109f21d8 100644 --- a/routes/actions.php +++ b/routes/actions.php @@ -9,7 +9,6 @@ use CraftCms\Cms\Http\Controllers\ConfigSyncController; use CraftCms\Cms\Http\Controllers\Dashboard\Widgets\CraftSupportController; use CraftCms\Cms\Http\Controllers\Dashboard\Widgets\FeedController; -use CraftCms\Cms\Http\Controllers\Dashboard\WidgetsController; use CraftCms\Cms\Http\Controllers\Entries\CreateEntryController; use CraftCms\Cms\Http\Controllers\Entries\MoveEntryToSectionController; use CraftCms\Cms\Http\Controllers\Entries\StoreEntryController; @@ -143,11 +142,6 @@ Route::post('utilities/apply-new-migrations', MigrationsController::class); // Widgets - Route::post('dashboard/create-widget', [WidgetsController::class, 'store']); - Route::post('dashboard/save-widget-settings', [WidgetsController::class, 'update']); - Route::post('dashboard/delete-user-widget', [WidgetsController::class, 'delete']); - Route::post('dashboard/change-widget-colspan', [WidgetsController::class, 'updateColspan']); - Route::post('dashboard/reorder-user-widgets', [WidgetsController::class, 'reorder']); Route::post('dashboard/cache-feed-data', [FeedController::class, 'cacheData']); Route::post('dashboard/send-support-request', CraftSupportController::class); diff --git a/routes/cp.php b/routes/cp.php index ff125914985..82c11d3b9b7 100644 --- a/routes/cp.php +++ b/routes/cp.php @@ -3,6 +3,7 @@ declare(strict_types=1); use CraftCms\Cms\Http\Controllers\Dashboard\DashboardController; +use CraftCms\Cms\Http\Controllers\Dashboard\WidgetsController; use CraftCms\Cms\Http\Controllers\Entries\CreateEntryController; use CraftCms\Cms\Http\Controllers\Entries\EntriesIndexController; use CraftCms\Cms\Http\Controllers\FieldsController; @@ -17,6 +18,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; @@ -29,7 +31,17 @@ * Admin requests that require a login */ Route::middleware('auth')->group(function () { - Route::get('dashboard', DashboardController::class); + Route::get('dashboard', DashboardController::class) + ->middleware([HandleInertiaRequests::class]); + + /** + * Widgets + */ + Route::post('widgets', [WidgetsController::class, 'store']); + Route::post('widgets/reorder', [WidgetsController::class, 'reorder']); + Route::post('widgets/{widgetId}', [WidgetsController::class, 'update']); + Route::delete('widgets/{widgetId}', [WidgetsController::class, 'delete']); + Route::post('widgets/{widgetId}/update-colspan', [WidgetsController::class, 'updateColspan']); 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/Dashboard/Widgets/CraftSupport.php b/src/Dashboard/Widgets/CraftSupport.php index 840fe64a8a8..d1323dccb16 100644 --- a/src/Dashboard/Widgets/CraftSupport.php +++ b/src/Dashboard/Widgets/CraftSupport.php @@ -125,25 +125,20 @@ public function getBodyHtml(): ?string EOD; - $view->registerJsWithVars(fn ($id, $settings) => <<id, - [ - 'issueTitlePrefix' => sprintf('[%s.x]: ', $cmsMajorVersion), - 'issueParams' => [ - 'labels' => sprintf('bug,craft%s', $cmsMajorVersion), - 'template' => sprintf('BUG-REPORT-V%s.yml', $cmsMajorVersion), - 'body' => $body, - 'cmsVersion' => sprintf('%s (%s)', $cmsVersion, Edition::get()->name), - 'phpVersion' => PHP::version(), - 'os' => sprintf('%s %s', PHP_OS, php_uname('r')), - 'db' => sprintf('%s %s', $dbDriver, normalizeVersion($db->getSchema()->getServerVersion())), - 'imageDriver' => sprintf('%s %s', $imageDriver, $imagesService->getVersion()), - 'plugins' => implode("\n", $pluginVersions), - ], + $jsVariables = [ + 'issueTitlePrefix' => sprintf('[%s.x]: ', $cmsMajorVersion), + 'issueParams' => [ + 'labels' => sprintf('bug,craft%s', $cmsMajorVersion), + 'template' => sprintf('BUG-REPORT-V%s.yml', $cmsMajorVersion), + 'body' => $body, + 'cmsVersion' => sprintf('%s (%s)', $cmsVersion, Edition::get()->name), + 'phpVersion' => PHP::version(), + 'os' => sprintf('%s %s', PHP_OS, php_uname('r')), + 'db' => sprintf('%s %s', $dbDriver, normalizeVersion($db->getSchema()->getServerVersion())), + 'imageDriver' => sprintf('%s %s', $imageDriver, $imagesService->getVersion()), + 'plugins' => implode("\n", $pluginVersions), ], - ]); + ]; // Only show the DB backup option if DB backups haven't been disabled $showBackupOption = $this->generalConfig->backupCommand !== false; @@ -152,6 +147,7 @@ public function getBodyHtml(): ?string 'widget' => $this, 'showBackupOption' => $showBackupOption, 'bundleUrl' => $assetBundle->baseUrl, + ...$jsVariables, ]); } } diff --git a/src/Dashboard/Widgets/Feed.php b/src/Dashboard/Widgets/Feed.php index a60b8e71e50..7efe66231f4 100644 --- a/src/Dashboard/Widgets/Feed.php +++ b/src/Dashboard/Widgets/Feed.php @@ -87,6 +87,7 @@ public function getBodyHtml(): string // Fake it for now and fetch it later $data = [ 'direction' => 'ltr', + 'url' => $this->url, 'items' => [], ]; diff --git a/src/Dashboard/Widgets/RecentEntries.php b/src/Dashboard/Widgets/RecentEntries.php index 49a33cfd96b..b6b9e993408 100644 --- a/src/Dashboard/Widgets/RecentEntries.php +++ b/src/Dashboard/Widgets/RecentEntries.php @@ -72,10 +72,9 @@ public static function getRules(): array #[\Override] public function getSettingsHtml(): string { - return Craft::$app->getView()->renderTemplate('_components/widgets/RecentEntries/settings.twig', - [ - 'widget' => $this, - ]); + return Craft::$app->getView()->renderTemplate('_components/widgets/RecentEntries/settings.twig', [ + 'widget' => $this, + ]); } /** diff --git a/src/Dashboard/Widgets/Updates.php b/src/Dashboard/Widgets/Updates.php index 72c3c3974da..e87cf7d498a 100644 --- a/src/Dashboard/Widgets/Updates.php +++ b/src/Dashboard/Widgets/Updates.php @@ -76,13 +76,11 @@ public function getBodyHtml(): ?string $view->registerJs('new Craft.UpdatesWidget('.$this->id.', '.($cached ? 'true' : 'false').');'); } - if ($cached) { - return $view->renderTemplate('_components/widgets/Updates/body.twig', - [ - 'total' => $this->updates->totalAvailableUpdates(), - ]); - } - - return '

'.t('Checking for updates…').'

'; + return $view->renderTemplate('_components/widgets/Updates/body.twig', + [ + 'cached' => $cached, + 'total' => $this->updates->totalAvailableUpdates(), + ] + ); } } diff --git a/src/Http/Controllers/Dashboard/DashboardController.php b/src/Http/Controllers/Dashboard/DashboardController.php index 23ca0543138..52c8eab4358 100644 --- a/src/Http/Controllers/Dashboard/DashboardController.php +++ b/src/Http/Controllers/Dashboard/DashboardController.php @@ -11,6 +11,7 @@ use CraftCms\Cms\Support\Json; use Illuminate\Container\Attributes\Give; use Illuminate\Support\Collection; +use Inertia\Inertia; final readonly class DashboardController { @@ -85,16 +86,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/Controllers/Dashboard/Widgets/CraftSupportController.php b/src/Http/Controllers/Dashboard/Widgets/CraftSupportController.php index 7eee6890132..901a2ccd023 100644 --- a/src/Http/Controllers/Dashboard/Widgets/CraftSupportController.php +++ b/src/Http/Controllers/Dashboard/Widgets/CraftSupportController.php @@ -16,6 +16,7 @@ use Exception; use GuzzleHttp\RequestOptions; use Illuminate\Container\Attributes\Give; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\File; @@ -27,7 +28,6 @@ use ZipArchive; use function CraftCms\Cms\maxPowerCaptain; -use function CraftCms\Cms\t; final readonly class CraftSupportController { @@ -37,18 +37,14 @@ public function __construct( private Api $api, ) {} - public function __invoke(Request $request, #[Give('Craft')] Application $craft): string + public function __invoke(Request $request, #[Give('Craft')] Application $craft): JsonResponse { - $view = $craft->getView(); - $request->validate([ - 'widgetId' => ['required', 'integer'], 'namespace' => ['nullable', 'string'], ]); maxPowerCaptain(); - $widgetId = $request->input('widgetId'); $namespace = $request->has('namespace') ? $request->input('namespace').'.' : ''; $data = $namespace ? $request->input($namespace) : $request->all(); @@ -62,11 +58,10 @@ public function __invoke(Request $request, #[Give('Craft')] Application $craft): ]); if ($validator->fails()) { - return $view->renderTemplate('_components/widgets/CraftSupport/response.twig', [ - 'widgetId' => $widgetId, + return new JsonResponse([ 'success' => false, 'errors' => $validator->errors()->toArray(), - ]); + ], 422); } $parts = [ @@ -122,8 +117,7 @@ public function __invoke(Request $request, #[Give('Craft')] Application $craft): RequestOptions::MULTIPART => $parts, ]); - return $view->renderTemplate('_components/widgets/CraftSupport/response.twig', [ - 'widgetId' => $widgetId, + return new JsonResponse([ 'success' => true, 'errors' => [], ]); @@ -131,15 +125,14 @@ public function __invoke(Request $request, #[Give('Craft')] Application $craft): Log::error("Unable to send support request: {$requestException->getMessage()}", [__METHOD__]); report($requestException); - return $view->renderTemplate('_components/widgets/CraftSupport/response.twig', [ - 'widgetId' => $widgetId, + return new JsonResponse([ 'success' => false, 'errors' => [ - 'Support' => [ - t('A server error occurred.'), + 'form' => [ + 'Unable to send support request. Please email it to support@craftcms.com instead.', ], ], - ]); + ], $requestException->getCode() ?: 500); } finally { // Delete the zip file if (isset($zipData)) { diff --git a/src/Http/Controllers/Dashboard/WidgetsController.php b/src/Http/Controllers/Dashboard/WidgetsController.php index 4003288f538..bf5200e781a 100644 --- a/src/Http/Controllers/Dashboard/WidgetsController.php +++ b/src/Http/Controllers/Dashboard/WidgetsController.php @@ -7,7 +7,6 @@ use craft\web\Application; use CraftCms\Cms\Dashboard\Contracts\WidgetInterface; use CraftCms\Cms\Dashboard\Dashboard; -use CraftCms\Cms\Support\Json; use Illuminate\Container\Attributes\Give; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -66,7 +65,7 @@ public function update(Request $request): JsonResponse ], ]); - $widget = $this->dashboard->getWidgetById($request->input('widgetId')); + $widget = $this->dashboard->getWidgetById($request->integer('widgetId')); // Create a new widget model with the new settings $settings = $request->input("widget{$widget->id}-settings"); @@ -85,6 +84,14 @@ public function update(Request $request): JsonResponse public function updateColspan(Request $request): JsonResponse { + /** + * For backwards compatibility, if the request came in to `/widgets/{widgetId}/update-colspan`, + * we need to merge the `widgetId` into the request data. + */ + if ($request->route()->parameter('widgetId') !== null) { + $request->merge(['id' => (int) $request->route()->parameter('widgetId')]); + } + $request->validate([ 'id' => [ 'required', @@ -101,9 +108,9 @@ public function updateColspan(Request $request): JsonResponse public function reorder(Request $request): JsonResponse { - $ids = Json::decode($request->input('ids')); + $ids = $request->input('ids'); - Validator::validate(['ids' => $ids], [ + Validator::validate(['ids' => $request->input('ids')], [], [ 'ids' => ['required', 'array'], 'ids.*' => [ 'required', @@ -119,6 +126,13 @@ public function reorder(Request $request): JsonResponse public function delete(Request $request): JsonResponse { + /** + * For backwards compatibility, if the request came in to `DELETE /widgets/{widgetId}`, + * we need to merge the `widgetId` into the request data. + */ + if ($request->route()->parameter('widgetId') !== null) { + $request->merge(['id' => (int) $request->route()->parameter('widgetId')]); + } $request->validate([ 'id' => [ 'required', diff --git a/src/Http/Middleware/HandleInertiaRequests.php b/src/Http/Middleware/HandleInertiaRequests.php new file mode 100644 index 00000000000..4ed3e1533ac --- /dev/null +++ b/src/Http/Middleware/HandleInertiaRequests.php @@ -0,0 +1,97 @@ + + */ + #[\Override] + 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 (Edition::get()->value >= Edition::Pro->value && $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' => [ + 'email' => $currentUser->email ?? null, + ], + 'cpUrl' => UrlHelper::cpUrl(), + 'actionUrl' => UrlHelper::actionUrl(), + 'nav' => Navigation::getItems(), + ], + ]; + } +} diff --git a/src/Providers/AppServiceProvider.php b/src/Providers/AppServiceProvider.php index 2ff3a1f9afe..ed14ba10343 100644 --- a/src/Providers/AppServiceProvider.php +++ b/src/Providers/AppServiceProvider.php @@ -26,6 +26,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; @@ -83,6 +84,11 @@ public function boot(): void $this->setNamespace(); $this->bootAliases(); + $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()) { // Possibly run garbage collection diff --git a/src/Providers/CraftServiceProvider.php b/src/Providers/CraftServiceProvider.php index 3bbd91731a5..20a7b180155 100644 --- a/src/Providers/CraftServiceProvider.php +++ b/src/Providers/CraftServiceProvider.php @@ -22,6 +22,7 @@ use CraftCms\Cms\Updates\UpdatesServiceProvider; use CraftCms\Cms\User\UserServiceProvider; use Illuminate\Support\AggregateServiceProvider; +use Inertia\ServiceProvider as InertiaServiceProvider; final class CraftServiceProvider extends AggregateServiceProvider { @@ -44,6 +45,7 @@ final class CraftServiceProvider extends AggregateServiceProvider UserServiceProvider::class, FieldsServiceProvider::class, SectionServiceProvider::class, + InertiaServiceProvider::class, EntryServiceProvider::class, StructureServiceProvider::class, ]; diff --git a/tests/Http/Controllers/Dashboard/DashboardControllerTest.php b/tests/Http/Controllers/Dashboard/DashboardControllerTest.php index 5db30f64f3a..f7a4b6a5fbe 100644 --- a/tests/Http/Controllers/Dashboard/DashboardControllerTest.php +++ b/tests/Http/Controllers/Dashboard/DashboardControllerTest.php @@ -5,6 +5,7 @@ use CraftCms\Cms\Cms; use CraftCms\Cms\Http\Controllers\Dashboard\DashboardController; use CraftCms\Cms\User\Models\User; +use Inertia\Testing\AssertableInertia as Assert; use function Pest\Laravel\actingAs; use function Pest\Laravel\get; @@ -17,8 +18,8 @@ it('can be rendered', function () { actingAs(User::first()); - get(action(DashboardController::class)) - ->assertOk() - ->assertSee('Dashboard') - ->assertSee('Widget'); + $response = get(action(DashboardController::class)); + $response->assertOk(); + + $response->assertInertia(fn (Assert $page) => $page->component('Dashboard')); }); diff --git a/tests/Http/Controllers/Dashboard/Widgets/CraftSupportControllerTest.php b/tests/Http/Controllers/Dashboard/Widgets/CraftSupportControllerTest.php index 71a679e159a..43f724a96f5 100644 --- a/tests/Http/Controllers/Dashboard/Widgets/CraftSupportControllerTest.php +++ b/tests/Http/Controllers/Dashboard/Widgets/CraftSupportControllerTest.php @@ -3,7 +3,6 @@ declare(strict_types=1); use CraftCms\Cms\Dashboard\Dashboard; -use CraftCms\Cms\Dashboard\Widgets\CraftSupport; use CraftCms\Cms\Http\Controllers\Dashboard\Widgets\CraftSupportController; use CraftCms\Cms\User\Models\User; use Illuminate\Support\Facades\Auth; @@ -24,15 +23,12 @@ ->assertUnauthorized(); }); -it('requires a widget id', function () { - postJson(action(CraftSupportController::class)) - ->assertJsonValidationErrorFor('widgetId'); -}); - -it('validates data after widget id', function (array $data, array $errors) { - $this->dashboard->saveWidget($widget = $this->dashboard->createWidget(CraftSupport::class)); +it('validates data', function (array $data, array $errors) { + Http::fake([ + 'https://api.craftcms.com/v1/support' => Http::response([]), + ]); - $response = postJson(action(CraftSupportController::class), array_merge(['widgetId' => $widget->id], $data)); + $response = postJson(action(CraftSupportController::class), $data); if (count($errors) === 0) { $response->assertOk(); @@ -40,9 +36,10 @@ return; } - foreach ($errors as $error) { - $response->assertSee("errors: {\"$error\"", escape: false); - } + $response->assertStatus(422); + $errorKeys = array_keys($response->json('errors')); + + expect($errorKeys)->toMatchArray($errors); })->with([ [ 'data' => [ diff --git a/tests/TestCase.php b/tests/TestCase.php index 512f1770357..09a1249df25 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -34,6 +34,9 @@ protected function setUp(): void app()->setLocale('en-US'); Config::set('app.timezone', 'America/Los_Angeles'); + Config::set('inertia.testing.page_paths', [ + dirname(__DIR__).'/resources/js/Pages', + ]); Edition::set(Edition::Solo); diff --git a/tsconfig.json b/tsconfig.json index 03eea04286c..b0a0ccbba92 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,7 @@ "paths": { "@/*": ["./resources/js/*"] }, - "types": ["vite/client", "./resources/js/types"] + "types": ["vite/client"] }, "include": [ "resources/js/**/*.ts", diff --git a/vite.config.js b/vite.config.js index 0beb93caaa0..c03da90f0f1 100644 --- a/vite.config.js +++ b/vite.config.js @@ -33,6 +33,7 @@ export default defineConfig(({mode}) => { }, build: { + sourcemap: true, assetsDir: '', emptyOutDir: true, rollupOptions: { diff --git a/yii2-adapter/routes/actions.php b/yii2-adapter/routes/actions.php new file mode 100644 index 00000000000..cddce9e12e7 --- /dev/null +++ b/yii2-adapter/routes/actions.php @@ -0,0 +1,38 @@ +actionTrigger)->group(function() { + // Nothing for now +}); + +/** + * CP Actions, if actions need to be accessible both in /{cpTrigger} and + * the frontend site, you need to register them above as well. + */ +Route::prefix(implode('/', [ + Cms::config()->cpTrigger, + Cms::config()->actionTrigger, +]))->middleware(['craft.cp'])->group(function() { + /** + * Actions not needing auth + */ + + // Nothing for now + + /** + * Actions needing auth + */ + Route::middleware(['auth'])->group(function() { + // Widgets + Route::post('dashboard/create-widget', [WidgetsController::class, 'store']); + Route::post('dashboard/save-widget-settings', [WidgetsController::class, 'update']); + Route::post('dashboard/delete-user-widget', [WidgetsController::class, 'delete']); + Route::post('dashboard/change-widget-colspan', [WidgetsController::class, 'updateColspan']); + Route::post('dashboard/reorder-user-widgets', [WidgetsController::class, 'reorder']); + }); +}); diff --git a/yii2-adapter/routes/cp.php b/yii2-adapter/routes/cp.php new file mode 100644 index 00000000000..b3d9bbc7f37 --- /dev/null +++ b/yii2-adapter/routes/cp.php @@ -0,0 +1 @@ +name('craft.actions.') + ->group(__DIR__ . '/actions.php'); + +Route::middleware(['web', 'craft', 'craft.cp', LegacyMiddleware::class]) + ->name('craft.cp.') + ->prefix(Cms::config()->cpTrigger) + ->group(__DIR__ . '/cp.php'); + +Route::middleware(['web', 'craft', LegacyMiddleware::class]) + ->group(__DIR__ . '/web.php'); diff --git a/yii2-adapter/src/Yii2ServiceProvider.php b/yii2-adapter/src/Yii2ServiceProvider.php index ce60619d3a5..84252b0c96e 100644 --- a/yii2-adapter/src/Yii2ServiceProvider.php +++ b/yii2-adapter/src/Yii2ServiceProvider.php @@ -58,7 +58,7 @@ public function register(): void $this->registerMacros(); $this->registerLegacyApp(); - $this->loadRoutesFrom(__DIR__ . '/../routes/web.php'); + $this->loadRoutesFrom(__DIR__ . '/../routes/routes.php'); $this->setLaravelDefaults(); }