Snapshot synced v3.6.1 working state as baseline
Machine-sync snapshot: the working tree carried the full 3.0.0-3.6.1 arc (licensing/FileManager/DatabaseExplorer removal, all new replacement screens and REST controllers) that had not been committed on this machine. Captured here as a safety baseline before starting the Adminify-parity revamp. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
07d77bda57
commit
e2db2ad4b5
Generated
+342
-2
@@ -1,13 +1,22 @@
|
||||
{
|
||||
"name": "aurora-admin-app",
|
||||
"version": "2.0.0",
|
||||
"version": "2.16.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aurora-admin-app",
|
||||
"version": "2.0.0",
|
||||
"version": "2.16.0",
|
||||
"dependencies": {
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-html": "^6.4.11",
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/lang-php": "^6.0.2",
|
||||
"@codemirror/lang-sql": "^6.10.0",
|
||||
"@codemirror/lang-xml": "^6.1.0",
|
||||
"codemirror": "^6.0.2",
|
||||
"vue": "^3.5.26"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -61,6 +70,198 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/autocomplete": {
|
||||
"version": "6.20.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
|
||||
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/commands": {
|
||||
"version": "6.10.4",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz",
|
||||
"integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.7.0",
|
||||
"@codemirror/view": "^6.27.0",
|
||||
"@lezer/common": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-css": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
|
||||
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@lezer/common": "^1.0.2",
|
||||
"@lezer/css": "^1.1.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-html": {
|
||||
"version": "6.4.11",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
|
||||
"integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/lang-css": "^6.0.0",
|
||||
"@codemirror/lang-javascript": "^6.0.0",
|
||||
"@codemirror/language": "^6.4.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0",
|
||||
"@lezer/css": "^1.1.0",
|
||||
"@lezer/html": "^1.3.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-javascript": {
|
||||
"version": "6.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
|
||||
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/language": "^6.6.0",
|
||||
"@codemirror/lint": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0",
|
||||
"@lezer/javascript": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-json": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
|
||||
"integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@lezer/json": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-markdown": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz",
|
||||
"integrity": "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.7.1",
|
||||
"@codemirror/lang-html": "^6.0.0",
|
||||
"@codemirror/language": "^6.3.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0",
|
||||
"@lezer/common": "^1.2.1",
|
||||
"@lezer/markdown": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-php": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-php/-/lang-php-6.0.2.tgz",
|
||||
"integrity": "sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/lang-html": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@lezer/common": "^1.0.0",
|
||||
"@lezer/php": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-sql": {
|
||||
"version": "6.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-sql/-/lang-sql-6.10.0.tgz",
|
||||
"integrity": "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-xml": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz",
|
||||
"integrity": "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/language": "^6.4.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0",
|
||||
"@lezer/common": "^1.0.0",
|
||||
"@lezer/xml": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/language": {
|
||||
"version": "6.12.4",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
|
||||
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.23.0",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0",
|
||||
"style-mod": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lint": {
|
||||
"version": "6.9.7",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
|
||||
"integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.42.0",
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/search": {
|
||||
"version": "6.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.1.tgz",
|
||||
"integrity": "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.37.0",
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/state": {
|
||||
"version": "6.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz",
|
||||
"integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@marijn/find-cluster-break": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/view": {
|
||||
"version": "6.43.5",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.5.tgz",
|
||||
"integrity": "sha512-7uT/vUgH6dfXWn3WqOe23KneILMvGy5wQjNMEcRXLKzziJ9NOktpW6tGoyQpwVkBgE5Gj6hKkCcsddbnkaWrOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.7.0",
|
||||
"crelt": "^1.0.6",
|
||||
"style-mod": "^4.1.0",
|
||||
"w3c-keyname": "^2.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||
@@ -509,6 +710,112 @@
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lezer/common": {
|
||||
"version": "1.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
|
||||
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lezer/css": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.4.tgz",
|
||||
"integrity": "sha512-N+tn9tej2hPvyKgHEApMOQfHczDJCwxrRFS3SPn9QjYN+uwHvEDnCgKRrb3mxDYxRS8sKMM8fhC3+lc04Abz5Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/highlight": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
|
||||
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/html": {
|
||||
"version": "1.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
|
||||
"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/javascript": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
|
||||
"integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.1.3",
|
||||
"@lezer/lr": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/json": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
|
||||
"integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/lr": {
|
||||
"version": "1.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
|
||||
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/markdown": {
|
||||
"version": "1.6.4",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.6.4.tgz",
|
||||
"integrity": "sha512-N0SxazMj4k65DBfaf1azqtMZd6u7MqluP84/NZnB/io8Td9aleFmAhz9hcbvSfsxT5tdYlJ5qgv5aMJGY4zEtA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/highlight": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/php": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/php/-/php-1.0.5.tgz",
|
||||
"integrity": "sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/xml": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz",
|
||||
"integrity": "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@marijn/find-cluster-break": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz",
|
||||
"integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.62.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
|
||||
@@ -1019,6 +1326,27 @@
|
||||
"integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/codemirror": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
|
||||
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/commands": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/lint": "^6.0.0",
|
||||
"@codemirror/search": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
|
||||
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -1246,6 +1574,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/style-mod": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
|
||||
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
@@ -1358,6 +1692,12 @@
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aurora-admin-app",
|
||||
"version": "2.7.2",
|
||||
"version": "3.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -8,6 +8,15 @@
|
||||
"build": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-html": "^6.4.11",
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/lang-php": "^6.0.2",
|
||||
"@codemirror/lang-sql": "^6.10.0",
|
||||
"@codemirror/lang-xml": "^6.1.0",
|
||||
"codemirror": "^6.0.2",
|
||||
"vue": "^3.5.26"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
// 0-100. When null, renders an indeterminate/pulsing bar instead of a
|
||||
// determinate fill — used for phases with no cheap way to compute a
|
||||
// percentage (e.g. "building the file list" before zipping starts).
|
||||
percent: { type: Number, default: null },
|
||||
label: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const clamped = computed(() => (props.percent === null ? null : Math.max(0, Math.min(100, props.percent))));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="progress-bar">
|
||||
<div v-if="label" class="progress-bar__label">{{ label }}</div>
|
||||
<div class="progress-bar__track" :class="{ 'progress-bar__track--indeterminate': clamped === null }">
|
||||
<div
|
||||
v-if="clamped !== null"
|
||||
class="progress-bar__fill"
|
||||
:style="{ width: clamped + '%' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.progress-bar__label {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--aurora-text-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.progress-bar__track {
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--aurora-bg-subtle);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.progress-bar__fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--aurora-accent);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.progress-bar__track--indeterminate::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; bottom: 0;
|
||||
width: 40%;
|
||||
border-radius: 999px;
|
||||
background: var(--aurora-accent);
|
||||
animation: progress-bar-indeterminate 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes progress-bar-indeterminate {
|
||||
0% { left: -40%; }
|
||||
100% { left: 100%; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -8,13 +8,17 @@ defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits(['update:modelValue']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label class="toggle">
|
||||
<label class="toggle" :class="{ 'toggle--disabled': disabled }">
|
||||
<span v-if="label" class="toggle__label">{{ label }}</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -22,7 +26,8 @@ defineEmits(['update:modelValue']);
|
||||
:class="{ 'toggle__switch--on': modelValue }"
|
||||
role="switch"
|
||||
:aria-checked="modelValue"
|
||||
@click="$emit('update:modelValue', !modelValue)"
|
||||
:disabled="disabled"
|
||||
@click="!disabled && $emit('update:modelValue', !modelValue)"
|
||||
>
|
||||
<span class="toggle__thumb" />
|
||||
</button>
|
||||
@@ -59,6 +64,9 @@ defineEmits(['update:modelValue']);
|
||||
background: var(--aurora-accent);
|
||||
}
|
||||
|
||||
.toggle--disabled { cursor: default; opacity: 0.55; }
|
||||
.toggle--disabled .toggle__switch { cursor: default; }
|
||||
|
||||
.toggle__thumb {
|
||||
position: absolute;
|
||||
top: 0.15rem;
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { ref, onUnmounted } from 'vue';
|
||||
|
||||
// Phase weights for a rough overall-progress percentage. Backup and
|
||||
// restore jobs use different phase sets (see BackupEngine::effective_phases
|
||||
// on the PHP side) — only the phases relevant to whichever job is active
|
||||
// contribute, everything else is ignored.
|
||||
const PHASE_WEIGHTS = {
|
||||
db_dump: 40,
|
||||
file_zip: 40,
|
||||
upload_remote: 20,
|
||||
download_remote: 20,
|
||||
db_restore: 50,
|
||||
file_restore: 50,
|
||||
};
|
||||
|
||||
const PHASE_LABELS = {
|
||||
pending: 'Starting…',
|
||||
db_dump: 'Backing up the database…',
|
||||
file_zip: 'Archiving files…',
|
||||
upload_remote: 'Uploading to remote storage…',
|
||||
download_remote: 'Downloading from remote storage…',
|
||||
db_restore: 'Restoring the database…',
|
||||
file_restore: 'Restoring files…',
|
||||
done: 'Done',
|
||||
failed: 'Failed',
|
||||
cancelled: 'Cancelled',
|
||||
};
|
||||
|
||||
/**
|
||||
* Drives a backup/restore job to completion by repeatedly calling the
|
||||
* REST "step" route — a setTimeout-recursion loop (not setInterval), so a
|
||||
* slow step can never overlap the next poll for the same job. The
|
||||
* server-side wp_cron safety net keeps the job moving even if this stops
|
||||
* polling (e.g. the tab is closed mid-backup) — this composable is purely
|
||||
* the foreground "watch it happen live" experience, not the only thing
|
||||
* keeping a job alive.
|
||||
*/
|
||||
export function useJobPolling(api) {
|
||||
const job = ref(null);
|
||||
const isPolling = ref(false);
|
||||
const error = ref('');
|
||||
let timer = null;
|
||||
let stopped = false;
|
||||
|
||||
const percent = () => {
|
||||
if (!job.value) return null;
|
||||
const weight = PHASE_WEIGHTS[job.value.phase];
|
||||
if (!weight) return job.value.status === 'done' ? 100 : null;
|
||||
// No fine-grained progress within a phase is cheap to compute
|
||||
// client-side (server tracks byte/row offsets, not percentages) — an
|
||||
// indeterminate bar per phase, weighted by how much of the whole job
|
||||
// that phase represents, is a reasonable approximation without a
|
||||
// second round trip just to compute a number.
|
||||
return null;
|
||||
};
|
||||
|
||||
const label = () => {
|
||||
if (!job.value) return '';
|
||||
if (job.value.status === 'failed') return job.value.error_message || 'Failed';
|
||||
return PHASE_LABELS[job.value.phase] || PHASE_LABELS[job.value.status] || '';
|
||||
};
|
||||
|
||||
const poll = async (jobId) => {
|
||||
if (stopped) return;
|
||||
try {
|
||||
const res = await api(`/backup/jobs/${jobId}/step`, { method: 'POST' });
|
||||
job.value = res.job;
|
||||
} catch (e) {
|
||||
error.value = e.message || 'Request failed';
|
||||
isPolling.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!job.value || ['done', 'failed', 'cancelled'].includes(job.value.status)) {
|
||||
isPolling.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
timer = setTimeout(() => poll(jobId), 1500);
|
||||
};
|
||||
|
||||
const start = (jobId) => {
|
||||
stopped = false;
|
||||
isPolling.value = true;
|
||||
error.value = '';
|
||||
poll(jobId);
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
stopped = true;
|
||||
isPolling.value = false;
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
|
||||
onUnmounted(stop);
|
||||
|
||||
return { job, isPolling, error, start, stop, percent, label };
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue';
|
||||
import '@/shell/shell-frame.css';
|
||||
import AddUser from '@/pages/users/AddUser.vue';
|
||||
|
||||
const mountEl = document.getElementById('aurora-admin-add-user-root');
|
||||
|
||||
if (mountEl) {
|
||||
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
|
||||
createApp(AddUser, { serverData: data }).mount(mountEl);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue';
|
||||
import '@/shell/shell-frame.css';
|
||||
import BugReport from '@/pages/bug-report/BugReport.vue';
|
||||
|
||||
const mountEl = document.getElementById('aurora-admin-bug-report-root');
|
||||
|
||||
if (mountEl) {
|
||||
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
|
||||
createApp(BugReport, { serverData: data }).mount(mountEl);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { createApp } from 'vue';
|
||||
import '@/shell/shell-frame.css';
|
||||
import DatabaseExplorer from '@/pages/database-explorer/DatabaseExplorer.vue';
|
||||
|
||||
const mountEl = document.getElementById('aurora-admin-database-explorer-root');
|
||||
|
||||
if (mountEl) {
|
||||
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
|
||||
createApp(DatabaseExplorer, { serverData: data }).mount(mountEl);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { createApp } from 'vue';
|
||||
import '@/shell/shell-frame.css';
|
||||
import FileManager from '@/pages/file-manager/FileManager.vue';
|
||||
|
||||
const mountEl = document.getElementById('aurora-admin-file-manager-root');
|
||||
|
||||
if (mountEl) {
|
||||
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
|
||||
createApp(FileManager, { serverData: data }).mount(mountEl);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { createApp } from 'vue';
|
||||
import '@/shell/shell-frame.css';
|
||||
import UpgradeNotice from '@/pages/_shared/UpgradeNotice.vue';
|
||||
|
||||
const mountEl = document.querySelector('[data-aurora-locked-feature]');
|
||||
|
||||
if (mountEl) {
|
||||
const data = JSON.parse(mountEl.dataset.auroraLockedFeature || '{}');
|
||||
createApp(UpgradeNotice, { feature: data.feature, description: data.description }).mount(mountEl);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue';
|
||||
import '@/shell/shell-frame.css';
|
||||
import MediaUpload from '@/pages/media-upload/MediaUpload.vue';
|
||||
|
||||
const mountEl = document.getElementById('aurora-admin-media-upload-root');
|
||||
|
||||
if (mountEl) {
|
||||
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
|
||||
createApp(MediaUpload, { serverData: data }).mount(mountEl);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue';
|
||||
import '@/shell/shell-frame.css';
|
||||
import NativeSettings from '@/pages/native-settings/NativeSettings.vue';
|
||||
|
||||
const mountEl = document.getElementById('aurora-admin-native-settings-root');
|
||||
|
||||
if (mountEl) {
|
||||
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
|
||||
createApp(NativeSettings, { serverData: data }).mount(mountEl);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue';
|
||||
import '@/shell/shell-frame.css';
|
||||
import NavMenus from '@/pages/nav-menus/NavMenus.vue';
|
||||
|
||||
const mountEl = document.getElementById('aurora-admin-nav-menus-root');
|
||||
|
||||
if (mountEl) {
|
||||
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
|
||||
createApp(NavMenus, { serverData: data }).mount(mountEl);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue';
|
||||
import '@/shell/shell-frame.css';
|
||||
import Profile from '@/pages/profile/Profile.vue';
|
||||
|
||||
const mountEl = document.getElementById('aurora-admin-profile-root');
|
||||
|
||||
if (mountEl) {
|
||||
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
|
||||
createApp(Profile, { serverData: data }).mount(mountEl);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue';
|
||||
import '@/shell/shell-frame.css';
|
||||
import Themes from '@/pages/themes/Themes.vue';
|
||||
|
||||
const mountEl = document.getElementById('aurora-admin-themes-root');
|
||||
|
||||
if (mountEl) {
|
||||
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
|
||||
createApp(Themes, { serverData: data }).mount(mountEl);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue';
|
||||
import '@/shell/shell-frame.css';
|
||||
import Widgets from '@/pages/widgets/Widgets.vue';
|
||||
|
||||
const mountEl = document.getElementById('aurora-admin-widgets-root');
|
||||
|
||||
if (mountEl) {
|
||||
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
|
||||
createApp(Widgets, { serverData: data }).mount(mountEl);
|
||||
}
|
||||
@@ -72,7 +72,14 @@ const search = defineModel('search', { type: String, default: '' });
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
min-height: calc(100vh - var(--aurora-toolbar-h) - 60px);
|
||||
/* A fixed height (not just min-height) so the two panes below can each
|
||||
scroll independently within it — previously this only set a minimum
|
||||
and let the whole shell grow with content, meaning both panes shared
|
||||
one page-level scrollbar. Selecting an item near the bottom of a long
|
||||
list left the detail panel's newly-loaded content rendered above the
|
||||
current scroll position, looking like nothing happened until you
|
||||
scrolled all the way back up. */
|
||||
height: calc(100vh - var(--aurora-toolbar-h) - 60px);
|
||||
margin: -32px;
|
||||
padding: 22px;
|
||||
box-sizing: border-box;
|
||||
@@ -85,6 +92,8 @@ const search = defineModel('search', { type: String, default: '' });
|
||||
.app-list-shell__nav {
|
||||
width: 340px;
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
border-radius: 16px;
|
||||
padding: 24px 20px;
|
||||
box-sizing: border-box;
|
||||
@@ -150,6 +159,8 @@ const search = defineModel('search', { type: String, default: '' });
|
||||
/* Right — no background/border of its own, same reasoning. */
|
||||
.app-list-shell__content {
|
||||
flex: 1; min-width: 0;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
border-radius: 16px;
|
||||
padding: 28px 32px;
|
||||
min-height: 480px;
|
||||
@@ -172,7 +183,19 @@ const search = defineModel('search', { type: String, default: '' });
|
||||
.app-list-shell__empty-text { font-size: 0.8125rem; color: var(--aurora-text-muted); max-width: 320px; margin: 0; }
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.app-list-shell { flex-direction: column; }
|
||||
.app-list-shell__nav { width: auto; }
|
||||
/* Stacked instead of side-by-side, so the fixed-height/independent-
|
||||
scroll treatment above (built for two side-by-side panes) would just
|
||||
squash both panes into half-height boxes — switch to natural height
|
||||
with each pane capped and independently scrollable instead. */
|
||||
.app-list-shell { flex-direction: column; height: auto; }
|
||||
.app-list-shell__nav {
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-height: 40vh;
|
||||
}
|
||||
.app-list-shell__content {
|
||||
height: auto;
|
||||
max-height: 60vh;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
feature: { type: String, required: true },
|
||||
description: { type: String, default: '' },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="upgrade-notice">
|
||||
<div class="upgrade-notice__card">
|
||||
<span class="upgrade-notice__icon">
|
||||
<span class="dashicons dashicons-lock" />
|
||||
</span>
|
||||
<h1 class="upgrade-notice__title">{{ feature }} is a Pro feature</h1>
|
||||
<p class="upgrade-notice__text">
|
||||
{{ description || `Upgrade to Aurora Admin Pro to unlock ${feature}.` }}
|
||||
</p>
|
||||
<a class="upgrade-notice__cta" href="admin.php?page=aurora-admin#license">
|
||||
Enter a license key
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.upgrade-notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
.upgrade-notice__card {
|
||||
max-width: 380px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.upgrade-notice__icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--aurora-accent-soft);
|
||||
color: var(--aurora-accent);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.upgrade-notice__icon .dashicons {
|
||||
font-size: 26px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
.upgrade-notice__title {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--aurora-text);
|
||||
}
|
||||
.upgrade-notice__text {
|
||||
font-size: 0.875rem;
|
||||
color: var(--aurora-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
.upgrade-notice__cta {
|
||||
margin-top: 8px;
|
||||
padding: 8px 18px;
|
||||
border-radius: 8px;
|
||||
background: var(--aurora-accent);
|
||||
color: var(--aurora-accent-text);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.upgrade-notice__cta:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,128 @@
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
serverData: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const description = ref('');
|
||||
const sending = ref(false);
|
||||
const notice = ref('');
|
||||
const error = ref('');
|
||||
|
||||
const diagnostics = reactive({
|
||||
pluginVersion: props.serverData.pluginVersion || '',
|
||||
wpVersion: props.serverData.wpVersion || '',
|
||||
phpVersion: props.serverData.phpVersion || '',
|
||||
theme: props.serverData.theme || '',
|
||||
});
|
||||
|
||||
const send = async () => {
|
||||
if (!description.value.trim()) return;
|
||||
|
||||
sending.value = true;
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
try {
|
||||
const res = await fetch(`${props.serverData.restUrl}aurora-admin/v1/bug-report`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-WP-Nonce': props.serverData.restNonce,
|
||||
},
|
||||
body: JSON.stringify({ description: description.value }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.success) throw new Error(data?.message || 'Could not send your report.');
|
||||
notice.value = 'Thanks — your report was sent.';
|
||||
description.value = '';
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
} finally {
|
||||
sending.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="bug-report-page">
|
||||
<header class="bug-report-page__header">
|
||||
<p class="bug-report-page__eyebrow">Support</p>
|
||||
<h1>Report a Bug</h1>
|
||||
<p class="bug-report-page__sub">
|
||||
Tell us what went wrong. We'll get your diagnostics automatically —
|
||||
just describe the issue below.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div v-if="notice" class="bug-report-notice bug-report-notice--success">{{ notice }}</div>
|
||||
<div v-if="error" class="bug-report-notice bug-report-notice--error">{{ error }}</div>
|
||||
|
||||
<form class="bug-report-form" @submit.prevent="send">
|
||||
<label class="bug-report-field">
|
||||
<span>What happened?</span>
|
||||
<textarea
|
||||
v-model="description"
|
||||
rows="8"
|
||||
placeholder="Describe the bug, what you expected, and the steps to reproduce it…"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="bug-report-diagnostics">
|
||||
<h2>Diagnostics (sent automatically)</h2>
|
||||
<dl>
|
||||
<div><dt>Plugin version</dt><dd>{{ diagnostics.pluginVersion || '—' }}</dd></div>
|
||||
<div><dt>WordPress version</dt><dd>{{ diagnostics.wpVersion || '—' }}</dd></div>
|
||||
<div><dt>PHP version</dt><dd>{{ diagnostics.phpVersion || '—' }}</dd></div>
|
||||
<div><dt>Active theme</dt><dd>{{ diagnostics.theme || '—' }}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="bug-report-form__actions">
|
||||
<button type="submit" class="button-primary" :disabled="sending || !description.trim()">
|
||||
{{ sending ? 'Sending…' : 'Send Report' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bug-report-page { max-width: 640px; margin: 0 auto; }
|
||||
.bug-report-page__header { margin-bottom: 16px; }
|
||||
.bug-report-page__eyebrow {
|
||||
margin: 0 0 4px; font-size: 0.75rem; text-transform: uppercase;
|
||||
letter-spacing: 0.06em; color: var(--aurora-text-muted);
|
||||
}
|
||||
.bug-report-page h1 { margin: 0; font-size: 1.6rem; font-weight: 700; color: var(--aurora-text); }
|
||||
.bug-report-page__sub { margin: 8px 0 0; font-size: 0.875rem; color: var(--aurora-text-muted); }
|
||||
|
||||
.bug-report-notice { padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; font-size: 0.875rem; }
|
||||
.bug-report-notice--success { background: rgba(34, 197, 94, 0.12); color: #22c55e; }
|
||||
.bug-report-notice--error { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
|
||||
|
||||
.bug-report-form { display: flex; flex-direction: column; gap: 20px; }
|
||||
.bug-report-field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.bug-report-field span { font-size: 0.8125rem; font-weight: 600; color: var(--aurora-text); }
|
||||
.bug-report-field textarea {
|
||||
padding: 10px 12px; border-radius: 8px; border: 1px solid var(--aurora-border);
|
||||
background: var(--aurora-bg-subtle); color: var(--aurora-text); font-size: 0.875rem;
|
||||
font-family: inherit; resize: vertical;
|
||||
}
|
||||
|
||||
.bug-report-diagnostics {
|
||||
background: var(--aurora-bg-subtle); border: 1px solid var(--aurora-border);
|
||||
border-radius: 10px; padding: 14px 16px;
|
||||
}
|
||||
.bug-report-diagnostics h2 {
|
||||
margin: 0 0 10px; font-size: 0.75rem; text-transform: uppercase;
|
||||
letter-spacing: 0.03em; color: var(--aurora-text-muted); font-weight: 600;
|
||||
}
|
||||
.bug-report-diagnostics dl { margin: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.bug-report-diagnostics dl > div { display: flex; justify-content: space-between; font-size: 0.8125rem; }
|
||||
.bug-report-diagnostics dt { color: var(--aurora-text-muted); }
|
||||
.bug-report-diagnostics dd { margin: 0; color: var(--aurora-text); font-weight: 500; }
|
||||
|
||||
.bug-report-form__actions { display: flex; justify-content: flex-end; }
|
||||
</style>
|
||||
@@ -13,7 +13,6 @@ import ActiveUsers from './cards/ActiveUsers.vue';
|
||||
import DeviceUsage from './cards/DeviceUsage.vue';
|
||||
import Engagement from './cards/Engagement.vue';
|
||||
import TopList from './cards/TopList.vue';
|
||||
import UpgradeNotice from '../_shared/UpgradeNotice.vue';
|
||||
|
||||
const props = defineProps({
|
||||
serverData: { type: Object, required: true },
|
||||
@@ -108,11 +107,9 @@ onMounted(() => reload(range.value[0], range.value[1]));
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UpgradeNotice
|
||||
v-else-if="analyticsError"
|
||||
feature="Analytics"
|
||||
description="Upgrade to Aurora Admin Pro to unlock first-party visitor analytics for this dashboard."
|
||||
/>
|
||||
<div v-else-if="analyticsError" class="dash__analytics-error">
|
||||
Could not load analytics. Try refreshing the page.
|
||||
</div>
|
||||
|
||||
<div v-else class="dash__grid">
|
||||
<div class="dash__cell" style="grid-column: span 8">
|
||||
@@ -209,6 +206,12 @@ onMounted(() => reload(range.value[0], range.value[1]));
|
||||
padding: 3px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.dash__analytics-error {
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
color: var(--aurora-text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.dash__tab {
|
||||
border: none; background: none; cursor: pointer;
|
||||
padding: 6px 16px; border-radius: 8px;
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue';
|
||||
import AppListShell from '../_shared/AppListShell.vue';
|
||||
import AppListItem from '../_shared/AppListItem.vue';
|
||||
|
||||
const props = defineProps({
|
||||
serverData: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const restBase = `${props.serverData.restUrl}aurora-admin/v1`;
|
||||
const headers = { 'Content-Type': 'application/json', 'X-WP-Nonce': props.serverData.restNonce };
|
||||
const api = (path, opts = {}) =>
|
||||
fetch(`${restBase}${path}`, { ...opts, headers }).then(async (r) => {
|
||||
const body = await r.json();
|
||||
if (!r.ok) throw new Error(body?.message || 'Request failed');
|
||||
return body;
|
||||
});
|
||||
|
||||
const tables = ref([]);
|
||||
const search = ref('');
|
||||
const selectedTable = ref(null);
|
||||
const tableData = ref(null);
|
||||
const tablePage = ref(1);
|
||||
const loadingTable = ref(false);
|
||||
|
||||
const sqlMode = ref(false);
|
||||
const sqlQuery = ref('SELECT * FROM ' + (props.serverData.tablePrefix || 'wp_') + 'options LIMIT 10');
|
||||
const sqlResult = ref(null);
|
||||
const sqlError = ref('');
|
||||
const sqlRunning = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
tables.value = await api('/db-tables');
|
||||
});
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = search.value.trim().toLowerCase();
|
||||
if (!q) return tables.value;
|
||||
return tables.value.filter((t) => t.name.toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
const formatSize = (bytes) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
||||
};
|
||||
|
||||
const loadTable = async (name, page = 1) => {
|
||||
sqlMode.value = false;
|
||||
selectedTable.value = name;
|
||||
tablePage.value = page;
|
||||
loadingTable.value = true;
|
||||
try {
|
||||
tableData.value = await api(`/db-tables/${encodeURIComponent(name)}?page=${page}`);
|
||||
} finally {
|
||||
loadingTable.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(tablePage, (p) => {
|
||||
if (selectedTable.value) loadTable(selectedTable.value, p);
|
||||
});
|
||||
|
||||
const openSqlEditor = () => {
|
||||
sqlMode.value = true;
|
||||
selectedTable.value = null;
|
||||
};
|
||||
|
||||
const runQuery = async () => {
|
||||
sqlRunning.value = true;
|
||||
sqlError.value = '';
|
||||
sqlResult.value = null;
|
||||
try {
|
||||
sqlResult.value = await api('/db-query', { method: 'POST', body: JSON.stringify({ query: sqlQuery.value }) });
|
||||
} catch (e) {
|
||||
sqlError.value = e.message;
|
||||
} finally {
|
||||
sqlRunning.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const hasSelection = computed(() => !!selectedTable.value || sqlMode.value);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppListShell
|
||||
v-model:search="search"
|
||||
title="Database Explorer"
|
||||
search-placeholder="Search tables…"
|
||||
:count-label="`${filtered.length} TABLES`"
|
||||
detail-icon="dashicons-database"
|
||||
detail-title="Table Details"
|
||||
detail-text="Select a table from the list to view its structure and data."
|
||||
:has-selection="hasSelection"
|
||||
>
|
||||
<template #header-actions>
|
||||
<button type="button" @click="openSqlEditor">
|
||||
<span class="dashicons dashicons-editor-code" /> SQL
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template #detail-empty-actions>
|
||||
<button type="button" class="is-primary" style="margin-top: 14px;" @click="openSqlEditor">
|
||||
Open SQL Editor
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template #list>
|
||||
<AppListItem
|
||||
v-for="t in filtered"
|
||||
:key="t.name"
|
||||
icon="dashicons-database"
|
||||
:title="t.name"
|
||||
:subtitle="`${t.rows} rows · ${formatSize(t.sizeBytes)}`"
|
||||
:active="t.name === selectedTable"
|
||||
@click="loadTable(t.name)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #detail>
|
||||
<div v-if="sqlMode" class="db-sql">
|
||||
<h2>SQL Editor</h2>
|
||||
<p class="db-sql__hint">Only SELECT / SHOW / DESCRIBE / EXPLAIN queries are allowed.</p>
|
||||
<textarea v-model="sqlQuery" rows="4" spellcheck="false" />
|
||||
<div class="db-sql__actions">
|
||||
<button type="button" class="is-primary" :disabled="sqlRunning" @click="runQuery">
|
||||
{{ sqlRunning ? 'Running…' : 'Run query' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="sqlError" class="db-sql__error">{{ sqlError }}</p>
|
||||
<div v-if="sqlResult" class="db-table-wrap">
|
||||
<p class="db-table__count">{{ sqlResult.count }} rows</p>
|
||||
<table v-if="sqlResult.rows.length" class="db-table">
|
||||
<thead>
|
||||
<tr><th v-for="k in Object.keys(sqlResult.rows[0])" :key="k">{{ k }}</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, i) in sqlResult.rows" :key="i">
|
||||
<td v-for="(v, k) in row" :key="k">{{ v }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="selectedTable" class="db-table-detail">
|
||||
<h2>{{ selectedTable }}</h2>
|
||||
<div v-if="loadingTable">Loading…</div>
|
||||
<template v-else-if="tableData">
|
||||
<div class="db-table-wrap">
|
||||
<table class="db-table">
|
||||
<thead>
|
||||
<tr><th v-for="c in tableData.columns" :key="c.name">{{ c.name }}</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, i) in tableData.rows" :key="i">
|
||||
<td v-for="c in tableData.columns" :key="c.name">{{ row[c.name] }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="db-table__pager">
|
||||
<span>Page {{ tableData.page }} of {{ tableData.totalPages || 1 }} · {{ tableData.total }} rows</span>
|
||||
<button type="button" :disabled="tablePage <= 1" @click="tablePage--">‹</button>
|
||||
<button type="button" :disabled="tablePage >= tableData.totalPages" @click="tablePage++">›</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</AppListShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.db-sql h2, .db-table-detail h2 { margin: 0 0 10px; font-size: 1.1rem; color: var(--aurora-text); }
|
||||
.db-sql__hint { font-size: 0.75rem; color: var(--aurora-text-muted); margin: 0 0 10px; }
|
||||
.db-sql textarea {
|
||||
width: 100%; box-sizing: border-box; font-family: monospace; font-size: 0.8125rem;
|
||||
padding: 10px; border-radius: 8px; border: 1px solid var(--aurora-border);
|
||||
background: var(--aurora-bg-subtle); color: var(--aurora-text);
|
||||
}
|
||||
.db-sql__actions { display: flex; justify-content: flex-end; margin: 10px 0; }
|
||||
.db-sql__actions button, .db-table__pager button {
|
||||
border: none; border-radius: 8px; padding: 8px 16px; font-size: 0.8125rem; cursor: pointer;
|
||||
background: var(--aurora-accent); color: #fff;
|
||||
}
|
||||
.db-sql__actions button:disabled { opacity: 0.5; cursor: default; }
|
||||
.db-sql__error { color: #ef4444; font-size: 0.8125rem; }
|
||||
|
||||
.db-table-wrap { overflow-x: auto; max-height: 420px; overflow-y: auto; border: 1px solid var(--aurora-frame-border); border-radius: 8px; }
|
||||
.db-table { width: 100%; border-collapse: collapse; font-size: 0.75rem; }
|
||||
.db-table th, .db-table td {
|
||||
padding: 6px 10px; text-align: left; white-space: nowrap;
|
||||
border-bottom: 1px solid var(--aurora-frame-border); color: var(--aurora-text);
|
||||
}
|
||||
.db-table th { background: var(--aurora-bg-subtle); position: sticky; top: 0; }
|
||||
.db-table__count { font-size: 0.75rem; color: var(--aurora-text-muted); margin: 8px 0; }
|
||||
.db-table__pager {
|
||||
display: flex; align-items: center; gap: 8px; margin-top: 12px;
|
||||
font-size: 0.75rem; color: var(--aurora-text-muted);
|
||||
}
|
||||
.db-table__pager button { background: var(--aurora-bg-subtle); color: var(--aurora-text); padding: 4px 10px; }
|
||||
</style>
|
||||
@@ -1,649 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
serverData: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const path = ref('');
|
||||
const breadcrumb = ref([]);
|
||||
const items = ref([]);
|
||||
const loading = ref(true);
|
||||
const busy = ref(false);
|
||||
const error = ref('');
|
||||
const notice = ref('');
|
||||
const search = ref('');
|
||||
const fileInput = ref(null);
|
||||
|
||||
// Text editor state — a single file open at a time.
|
||||
const editing = ref(null); // { path, name, content, isWritable }
|
||||
const editorContent = ref('');
|
||||
const savingEdit = ref(false);
|
||||
|
||||
const apiBase = computed(() => `${props.serverData.restUrl}aurora-admin/v1`);
|
||||
|
||||
const request = async (path, options = {}) => {
|
||||
const response = await fetch(`${apiBase.value}${path}`, {
|
||||
credentials: 'same-origin',
|
||||
...options,
|
||||
headers: {
|
||||
'X-WP-Nonce': props.serverData.restNonce,
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || data.error || 'Request failed');
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
const load = async (targetPath = path.value) => {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const params = new URLSearchParams({ path: targetPath });
|
||||
const data = await request(`/file-manager/list?${params.toString()}`);
|
||||
path.value = data.path || '';
|
||||
breadcrumb.value = data.breadcrumb || [];
|
||||
items.value = data.items || [];
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Could not load this folder';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => load(''));
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
const q = search.value.trim().toLowerCase();
|
||||
if (!q) return items.value;
|
||||
return items.value.filter((item) => item.name.toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
const openFolder = (item) => {
|
||||
if (item.type !== 'dir') return;
|
||||
load(item.path);
|
||||
};
|
||||
|
||||
const goToCrumb = (crumbPath) => load(crumbPath);
|
||||
const goToRoot = () => load('');
|
||||
|
||||
const newFolder = async () => {
|
||||
const name = window.prompt('New folder name:');
|
||||
if (!name) return;
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
try {
|
||||
const data = await request('/file-manager/folder', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: path.value, name }),
|
||||
});
|
||||
notice.value = data.message || 'Folder created.';
|
||||
await load();
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Could not create folder';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const newFile = async () => {
|
||||
const name = window.prompt('New file name:');
|
||||
if (!name) return;
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
try {
|
||||
const data = await request('/file-manager/file', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: path.value, name }),
|
||||
});
|
||||
notice.value = data.message || 'File created.';
|
||||
await load();
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Could not create file';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const triggerUpload = () => fileInput.value?.click();
|
||||
|
||||
const onFileSelected = async (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
if (!file) return;
|
||||
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('path', path.value);
|
||||
formData.append('file', file);
|
||||
const data = await request('/file-manager/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
notice.value = data.message || 'File uploaded.';
|
||||
await load();
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Upload failed';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const renameItem = async (item) => {
|
||||
const newName = window.prompt('Rename to:', item.name);
|
||||
if (!newName || newName === item.name) return;
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
try {
|
||||
const data = await request('/file-manager/rename', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: item.path, newName }),
|
||||
});
|
||||
notice.value = data.message || 'Renamed.';
|
||||
await load();
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Rename failed';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteItem = async (item) => {
|
||||
const warning =
|
||||
item.type === 'dir'
|
||||
? `Permanently delete the folder "${item.name}" and everything inside it? This cannot be undone.`
|
||||
: `Permanently delete "${item.name}"? This cannot be undone.`;
|
||||
if (!window.confirm(warning)) return;
|
||||
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
try {
|
||||
const data = await request('/file-manager/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: item.path }),
|
||||
});
|
||||
notice.value = data.message || 'Deleted.';
|
||||
await load();
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Delete failed';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const downloadUrl = (item) => {
|
||||
const params = new URLSearchParams({
|
||||
path: item.path,
|
||||
_wpnonce: props.serverData.restNonce,
|
||||
});
|
||||
return `${apiBase.value}/file-manager/download?${params.toString()}`;
|
||||
};
|
||||
|
||||
const openEditor = async (item) => {
|
||||
error.value = '';
|
||||
try {
|
||||
const params = new URLSearchParams({ path: item.path });
|
||||
const data = await request(`/file-manager/read?${params.toString()}`);
|
||||
editing.value = { path: item.path, name: item.name, isWritable: data.isWritable };
|
||||
editorContent.value = data.content;
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Could not open this file';
|
||||
}
|
||||
};
|
||||
|
||||
const closeEditor = () => {
|
||||
editing.value = null;
|
||||
editorContent.value = '';
|
||||
};
|
||||
|
||||
const saveEditor = async () => {
|
||||
if (!editing.value) return;
|
||||
savingEdit.value = true;
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
try {
|
||||
const data = await request('/file-manager/write', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: editing.value.path, content: editorContent.value }),
|
||||
});
|
||||
notice.value = data.message || 'Saved.';
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Save failed';
|
||||
} finally {
|
||||
savingEdit.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const icon = (item) => {
|
||||
if (item.type === 'dir') return 'dashicons-portfolio';
|
||||
const ext = item.extension;
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'ico', 'bmp'].includes(ext)) return 'dashicons-format-image';
|
||||
if (['zip', 'gz', 'tar', 'rar', '7z'].includes(ext)) return 'dashicons-media-archive';
|
||||
if (['mp3', 'wav', 'ogg'].includes(ext)) return 'dashicons-format-audio';
|
||||
if (['mp4', 'mov', 'avi', 'webm'].includes(ext)) return 'dashicons-format-video';
|
||||
if (['php', 'js', 'css', 'json', 'html', 'htm', 'xml', 'sql', 'yml', 'yaml'].includes(ext)) return 'dashicons-media-code';
|
||||
return 'dashicons-media-default';
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="fm-page">
|
||||
<header class="fm-page__header">
|
||||
<div>
|
||||
<p class="fm-page__eyebrow">Tools</p>
|
||||
<h1>File Manager</h1>
|
||||
</div>
|
||||
<div class="fm-page__actions">
|
||||
<button type="button" class="icon-button" title="New folder" :disabled="busy" @click="newFolder">
|
||||
<span class="dashicons dashicons-plus-alt2" /> Folder
|
||||
</button>
|
||||
<button type="button" class="icon-button" title="New file" :disabled="busy" @click="newFile">
|
||||
<span class="dashicons dashicons-plus-alt2" /> File
|
||||
</button>
|
||||
<button type="button" class="icon-button" title="Upload file" :disabled="busy" @click="triggerUpload">
|
||||
<span class="dashicons dashicons-upload" /> Upload
|
||||
</button>
|
||||
<input ref="fileInput" type="file" hidden @change="onFileSelected" />
|
||||
<button type="button" class="icon-button" title="Refresh" :disabled="loading" @click="load()">
|
||||
<span class="dashicons dashicons-update" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="fm-breadcrumb" aria-label="Folder path">
|
||||
<button type="button" class="fm-breadcrumb__item" @click="goToRoot">WordPress root</button>
|
||||
<template v-for="crumb in breadcrumb" :key="crumb.path">
|
||||
<span class="fm-breadcrumb__sep">/</span>
|
||||
<button type="button" class="fm-breadcrumb__item" @click="goToCrumb(crumb.path)">{{ crumb.label }}</button>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<section class="fm-toolbar">
|
||||
<label class="fm-search">
|
||||
<span class="dashicons dashicons-search" />
|
||||
<input v-model="search" type="search" placeholder="Filter this folder..." />
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<div v-if="notice" class="fm-notice fm-notice--success">{{ notice }}</div>
|
||||
<div v-if="error" class="fm-notice fm-notice--error">{{ error }}</div>
|
||||
|
||||
<section class="fm-table" aria-label="Files and folders">
|
||||
<div class="fm-table__head">
|
||||
<div>Name</div>
|
||||
<div>Size</div>
|
||||
<div>Modified</div>
|
||||
<div>Actions</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="fm-table__empty">Loading...</div>
|
||||
<div v-else-if="!filteredItems.length" class="fm-table__empty">This folder is empty.</div>
|
||||
|
||||
<article v-for="item in filteredItems" v-else :key="item.name" class="fm-row">
|
||||
<div
|
||||
class="fm-row__name"
|
||||
:class="{ 'fm-row__name--clickable': item.type === 'dir' }"
|
||||
@click="openFolder(item)"
|
||||
>
|
||||
<span class="dashicons" :class="icon(item)" />
|
||||
<span>{{ item.name }}</span>
|
||||
</div>
|
||||
<div class="fm-row__cell">{{ item.sizeLabel || '—' }}</div>
|
||||
<div class="fm-row__cell">{{ item.modified }}</div>
|
||||
<div class="fm-row__actions">
|
||||
<button
|
||||
v-if="item.isEditable"
|
||||
type="button"
|
||||
class="icon-button icon-button--inline"
|
||||
title="Edit"
|
||||
@click="openEditor(item)"
|
||||
>
|
||||
<span class="dashicons dashicons-edit" />
|
||||
</button>
|
||||
<a
|
||||
v-if="item.type === 'file'"
|
||||
class="icon-button icon-button--inline"
|
||||
title="Download"
|
||||
:href="downloadUrl(item)"
|
||||
>
|
||||
<span class="dashicons dashicons-download" />
|
||||
</a>
|
||||
<button type="button" class="icon-button icon-button--inline" title="Rename" @click="renameItem(item)">
|
||||
<span class="dashicons dashicons-tag" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-button icon-button--inline"
|
||||
title="Delete"
|
||||
:disabled="!item.isWritable"
|
||||
@click="deleteItem(item)"
|
||||
>
|
||||
<span class="dashicons dashicons-trash" />
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<!-- Text editor overlay -->
|
||||
<div v-if="editing" class="fm-editor-backdrop" @click.self="closeEditor">
|
||||
<div class="fm-editor">
|
||||
<header class="fm-editor__header">
|
||||
<strong>{{ editing.name }}</strong>
|
||||
<span v-if="!editing.isWritable" class="fm-editor__readonly">Read-only (not writable on disk)</span>
|
||||
<div class="fm-editor__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="button-primary"
|
||||
:disabled="!editing.isWritable || savingEdit"
|
||||
@click="saveEditor"
|
||||
>
|
||||
{{ savingEdit ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
<button type="button" class="icon-button" @click="closeEditor">
|
||||
<span class="dashicons dashicons-no-alt" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<textarea
|
||||
v-model="editorContent"
|
||||
class="fm-editor__textarea"
|
||||
spellcheck="false"
|
||||
:readonly="!editing.isWritable"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fm-page {
|
||||
min-height: calc(100vh - 112px);
|
||||
padding: 0;
|
||||
color: var(--aurora-text);
|
||||
background: transparent;
|
||||
}
|
||||
.fm-page__header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.fm-page__eyebrow {
|
||||
margin: 0 0 7px;
|
||||
color: var(--aurora-text-muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.fm-page h1 {
|
||||
margin: 0;
|
||||
color: var(--aurora-text);
|
||||
font-size: 1.45rem;
|
||||
line-height: 1.15;
|
||||
}
|
||||
.fm-page__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.icon-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 38px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--aurora-border);
|
||||
border-radius: 8px;
|
||||
color: var(--aurora-text);
|
||||
background: var(--aurora-bg-subtle);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.icon-button:hover:not(:disabled) {
|
||||
border-color: var(--aurora-text-muted);
|
||||
}
|
||||
.icon-button--inline {
|
||||
width: 30px;
|
||||
min-height: 30px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--aurora-text-muted);
|
||||
justify-content: center;
|
||||
}
|
||||
.icon-button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.button-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 34px;
|
||||
padding: 0 14px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: var(--aurora-accent);
|
||||
color: var(--aurora-accent-text);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.button-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.fm-breadcrumb {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 14px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.fm-breadcrumb__item {
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--aurora-text-muted);
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.fm-breadcrumb__item:hover {
|
||||
color: var(--aurora-text);
|
||||
background: var(--aurora-bg-subtle);
|
||||
}
|
||||
.fm-breadcrumb__sep {
|
||||
color: var(--aurora-text-muted);
|
||||
}
|
||||
.fm-toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.fm-search {
|
||||
width: min(320px, 100%);
|
||||
min-height: 38px;
|
||||
border: 1px solid var(--aurora-border);
|
||||
border-radius: 8px;
|
||||
background: var(--aurora-bg-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 12px;
|
||||
color: var(--aurora-text-muted);
|
||||
}
|
||||
.fm-search input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
color: var(--aurora-text);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
.fm-notice {
|
||||
margin: 0 0 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--aurora-bg-subtle);
|
||||
color: var(--aurora-text);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
.fm-table {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--aurora-frame-border);
|
||||
border-radius: 8px;
|
||||
background: var(--aurora-frame-bg);
|
||||
}
|
||||
.fm-table__head,
|
||||
.fm-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(200px, 1.8fr) 120px 180px 130px;
|
||||
align-items: center;
|
||||
}
|
||||
.fm-table__head {
|
||||
min-height: 44px;
|
||||
border-bottom: 1px solid var(--aurora-border);
|
||||
color: var(--aurora-text);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 850;
|
||||
}
|
||||
.fm-table__head > div {
|
||||
padding: 0 16px;
|
||||
}
|
||||
.fm-row {
|
||||
min-height: 48px;
|
||||
border-bottom: 1px solid var(--aurora-border);
|
||||
}
|
||||
.fm-row:hover {
|
||||
background: var(--aurora-bg-subtle);
|
||||
}
|
||||
.fm-row__name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
padding: 10px 16px;
|
||||
color: var(--aurora-text);
|
||||
font-size: 0.84rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
.fm-row__name span:last-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.fm-row__name .dashicons {
|
||||
color: var(--aurora-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.fm-row__name--clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.fm-row__name--clickable:hover {
|
||||
color: var(--aurora-accent);
|
||||
}
|
||||
.fm-row__cell {
|
||||
min-width: 0;
|
||||
padding: 10px 16px;
|
||||
color: var(--aurora-text-muted);
|
||||
font-size: 0.78rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.fm-row__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
.fm-table__empty {
|
||||
padding: 38px 18px;
|
||||
color: var(--aurora-text-muted);
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
}
|
||||
.fm-editor-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100000;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 30px;
|
||||
}
|
||||
.fm-editor {
|
||||
width: min(900px, 100%);
|
||||
height: min(640px, 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--aurora-frame-border);
|
||||
border-radius: 10px;
|
||||
background: var(--aurora-frame-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.fm-editor__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--aurora-border);
|
||||
color: var(--aurora-text);
|
||||
}
|
||||
.fm-editor__readonly {
|
||||
font-size: 0.75rem;
|
||||
color: var(--aurora-text-muted);
|
||||
}
|
||||
.fm-editor__actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.fm-editor__textarea {
|
||||
flex: 1;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
resize: none;
|
||||
padding: 16px;
|
||||
background: var(--aurora-bg-subtle);
|
||||
color: var(--aurora-text);
|
||||
font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.fm-table__head {
|
||||
display: none;
|
||||
}
|
||||
.fm-row {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 6px 0;
|
||||
}
|
||||
.fm-row__cell,
|
||||
.fm-row__actions {
|
||||
padding: 4px 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,148 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
serverData: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const files = ref([]);
|
||||
const dragging = ref(false);
|
||||
const fileInput = ref(null);
|
||||
|
||||
const addFiles = (fileList) => {
|
||||
files.value.push(
|
||||
...Array.from(fileList).map((file) => ({
|
||||
file,
|
||||
name: file.name,
|
||||
status: 'pending', // pending | uploading | done | error
|
||||
message: '',
|
||||
url: '',
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
||||
const onDrop = (e) => {
|
||||
dragging.value = false;
|
||||
addFiles(e.dataTransfer.files);
|
||||
};
|
||||
|
||||
const onPick = (e) => {
|
||||
addFiles(e.target.files);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const uploadOne = async (entry) => {
|
||||
entry.status = 'uploading';
|
||||
try {
|
||||
const res = await fetch(`${props.serverData.restUrl}wp/v2/media`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-WP-Nonce': props.serverData.restNonce,
|
||||
'Content-Disposition': `attachment; filename="${entry.file.name}"`,
|
||||
'Content-Type': entry.file.type || 'application/octet-stream',
|
||||
},
|
||||
body: entry.file,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.message || 'Upload failed.');
|
||||
entry.status = 'done';
|
||||
entry.url = data.source_url || '';
|
||||
} catch (e) {
|
||||
entry.status = 'error';
|
||||
entry.message = e.message || 'Upload failed.';
|
||||
}
|
||||
};
|
||||
|
||||
const uploadAll = () => {
|
||||
files.value.filter((f) => f.status === 'pending').forEach(uploadOne);
|
||||
};
|
||||
|
||||
const clearDone = () => {
|
||||
files.value = files.value.filter((f) => f.status !== 'done');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="media-upload-page">
|
||||
<header class="media-upload-page__header">
|
||||
<div>
|
||||
<p class="media-upload-page__eyebrow">Media</p>
|
||||
<h1>Add Media File</h1>
|
||||
</div>
|
||||
<a class="button" :href="serverData.libraryUrl">Back to Media Library</a>
|
||||
</header>
|
||||
|
||||
<div
|
||||
class="media-upload-drop"
|
||||
:class="{ 'media-upload-drop--active': dragging }"
|
||||
@dragover.prevent="dragging = true"
|
||||
@dragleave.prevent="dragging = false"
|
||||
@drop.prevent="onDrop"
|
||||
@click="fileInput.click()"
|
||||
>
|
||||
<span class="dashicons dashicons-upload" />
|
||||
<p>Drop files here, or click to select files</p>
|
||||
<input ref="fileInput" type="file" multiple hidden @change="onPick" />
|
||||
</div>
|
||||
|
||||
<section v-if="files.length" class="media-upload-list">
|
||||
<div class="media-upload-list__actions">
|
||||
<button type="button" class="button-primary" @click="uploadAll">Upload All</button>
|
||||
<button type="button" class="button" @click="clearDone">Clear Completed</button>
|
||||
</div>
|
||||
|
||||
<div v-for="entry in files" :key="entry.name + entry.file.lastModified" class="media-upload-item">
|
||||
<span class="media-upload-item__name">{{ entry.name }}</span>
|
||||
<span class="media-upload-item__status" :class="`media-upload-item__status--${entry.status}`">
|
||||
<template v-if="entry.status === 'pending'">Ready</template>
|
||||
<template v-else-if="entry.status === 'uploading'">Uploading…</template>
|
||||
<template v-else-if="entry.status === 'done'">
|
||||
Uploaded — <a :href="entry.url" target="_blank" rel="noopener">View</a>
|
||||
</template>
|
||||
<template v-else>{{ entry.message }}</template>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.media-upload-page { max-width: 720px; margin: 0 auto; }
|
||||
.media-upload-page__header {
|
||||
display: flex; align-items: flex-start; justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.media-upload-page__eyebrow {
|
||||
margin: 0 0 4px; font-size: 0.75rem; text-transform: uppercase;
|
||||
letter-spacing: 0.06em; color: var(--aurora-text-muted);
|
||||
}
|
||||
.media-upload-page h1 { margin: 0; font-size: 1.6rem; font-weight: 700; color: var(--aurora-text); }
|
||||
|
||||
.media-upload-drop {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 10px; padding: 48px 24px; text-align: center; cursor: pointer;
|
||||
border: 2px dashed var(--aurora-border);
|
||||
border-radius: 14px;
|
||||
color: var(--aurora-text-muted);
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.media-upload-drop .dashicons { font-size: 32px; width: 32px; height: 32px; }
|
||||
.media-upload-drop--active,
|
||||
.media-upload-drop:hover { border-color: var(--aurora-accent); color: var(--aurora-text); }
|
||||
|
||||
.media-upload-list { margin-top: 20px; }
|
||||
.media-upload-list__actions { display: flex; gap: 8px; margin-bottom: 12px; }
|
||||
|
||||
.media-upload-item {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px 14px; border-radius: 8px;
|
||||
background: var(--aurora-bg-subtle);
|
||||
border: 1px solid var(--aurora-border);
|
||||
margin-bottom: 6px; font-size: 0.875rem;
|
||||
}
|
||||
.media-upload-item__name { color: var(--aurora-text); }
|
||||
.media-upload-item__status { color: var(--aurora-text-muted); }
|
||||
.media-upload-item__status--done { color: #22c55e; }
|
||||
.media-upload-item__status--error { color: #ef4444; }
|
||||
.media-upload-item__status a { color: inherit; text-decoration: underline; }
|
||||
</style>
|
||||
+386
-67
@@ -1,10 +1,14 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import MediaFolderTree from './MediaFolderTree.vue';
|
||||
|
||||
const props = defineProps({
|
||||
serverData: { type: Object, required: true },
|
||||
});
|
||||
|
||||
// Media Folders is a core feature — always on, no gating.
|
||||
const foldersAvailable = true;
|
||||
|
||||
const items = ref([]);
|
||||
const stats = ref({ all: 0, image: 0, audio: 0, video: 0, application: 0, unattached: 0 });
|
||||
const pagination = ref({ page: 1, perPage: 24, total: 0, totalPages: 1 });
|
||||
@@ -15,6 +19,16 @@ const notice = ref('');
|
||||
const search = ref('');
|
||||
const type = ref('all');
|
||||
|
||||
// Media Folders (paid) state — folderId 0 = All Media, -1 = Uncategorized,
|
||||
// matching MediaFolders::ALL_MEDIA / ::UNCATEGORIZED on the PHP side.
|
||||
const folderId = ref(0);
|
||||
const folderTree = ref([]);
|
||||
const allMediaCount = ref(0);
|
||||
const uncategorizedCount = ref(0);
|
||||
const foldersLoading = ref(false);
|
||||
const expanded = reactive({});
|
||||
const moveMenuFor = ref(0);
|
||||
|
||||
const apiBase = computed(() => `${props.serverData.restUrl}aurora-admin/v1`);
|
||||
|
||||
const request = async (path, options = {}) => {
|
||||
@@ -44,6 +58,7 @@ const load = async () => {
|
||||
page: String(pagination.value.page),
|
||||
perPage: String(pagination.value.perPage),
|
||||
});
|
||||
params.set('folderId', String(folderId.value));
|
||||
const data = await request(`/media?${params.toString()}`);
|
||||
items.value = data.items || [];
|
||||
stats.value = data.stats || stats.value;
|
||||
@@ -55,7 +70,99 @@ const load = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(load);
|
||||
const loadFolders = async () => {
|
||||
foldersLoading.value = true;
|
||||
try {
|
||||
const data = await request('/media-folders');
|
||||
folderTree.value = data.tree || [];
|
||||
allMediaCount.value = data.allMediaCount || 0;
|
||||
uncategorizedCount.value = data.uncategorizedCount || 0;
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Could not load folders';
|
||||
} finally {
|
||||
foldersLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const flattenFolders = (nodes, depth = 0, out = []) => {
|
||||
for (const node of nodes) {
|
||||
out.push({ id: node.id, name: node.name, depth });
|
||||
if (node.children && node.children.length) flattenFolders(node.children, depth + 1, out);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const flatFolders = computed(() => flattenFolders(folderTree.value));
|
||||
|
||||
const selectFolder = (id) => {
|
||||
folderId.value = id;
|
||||
pagination.value.page = 1;
|
||||
load();
|
||||
};
|
||||
|
||||
const toggleFolder = (id) => {
|
||||
expanded[id] = !expanded[id];
|
||||
};
|
||||
|
||||
const createFolder = async (parent) => {
|
||||
const name = window.prompt('New folder name:');
|
||||
if (!name) return;
|
||||
try {
|
||||
await request('/media-folders', { method: 'POST', body: JSON.stringify({ name, parent }) });
|
||||
if (parent) expanded[parent] = true;
|
||||
await loadFolders();
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Could not create folder';
|
||||
}
|
||||
};
|
||||
|
||||
const renameFolder = async (node) => {
|
||||
const name = window.prompt('Rename folder:', node.name);
|
||||
if (!name || name === node.name) return;
|
||||
try {
|
||||
await request(`/media-folders/${node.id}`, { method: 'POST', body: JSON.stringify({ name }) });
|
||||
await loadFolders();
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Could not rename folder';
|
||||
}
|
||||
};
|
||||
|
||||
const deleteFolder = async (node) => {
|
||||
if (!window.confirm(`Delete "${node.name}"? Its media will become uncategorized.`)) return;
|
||||
try {
|
||||
await request(`/media-folders/${node.id}`, { method: 'DELETE' });
|
||||
if (folderId.value === node.id) selectFolder(0);
|
||||
else await loadFolders();
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Could not delete folder';
|
||||
}
|
||||
};
|
||||
|
||||
const reparentFolder = async ({ id, parent }) => {
|
||||
try {
|
||||
await request(`/media-folders/${id}`, { method: 'POST', body: JSON.stringify({ parent }) });
|
||||
await loadFolders();
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Could not move folder';
|
||||
}
|
||||
};
|
||||
|
||||
const assignToFolder = async (folderIdTarget, attachmentIds) => {
|
||||
try {
|
||||
await request('/media-folders/assign', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ folderId: folderIdTarget, attachmentIds }),
|
||||
});
|
||||
moveMenuFor.value = 0;
|
||||
await Promise.all([load(), loadFolders()]);
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Could not move media';
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
load();
|
||||
loadFolders();
|
||||
});
|
||||
|
||||
let searchTimer = 0;
|
||||
watch(search, () => {
|
||||
@@ -136,77 +243,168 @@ const typeIcon = (mimeType) => {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="media-page__toolbar" aria-label="Media filters and search">
|
||||
<div class="media-page__filters">
|
||||
<button
|
||||
v-for="filter in filters"
|
||||
:key="filter.key"
|
||||
type="button"
|
||||
class="filter-pill"
|
||||
:class="{ 'is-active': type === filter.key }"
|
||||
@click="setType(filter.key)"
|
||||
>
|
||||
<span>{{ filter.label }}</span>
|
||||
<strong>{{ filter.count }}</strong>
|
||||
</button>
|
||||
</div>
|
||||
<label class="media-page__search">
|
||||
<span class="dashicons dashicons-search" />
|
||||
<input v-model="search" type="search" placeholder="Search media..." />
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<div v-if="notice" class="media-notice media-notice--success">{{ notice }}</div>
|
||||
<div v-if="error" class="media-notice media-notice--error">{{ error }}</div>
|
||||
|
||||
<div v-if="loading" class="media-grid__empty">Loading media...</div>
|
||||
<div v-else-if="!items.length" class="media-grid__empty">No media items match your filters.</div>
|
||||
|
||||
<section v-else class="media-grid" aria-label="Media items">
|
||||
<article v-for="item in items" :key="item.id" class="media-card">
|
||||
<div class="media-card__preview">
|
||||
<img v-if="item.isImage && item.thumbnailUrl" :src="item.thumbnailUrl" :alt="item.title" />
|
||||
<span v-else class="dashicons media-card__icon" :class="typeIcon(item.mimeType)" />
|
||||
<div class="media-card__overlay">
|
||||
<a class="icon-button icon-button--inline" title="Edit" :href="item.editUrl">
|
||||
<span class="dashicons dashicons-edit" />
|
||||
</a>
|
||||
<a
|
||||
class="icon-button icon-button--inline"
|
||||
title="View file"
|
||||
:href="item.fileUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<span class="dashicons dashicons-visibility" />
|
||||
</a>
|
||||
<div class="media-page__body">
|
||||
<aside class="media-page__sidebar">
|
||||
<div class="mf-pinned">
|
||||
<button
|
||||
v-if="item.canDelete"
|
||||
type="button"
|
||||
class="icon-button icon-button--inline"
|
||||
title="Delete permanently"
|
||||
:disabled="busyId === item.id"
|
||||
@click="deleteItem(item)"
|
||||
class="mf-pinned__row"
|
||||
:class="{ 'mf-pinned__row--active': folderId === 0 }"
|
||||
@dragover.prevent
|
||||
@drop.prevent="
|
||||
(e) => {
|
||||
const p = e.dataTransfer.getData('text/plain');
|
||||
if (p.startsWith('aurora-attachments:')) assignToFolder(0, p.split(':')[1].split(',').map(Number));
|
||||
}
|
||||
"
|
||||
@click="selectFolder(0)"
|
||||
>
|
||||
<span class="dashicons dashicons-trash" />
|
||||
<span class="dashicons dashicons-admin-media" />
|
||||
<span>All Media</span>
|
||||
<span class="mf-pinned__count">{{ allMediaCount }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="mf-pinned__row"
|
||||
:class="{ 'mf-pinned__row--active': folderId === -1 }"
|
||||
@dragover.prevent
|
||||
@drop.prevent="
|
||||
(e) => {
|
||||
const p = e.dataTransfer.getData('text/plain');
|
||||
if (p.startsWith('aurora-attachments:')) assignToFolder(-1, p.split(':')[1].split(',').map(Number));
|
||||
}
|
||||
"
|
||||
@click="selectFolder(-1)"
|
||||
>
|
||||
<span class="dashicons dashicons-editor-help" />
|
||||
<span>Uncategorized</span>
|
||||
<span class="mf-pinned__count">{{ uncategorizedCount }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="media-card__meta">
|
||||
<strong :title="item.title">{{ item.title }}</strong>
|
||||
<span>{{ item.typeLabel }}<template v-if="item.dimensions"> · {{ item.dimensions }}</template></span>
|
||||
<span v-if="item.attachedToTitle">
|
||||
Attached to
|
||||
<a v-if="item.attachedToEditUrl" :href="item.attachedToEditUrl">{{ item.attachedToTitle }}</a>
|
||||
<template v-else>{{ item.attachedToTitle }}</template>
|
||||
</span>
|
||||
<span v-else class="media-card__unattached">Unattached</span>
|
||||
<span>{{ item.date }}<template v-if="item.fileSize"> · {{ item.fileSize }}</template></span>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<footer v-if="!loading && items.length" class="media-page__footer">
|
||||
<div class="mf-sidebar__header">
|
||||
<span>Folders</span>
|
||||
<button type="button" class="icon-button icon-button--tiny" title="New folder" @click="createFolder(0)">
|
||||
<span class="dashicons dashicons-plus-alt2" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="foldersLoading" class="mf-sidebar__loading">Loading folders…</p>
|
||||
<p v-else-if="!folderTree.length" class="mf-sidebar__empty">No folders yet.</p>
|
||||
<MediaFolderTree
|
||||
v-else
|
||||
:nodes="folderTree"
|
||||
:active-folder-id="folderId"
|
||||
:expanded="expanded"
|
||||
@select="selectFolder"
|
||||
@toggle="toggleFolder"
|
||||
@create-child="createFolder"
|
||||
@rename="renameFolder"
|
||||
@delete="deleteFolder"
|
||||
@reparent="reparentFolder"
|
||||
@assign="({ folderId: fid, attachmentIds }) => assignToFolder(fid, attachmentIds)"
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<div class="media-page__main">
|
||||
<section class="media-page__toolbar" aria-label="Media filters and search">
|
||||
<div class="media-page__filters">
|
||||
<button
|
||||
v-for="filter in filters"
|
||||
:key="filter.key"
|
||||
type="button"
|
||||
class="filter-pill"
|
||||
:class="{ 'is-active': type === filter.key }"
|
||||
@click="setType(filter.key)"
|
||||
>
|
||||
<span>{{ filter.label }}</span>
|
||||
<strong>{{ filter.count }}</strong>
|
||||
</button>
|
||||
</div>
|
||||
<label class="media-page__search">
|
||||
<span class="dashicons dashicons-search" />
|
||||
<input v-model="search" type="search" placeholder="Search media..." />
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<div v-if="notice" class="media-notice media-notice--success">{{ notice }}</div>
|
||||
<div v-if="error" class="media-notice media-notice--error">{{ error }}</div>
|
||||
|
||||
<div v-if="loading" class="media-grid__empty">Loading media...</div>
|
||||
<div v-else-if="!items.length" class="media-grid__empty">No media items match your filters.</div>
|
||||
|
||||
<section v-else class="media-grid" aria-label="Media items">
|
||||
<article
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="media-card"
|
||||
:draggable="foldersAvailable"
|
||||
@dragstart="foldersAvailable && $event.dataTransfer.setData('text/plain', `aurora-attachments:${item.id}`)"
|
||||
>
|
||||
<div class="media-card__preview">
|
||||
<img v-if="item.isImage && item.thumbnailUrl" :src="item.thumbnailUrl" :alt="item.title" />
|
||||
<span v-else class="dashicons media-card__icon" :class="typeIcon(item.mimeType)" />
|
||||
<div class="media-card__overlay">
|
||||
<a class="icon-button icon-button--inline" title="Edit" :href="item.editUrl">
|
||||
<span class="dashicons dashicons-edit" />
|
||||
</a>
|
||||
<a
|
||||
class="icon-button icon-button--inline"
|
||||
title="View file"
|
||||
:href="item.fileUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<span class="dashicons dashicons-visibility" />
|
||||
</a>
|
||||
<button
|
||||
v-if="foldersAvailable"
|
||||
type="button"
|
||||
class="icon-button icon-button--inline"
|
||||
title="Move to folder"
|
||||
@click.stop="moveMenuFor = moveMenuFor === item.id ? 0 : item.id"
|
||||
>
|
||||
<span class="dashicons dashicons-portfolio" />
|
||||
</button>
|
||||
<button
|
||||
v-if="item.canDelete"
|
||||
type="button"
|
||||
class="icon-button icon-button--inline"
|
||||
title="Delete permanently"
|
||||
:disabled="busyId === item.id"
|
||||
@click="deleteItem(item)"
|
||||
>
|
||||
<span class="dashicons dashicons-trash" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="moveMenuFor === item.id" class="media-card__move-menu" @click.stop>
|
||||
<button type="button" @click="assignToFolder(0, [item.id])">Uncategorized</button>
|
||||
<button
|
||||
v-for="f in flatFolders"
|
||||
:key="f.id"
|
||||
type="button"
|
||||
:style="{ paddingLeft: 10 + f.depth * 14 + 'px' }"
|
||||
@click="assignToFolder(f.id, [item.id])"
|
||||
>
|
||||
{{ f.name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="media-card__meta">
|
||||
<strong :title="item.title">{{ item.title }}</strong>
|
||||
<span>{{ item.typeLabel }}<template v-if="item.dimensions"> · {{ item.dimensions }}</template></span>
|
||||
<span v-if="item.attachedToTitle">
|
||||
Attached to
|
||||
<a v-if="item.attachedToEditUrl" :href="item.attachedToEditUrl">{{ item.attachedToTitle }}</a>
|
||||
<template v-else>{{ item.attachedToTitle }}</template>
|
||||
</span>
|
||||
<span v-else class="media-card__unattached">Unattached</span>
|
||||
<span>{{ item.date }}<template v-if="item.fileSize"> · {{ item.fileSize }}</template></span>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<footer v-if="!loading && items.length" class="media-page__footer">
|
||||
<div>Page Size: {{ pagination.perPage }}</div>
|
||||
<div>{{ rowRange }} of {{ pagination.total }}</div>
|
||||
<div class="media-page__pager">
|
||||
@@ -233,6 +431,8 @@ const typeIcon = (mimeType) => {
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -515,5 +715,124 @@ const typeIcon = (mimeType) => {
|
||||
.media-page__footer {
|
||||
gap: 10px;
|
||||
}
|
||||
.media-page__body {
|
||||
flex-direction: column;
|
||||
}
|
||||
.media-page__sidebar {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.media-page__body {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
}
|
||||
.media-page__sidebar {
|
||||
flex-shrink: 0;
|
||||
width: 220px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--aurora-frame-border);
|
||||
border-radius: 10px;
|
||||
background: var(--aurora-frame-bg);
|
||||
}
|
||||
.media-page__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.mf-pinned {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid var(--aurora-border);
|
||||
}
|
||||
.mf-pinned__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: none;
|
||||
padding: 6px 6px;
|
||||
color: var(--aurora-text);
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.mf-pinned__row:hover {
|
||||
background: var(--aurora-bg-subtle);
|
||||
}
|
||||
.mf-pinned__row--active {
|
||||
background: var(--aurora-accent-soft);
|
||||
}
|
||||
.mf-pinned__row .dashicons {
|
||||
font-size: 15px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
color: var(--aurora-text-muted);
|
||||
}
|
||||
.mf-pinned__row span:nth-child(2) {
|
||||
flex: 1;
|
||||
}
|
||||
.mf-pinned__count {
|
||||
font-size: 0.7rem;
|
||||
color: var(--aurora-text-muted);
|
||||
}
|
||||
.mf-sidebar__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
color: var(--aurora-text-muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.icon-button--tiny {
|
||||
width: 22px;
|
||||
min-height: 22px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--aurora-text-muted);
|
||||
}
|
||||
.icon-button--tiny:hover {
|
||||
color: var(--aurora-text);
|
||||
}
|
||||
.mf-sidebar__loading,
|
||||
.mf-sidebar__empty {
|
||||
margin: 0;
|
||||
color: var(--aurora-text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.media-card__move-menu {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
max-height: 160px;
|
||||
min-width: 150px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--aurora-border);
|
||||
border-radius: 8px;
|
||||
background: var(--aurora-frame-bg);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
padding: 4px;
|
||||
}
|
||||
.media-card__move-menu button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
border: none;
|
||||
background: none;
|
||||
border-radius: 6px;
|
||||
color: var(--aurora-text);
|
||||
font-size: 0.78rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.media-card__move-menu button:hover {
|
||||
background: var(--aurora-bg-subtle);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
nodes: { type: Array, required: true },
|
||||
activeFolderId: { type: Number, required: true },
|
||||
expanded: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select', 'toggle', 'create-child', 'rename', 'delete', 'reparent', 'assign']);
|
||||
|
||||
// Drag-and-drop reparenting (folder onto folder) and media-into-folder both
|
||||
// go through the same 'text/plain' payload, prefixed so drop handlers can
|
||||
// tell them apart — custom MIME types are unreliable to read during
|
||||
// dragover in some browsers, plain text is not.
|
||||
const dragOverId = ref(0);
|
||||
|
||||
const onDragStart = (event, node) => {
|
||||
event.dataTransfer.setData('text/plain', `aurora-folder:${node.id}`);
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
const onDragOver = (event, node) => {
|
||||
event.preventDefault();
|
||||
dragOverId.value = node.id;
|
||||
};
|
||||
|
||||
const onDragLeave = (node) => {
|
||||
if (dragOverId.value === node.id) dragOverId.value = 0;
|
||||
};
|
||||
|
||||
const onDrop = (event, node) => {
|
||||
event.preventDefault();
|
||||
dragOverId.value = 0;
|
||||
const payload = event.dataTransfer.getData('text/plain');
|
||||
if (!payload) return;
|
||||
|
||||
if (payload.startsWith('aurora-folder:')) {
|
||||
const draggedId = Number(payload.split(':')[1]);
|
||||
if (draggedId && draggedId !== node.id) {
|
||||
emit('reparent', { id: draggedId, parent: node.id });
|
||||
}
|
||||
} else if (payload.startsWith('aurora-attachments:')) {
|
||||
const ids = payload
|
||||
.split(':')[1]
|
||||
.split(',')
|
||||
.map(Number)
|
||||
.filter(Boolean);
|
||||
if (ids.length) {
|
||||
emit('assign', { folderId: node.id, attachmentIds: ids });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const contextMenu = ref({ open: false, x: 0, y: 0, node: null });
|
||||
|
||||
const openMenu = (event, node) => {
|
||||
contextMenu.value = { open: true, x: event.clientX, y: event.clientY, node };
|
||||
setTimeout(() => document.addEventListener('click', closeMenu), 0);
|
||||
};
|
||||
|
||||
const closeMenu = () => {
|
||||
contextMenu.value.open = false;
|
||||
document.removeEventListener('click', closeMenu);
|
||||
};
|
||||
|
||||
const runFromMenu = (fn) => {
|
||||
closeMenu();
|
||||
fn();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="mf-tree">
|
||||
<li v-for="node in nodes" :key="node.id">
|
||||
<div
|
||||
class="mf-tree__row"
|
||||
:class="{ 'mf-tree__row--active': node.id === activeFolderId, 'mf-tree__row--dragover': dragOverId === node.id }"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart($event, node)"
|
||||
@dragover="onDragOver($event, node)"
|
||||
@dragleave="onDragLeave(node)"
|
||||
@drop="onDrop($event, node)"
|
||||
@contextmenu.prevent="openMenu($event, node)"
|
||||
>
|
||||
<button
|
||||
v-if="node.children && node.children.length"
|
||||
type="button"
|
||||
class="mf-tree__toggle"
|
||||
@click="emit('toggle', node.id)"
|
||||
>
|
||||
<span class="dashicons dashicons-arrow-right-alt2" :class="{ 'mf-tree__toggle-icon--open': expanded[node.id] }" />
|
||||
</button>
|
||||
<span v-else class="mf-tree__toggle-spacer" />
|
||||
<button type="button" class="mf-tree__label" @click="emit('select', node.id)">
|
||||
<span class="dashicons dashicons-portfolio" :style="node.color ? { color: node.color } : {}" />
|
||||
<span class="mf-tree__name">{{ node.name }}</span>
|
||||
<span class="mf-tree__count">{{ node.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<MediaFolderTree
|
||||
v-if="expanded[node.id] && node.children && node.children.length"
|
||||
:nodes="node.children"
|
||||
:active-folder-id="activeFolderId"
|
||||
:expanded="expanded"
|
||||
@select="emit('select', $event)"
|
||||
@toggle="emit('toggle', $event)"
|
||||
@create-child="emit('create-child', $event)"
|
||||
@rename="emit('rename', $event)"
|
||||
@delete="emit('delete', $event)"
|
||||
@reparent="emit('reparent', $event)"
|
||||
@assign="emit('assign', $event)"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<Teleport to="body">
|
||||
<ul v-if="contextMenu.open" class="mf-context-menu" :style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }" @contextmenu.prevent>
|
||||
<li><button type="button" @click="runFromMenu(() => emit('create-child', contextMenu.node.id))"><span class="dashicons dashicons-plus" /> New Subfolder</button></li>
|
||||
<li><button type="button" @click="runFromMenu(() => emit('rename', contextMenu.node))"><span class="dashicons dashicons-edit" /> Rename</button></li>
|
||||
<li class="mf-context-menu__sep" />
|
||||
<li><button type="button" class="mf-context-menu__danger" @click="runFromMenu(() => emit('delete', contextMenu.node))"><span class="dashicons dashicons-trash" /> Delete</button></li>
|
||||
</ul>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mf-tree { list-style: none; margin: 0; padding-left: 14px; }
|
||||
.mf-tree:first-of-type { padding-left: 0; }
|
||||
.mf-tree__row {
|
||||
display: flex; align-items: center; gap: 2px; border-radius: 6px;
|
||||
}
|
||||
.mf-tree__row:hover { background: var(--aurora-bg-subtle); }
|
||||
.mf-tree__row--active { background: var(--aurora-accent-soft); }
|
||||
.mf-tree__row--dragover { outline: 2px dashed var(--aurora-accent); outline-offset: -2px; }
|
||||
.mf-tree__toggle,
|
||||
.mf-tree__toggle-spacer {
|
||||
width: 18px; height: 24px; flex-shrink: 0;
|
||||
}
|
||||
.mf-tree__toggle {
|
||||
border: none; background: none; cursor: pointer;
|
||||
color: var(--aurora-text-muted); display: inline-flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.mf-tree__toggle .dashicons { font-size: 14px; width: 14px; height: 14px; transition: transform 0.15s; }
|
||||
.mf-tree__toggle-icon--open { transform: rotate(90deg); }
|
||||
.mf-tree__label {
|
||||
flex: 1; min-width: 0; display: flex; align-items: center; gap: 6px;
|
||||
border: none; background: none; cursor: pointer; text-align: left;
|
||||
padding: 3px 4px; color: var(--aurora-text); font-size: 0.8125rem;
|
||||
}
|
||||
.mf-tree__name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
|
||||
.mf-tree__label .dashicons:first-child { color: var(--aurora-text-muted); flex-shrink: 0; font-size: 15px; width: 15px; height: 15px; }
|
||||
.mf-tree__count { font-size: 0.7rem; color: var(--aurora-text-muted); flex-shrink: 0; }
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.mf-context-menu {
|
||||
position: fixed;
|
||||
z-index: 100000;
|
||||
min-width: 170px;
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
list-style: none;
|
||||
border: 1px solid var(--aurora-border);
|
||||
border-radius: 8px;
|
||||
background: var(--aurora-frame-bg);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.mf-context-menu button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
border: none;
|
||||
background: none;
|
||||
border-radius: 6px;
|
||||
color: var(--aurora-text);
|
||||
font-size: 0.8rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mf-context-menu button:hover { background: var(--aurora-bg-subtle); }
|
||||
.mf-context-menu button .dashicons { font-size: 16px; width: 16px; height: 16px; color: var(--aurora-text-muted); }
|
||||
.mf-context-menu__danger { color: #ef4444 !important; }
|
||||
.mf-context-menu__danger .dashicons { color: #ef4444 !important; }
|
||||
.mf-context-menu__sep {
|
||||
height: 1px;
|
||||
margin: 4px 6px;
|
||||
background: var(--aurora-border);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,215 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import SettingsField from '@/pages/settings/components/SettingsField.vue';
|
||||
|
||||
const props = defineProps({
|
||||
serverData: { type: Object, required: true },
|
||||
});
|
||||
|
||||
// Permalinks (rewrite rules) and Discussion (core's REST settings schema
|
||||
// only exposes 2 of its ~15 real options — see DiscussionData.php) both
|
||||
// have their own aurora-admin/v1 routes instead of going through
|
||||
// wp/v2/settings like every other native-settings screen.
|
||||
const isPermalinks = props.serverData.screen === 'permalinks';
|
||||
const isDiscussion = props.serverData.screen === 'discussion';
|
||||
|
||||
const values = reactive({ ...(props.serverData.values || {}) });
|
||||
|
||||
// Mirrors options-permalink.php's own preset list — the actual reason
|
||||
// people visit this screen; the "Custom Structure" text field alone
|
||||
// (this screen's only original field) skipped the part of the native
|
||||
// page virtually everyone actually uses instead of hand-writing tags.
|
||||
const permalinkPresets = [
|
||||
{ key: 'plain', label: 'Plain', structure: '', example: '?p=123' },
|
||||
{
|
||||
key: 'day',
|
||||
label: 'Day and name',
|
||||
structure: '/%year%/%monthnum%/%day%/%postname%/',
|
||||
example: '/2026/01/28/sample-post/',
|
||||
},
|
||||
{
|
||||
key: 'month',
|
||||
label: 'Month and name',
|
||||
structure: '/%year%/%monthnum%/%postname%/',
|
||||
example: '/2026/01/sample-post/',
|
||||
},
|
||||
{
|
||||
key: 'numeric',
|
||||
label: 'Numeric',
|
||||
structure: '/archives/%post_id%',
|
||||
example: '/archives/123',
|
||||
},
|
||||
{
|
||||
key: 'postname',
|
||||
label: 'Post name',
|
||||
structure: '/%postname%/',
|
||||
example: '/sample-post/',
|
||||
},
|
||||
{ key: 'custom', label: 'Custom Structure', structure: null, example: '' },
|
||||
];
|
||||
|
||||
// Fields can declare showIf: { key, equals } to only appear once another
|
||||
// field's current value matches — e.g. the front-page/posts-page pickers
|
||||
// only make sense once "Homepage Displays" is set to "A static page",
|
||||
// matching the native Reading screen's own conditional reveal.
|
||||
const visibleFields = computed(() =>
|
||||
props.serverData.fields.filter((f) => !f.showIf || values[f.showIf.key] === f.showIf.equals)
|
||||
);
|
||||
|
||||
const selectedPreset = computed(() => {
|
||||
const match = permalinkPresets.find(
|
||||
(p) => p.structure !== null && p.structure === (values.permalink_structure || '')
|
||||
);
|
||||
return match ? match.key : 'custom';
|
||||
});
|
||||
|
||||
const choosePreset = (preset) => {
|
||||
if (preset.key === 'custom') {
|
||||
// Leave whatever custom value is already there (or empty) — this
|
||||
// option just reveals the text field instead of assigning a value.
|
||||
if (selectedPreset.value !== 'custom') values.permalink_structure = '';
|
||||
return;
|
||||
}
|
||||
values.permalink_structure = preset.structure;
|
||||
};
|
||||
|
||||
const saving = ref(false);
|
||||
const savedMsg = ref('');
|
||||
|
||||
const save = async () => {
|
||||
saving.value = true;
|
||||
savedMsg.value = '';
|
||||
try {
|
||||
const url = isPermalinks
|
||||
? `${props.serverData.restUrl}aurora-admin/v1/permalinks`
|
||||
: isDiscussion
|
||||
? `${props.serverData.restUrl}aurora-admin/v1/discussion-settings`
|
||||
: `${props.serverData.restUrl}wp/v2/settings`;
|
||||
|
||||
const body = isPermalinks
|
||||
? { structure: values.permalink_structure || '' }
|
||||
: isDiscussion
|
||||
? {
|
||||
values: Object.fromEntries(
|
||||
props.serverData.fields.filter((f) => !f.readonly).map((f) => [f.key, values[f.key]])
|
||||
),
|
||||
}
|
||||
: Object.fromEntries(
|
||||
props.serverData.fields
|
||||
.filter((f) => !f.readonly)
|
||||
.map((f) => [f.key, values[f.key]])
|
||||
);
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': props.serverData.restNonce },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
savedMsg.value = 'Saved';
|
||||
setTimeout(() => (savedMsg.value = ''), 2500);
|
||||
} catch {
|
||||
savedMsg.value = 'Could not save';
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="native-settings">
|
||||
<header class="native-settings__head">
|
||||
<div>
|
||||
<h2 class="native-settings__title">{{ serverData.title }}</h2>
|
||||
<p class="native-settings__sub">{{ serverData.subtitle }}</p>
|
||||
</div>
|
||||
<div class="native-settings__save-wrap">
|
||||
<span v-if="savedMsg" class="native-settings__saved">{{ savedMsg }}</span>
|
||||
<button type="button" class="native-settings__save" :disabled="saving" @click="save">
|
||||
{{ saving ? 'Saving…' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="isPermalinks" class="native-settings__presets">
|
||||
<label v-for="preset in permalinkPresets" :key="preset.key" class="native-settings__preset">
|
||||
<input
|
||||
type="radio"
|
||||
name="permalink-preset"
|
||||
:checked="selectedPreset === preset.key"
|
||||
@change="choosePreset(preset)"
|
||||
/>
|
||||
<span class="native-settings__preset-label">{{ preset.label }}</span>
|
||||
<code v-if="preset.example" class="native-settings__preset-example">{{ preset.example }}</code>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="native-settings__fields">
|
||||
<SettingsField
|
||||
v-for="f in visibleFields"
|
||||
:key="f.key"
|
||||
:field="f"
|
||||
v-model="values[f.key]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.native-settings {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.native-settings__head {
|
||||
display: flex; align-items: flex-start; justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding-bottom: 22px;
|
||||
margin-bottom: 4px;
|
||||
border-bottom: 1px solid var(--aurora-frame-border);
|
||||
}
|
||||
.native-settings__title { font-size: 1.6rem; font-weight: 700; margin: 0; color: var(--aurora-text); }
|
||||
.native-settings__sub { margin: 6px 0 0; font-size: 0.875rem; color: var(--aurora-text-muted); }
|
||||
.native-settings__save-wrap { display: flex; align-items: center; gap: 12px; flex-shrink: 0; }
|
||||
.native-settings__saved { font-size: 0.8125rem; color: var(--aurora-text-muted); }
|
||||
.native-settings__save {
|
||||
background: var(--aurora-accent); color: #fff; border: none;
|
||||
border-radius: 9px; padding: 9px 20px; font-size: 0.875rem; font-weight: 500; cursor: pointer;
|
||||
}
|
||||
.native-settings__save:disabled { opacity: 0.6; cursor: default; }
|
||||
.native-settings__fields > :last-child { border-bottom: none; }
|
||||
|
||||
.native-settings__presets {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 18px 0;
|
||||
border-bottom: 1px solid var(--aurora-frame-border);
|
||||
}
|
||||
.native-settings__preset {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 0;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
color: var(--aurora-text);
|
||||
}
|
||||
.native-settings__preset input[type='radio'] {
|
||||
accent-color: var(--aurora-accent);
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.native-settings__preset-label {
|
||||
min-width: 150px;
|
||||
flex-shrink: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
.native-settings__preset-example {
|
||||
color: var(--aurora-text-muted);
|
||||
font-size: 0.8125rem;
|
||||
background: var(--aurora-bg-subtle);
|
||||
padding: 2px 8px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,437 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
serverData: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const api = (path) => `${props.serverData.restUrl}aurora-admin/v1${path}`;
|
||||
const headers = () => ({ 'Content-Type': 'application/json', 'X-WP-Nonce': props.serverData.restNonce });
|
||||
|
||||
const menus = ref([]);
|
||||
const activeMenuId = ref(null);
|
||||
const items = ref([]);
|
||||
const locations = ref([]);
|
||||
const pickers = ref({ pages: [], posts: [], categories: [] });
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
const notice = ref('');
|
||||
|
||||
const activeMenu = computed(() => menus.value.find((m) => m.id === activeMenuId.value));
|
||||
|
||||
// Flat list, ordered so parents appear before children and children are
|
||||
// visually indented — simpler than a tree render, same end result.
|
||||
const orderedItems = computed(() => {
|
||||
const byParent = {};
|
||||
items.value.forEach((i) => {
|
||||
(byParent[i.parent] ||= []).push(i);
|
||||
});
|
||||
Object.values(byParent).forEach((list) => list.sort((a, b) => a.order - b.order));
|
||||
|
||||
const walk = (parentId, depth) => {
|
||||
const out = [];
|
||||
(byParent[parentId] || []).forEach((item) => {
|
||||
out.push({ ...item, depth });
|
||||
out.push(...walk(item.id, depth + 1));
|
||||
});
|
||||
return out;
|
||||
};
|
||||
return walk(0, 0);
|
||||
});
|
||||
|
||||
const loadMenus = async () => {
|
||||
try {
|
||||
const res = await fetch(api('/nav-menus'), { headers: headers() });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.message || 'Could not load menus.');
|
||||
menus.value = data;
|
||||
if (!activeMenuId.value && menus.value.length) {
|
||||
activeMenuId.value = menus.value[0].id;
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e.message || 'Could not load menus.';
|
||||
}
|
||||
};
|
||||
|
||||
const loadItems = async () => {
|
||||
if (!activeMenuId.value) {
|
||||
items.value = [];
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await fetch(api(`/nav-menus/${activeMenuId.value}/items`), { headers: headers() });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.message || 'Could not load menu items.');
|
||||
items.value = data;
|
||||
} catch (e) {
|
||||
error.value = e.message || 'Could not load menu items.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadLocations = async () => {
|
||||
try {
|
||||
const res = await fetch(api('/nav-menus/locations'), { headers: headers() });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.message || 'Could not load locations.');
|
||||
locations.value = data;
|
||||
} catch (e) {
|
||||
error.value = e.message || 'Could not load locations.';
|
||||
}
|
||||
};
|
||||
|
||||
const loadPickers = async () => {
|
||||
try {
|
||||
const res = await fetch(api('/nav-menus/pickers'), { headers: headers() });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.message || 'Could not load pickers.');
|
||||
pickers.value = data;
|
||||
} catch (e) {
|
||||
error.value = e.message || 'Could not load pickers.';
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadMenus(), loadLocations(), loadPickers()]);
|
||||
await loadItems();
|
||||
});
|
||||
|
||||
const selectMenu = async (id) => {
|
||||
activeMenuId.value = id;
|
||||
await loadItems();
|
||||
};
|
||||
|
||||
// Create menu -----------------------------------------------------------
|
||||
const newMenuName = ref('');
|
||||
const creatingMenu = ref(false);
|
||||
const createMenu = async () => {
|
||||
if (!newMenuName.value.trim()) return;
|
||||
creatingMenu.value = true;
|
||||
try {
|
||||
const res = await fetch(api('/nav-menus'), {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({ name: newMenuName.value.trim() }),
|
||||
});
|
||||
const menu = await res.json();
|
||||
if (!res.ok) throw new Error(menu?.message || 'Could not create menu.');
|
||||
menus.value.push(menu);
|
||||
newMenuName.value = '';
|
||||
await selectMenu(menu.id);
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
} finally {
|
||||
creatingMenu.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteMenu = async () => {
|
||||
if (!activeMenu.value) return;
|
||||
if (!confirm(`Delete menu "${activeMenu.value.name}"? This can't be undone.`)) return;
|
||||
await fetch(api(`/nav-menus/${activeMenuId.value}`), { method: 'DELETE', headers: headers() });
|
||||
menus.value = menus.value.filter((m) => m.id !== activeMenuId.value);
|
||||
activeMenuId.value = menus.value[0]?.id || null;
|
||||
await loadItems();
|
||||
};
|
||||
|
||||
// Add items ---------------------------------------------------------------
|
||||
const addTab = ref('pages');
|
||||
const customLink = ref({ title: '', url: 'https://' });
|
||||
const selectedIds = ref([]);
|
||||
|
||||
const toggleSelected = (id) => {
|
||||
const i = selectedIds.value.indexOf(id);
|
||||
if (i === -1) selectedIds.value.push(id);
|
||||
else selectedIds.value.splice(i, 1);
|
||||
};
|
||||
|
||||
const addSelectedItems = async () => {
|
||||
if (!activeMenuId.value) return;
|
||||
const list = pickers.value[addTab.value] || [];
|
||||
const toAdd = list.filter((p) => selectedIds.value.includes(p.id));
|
||||
for (const obj of toAdd) {
|
||||
const type = addTab.value === 'pages' ? 'page' : addTab.value === 'posts' ? 'post' : 'category';
|
||||
await fetch(api(`/nav-menus/${activeMenuId.value}/items`), {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({ type, objectId: obj.id, title: obj.title, parent: 0, order: items.value.length + 1 }),
|
||||
});
|
||||
}
|
||||
selectedIds.value = [];
|
||||
await loadItems();
|
||||
await loadMenus();
|
||||
};
|
||||
|
||||
const addCustomLink = async () => {
|
||||
if (!activeMenuId.value || !customLink.value.url) return;
|
||||
await fetch(api(`/nav-menus/${activeMenuId.value}/items`), {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({
|
||||
type: 'custom',
|
||||
url: customLink.value.url,
|
||||
title: customLink.value.title || customLink.value.url,
|
||||
parent: 0,
|
||||
order: items.value.length + 1,
|
||||
}),
|
||||
});
|
||||
customLink.value = { title: '', url: 'https://' };
|
||||
await loadItems();
|
||||
await loadMenus();
|
||||
};
|
||||
|
||||
// Structure editing -------------------------------------------------------
|
||||
const removeItem = async (id) => {
|
||||
await fetch(api(`/nav-menus/items/${id}`), { method: 'DELETE', headers: headers() });
|
||||
await loadItems();
|
||||
await loadMenus();
|
||||
};
|
||||
|
||||
const persistOrder = async () => {
|
||||
const payload = orderedItems.value.map((it, idx) => ({ id: it.id, parent: it.parent, order: idx + 1 }));
|
||||
await fetch(api(`/nav-menus/${activeMenuId.value}/reorder`), {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({ items: payload }),
|
||||
});
|
||||
};
|
||||
|
||||
const moveUp = async (item) => {
|
||||
const idx = items.value.findIndex((i) => i.id === item.id);
|
||||
const siblings = items.value.filter((i) => i.parent === item.parent).sort((a, b) => a.order - b.order);
|
||||
const pos = siblings.findIndex((i) => i.id === item.id);
|
||||
if (pos <= 0) return;
|
||||
[siblings[pos - 1].order, siblings[pos].order] = [siblings[pos].order, siblings[pos - 1].order];
|
||||
await persistOrder();
|
||||
await loadItems();
|
||||
};
|
||||
|
||||
const moveDown = async (item) => {
|
||||
const siblings = items.value.filter((i) => i.parent === item.parent).sort((a, b) => a.order - b.order);
|
||||
const pos = siblings.findIndex((i) => i.id === item.id);
|
||||
if (pos === -1 || pos >= siblings.length - 1) return;
|
||||
[siblings[pos + 1].order, siblings[pos].order] = [siblings[pos].order, siblings[pos + 1].order];
|
||||
await persistOrder();
|
||||
await loadItems();
|
||||
};
|
||||
|
||||
const indent = async (item) => {
|
||||
const siblings = items.value.filter((i) => i.parent === item.parent).sort((a, b) => a.order - b.order);
|
||||
const pos = siblings.findIndex((i) => i.id === item.id);
|
||||
if (pos <= 0) return;
|
||||
item.parent = siblings[pos - 1].id;
|
||||
await persistOrder();
|
||||
await loadItems();
|
||||
};
|
||||
|
||||
const outdent = async (item) => {
|
||||
const parentItem = items.value.find((i) => i.id === item.parent);
|
||||
if (!parentItem) return;
|
||||
item.parent = parentItem.parent;
|
||||
await persistOrder();
|
||||
await loadItems();
|
||||
};
|
||||
|
||||
// Locations ---------------------------------------------------------------
|
||||
const assignLocation = async (slug, menuId) => {
|
||||
await fetch(api('/nav-menus/locations'), {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({ location: slug, menuId: menuId || 0 }),
|
||||
});
|
||||
notice.value = 'Location updated.';
|
||||
setTimeout(() => (notice.value = ''), 2000);
|
||||
await loadLocations();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="nav-menus-page">
|
||||
<header class="nav-menus-page__header">
|
||||
<div>
|
||||
<p class="nav-menus-page__eyebrow">Appearance</p>
|
||||
<h1>Menus</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="notice" class="nav-menus-notice nav-menus-notice--success">{{ notice }}</div>
|
||||
<div v-if="error" class="nav-menus-notice nav-menus-notice--error">{{ error }}</div>
|
||||
|
||||
<div class="nav-menus-layout">
|
||||
<!-- Left: menu list + locations -->
|
||||
<aside class="nav-menus-side">
|
||||
<h2>Your Menus</h2>
|
||||
<ul class="nav-menus-list">
|
||||
<li v-for="menu in menus" :key="menu.id">
|
||||
<button
|
||||
type="button"
|
||||
class="nav-menus-list__item"
|
||||
:class="{ 'is-active': menu.id === activeMenuId }"
|
||||
@click="selectMenu(menu.id)"
|
||||
>
|
||||
{{ menu.name }} <span>({{ menu.itemCount }})</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="nav-menus-create">
|
||||
<input v-model="newMenuName" type="text" placeholder="New menu name" @keyup.enter="createMenu" />
|
||||
<button type="button" class="button" :disabled="creatingMenu" @click="createMenu">Create</button>
|
||||
</div>
|
||||
|
||||
<h2>Manage Locations</h2>
|
||||
<div v-for="loc in locations" :key="loc.slug" class="nav-menus-location">
|
||||
<label>{{ loc.description }}</label>
|
||||
<select :value="loc.menuId || ''" @change="assignLocation(loc.slug, $event.target.value ? Number($event.target.value) : 0)">
|
||||
<option value="">— Not assigned —</option>
|
||||
<option v-for="menu in menus" :key="menu.id" :value="menu.id">{{ menu.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<p v-if="!locations.length" class="nav-menus-empty">This theme registered no menu locations.</p>
|
||||
</aside>
|
||||
|
||||
<!-- Middle: add items -->
|
||||
<section class="nav-menus-add">
|
||||
<h2>Add Menu Items</h2>
|
||||
<div class="nav-menus-add__tabs">
|
||||
<button type="button" :class="{ 'is-active': addTab === 'pages' }" @click="addTab = 'pages'">Pages</button>
|
||||
<button type="button" :class="{ 'is-active': addTab === 'posts' }" @click="addTab = 'posts'">Posts</button>
|
||||
<button type="button" :class="{ 'is-active': addTab === 'categories' }" @click="addTab = 'categories'">Categories</button>
|
||||
<button type="button" :class="{ 'is-active': addTab === 'custom' }" @click="addTab = 'custom'">Custom Link</button>
|
||||
</div>
|
||||
|
||||
<div v-if="addTab !== 'custom'" class="nav-menus-add__list">
|
||||
<label v-for="opt in pickers[addTab]" :key="opt.id" class="nav-menus-add__row">
|
||||
<input type="checkbox" :checked="selectedIds.includes(opt.id)" @change="toggleSelected(opt.id)" />
|
||||
<span>{{ opt.title }}</span>
|
||||
</label>
|
||||
<p v-if="!pickers[addTab]?.length" class="nav-menus-empty">Nothing to show.</p>
|
||||
<button v-if="pickers[addTab]?.length" type="button" class="button-primary" :disabled="!activeMenuId" @click="addSelectedItems">
|
||||
Add to Menu
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="nav-menus-add__custom">
|
||||
<input v-model="customLink.title" type="text" placeholder="Link Text" />
|
||||
<input v-model="customLink.url" type="text" placeholder="https://example.com" />
|
||||
<button type="button" class="button-primary" :disabled="!activeMenuId" @click="addCustomLink">Add to Menu</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Right: current structure -->
|
||||
<section class="nav-menus-structure">
|
||||
<div class="nav-menus-structure__head">
|
||||
<h2>{{ activeMenu?.name || 'Select a menu' }}</h2>
|
||||
<button v-if="activeMenu" type="button" class="icon-button" title="Delete menu" @click="deleteMenu">
|
||||
<span class="dashicons dashicons-trash" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="loading" class="nav-menus-empty">Loading…</p>
|
||||
<p v-else-if="!orderedItems.length" class="nav-menus-empty">No items in this menu yet — add some from the left.</p>
|
||||
|
||||
<div
|
||||
v-for="item in orderedItems"
|
||||
:key="item.id"
|
||||
class="nav-menus-item"
|
||||
:style="{ marginLeft: `${item.depth * 24}px` }"
|
||||
>
|
||||
<span class="nav-menus-item__title">{{ item.title }}</span>
|
||||
<span class="nav-menus-item__actions">
|
||||
<button type="button" title="Move up" @click="moveUp(item)"><span class="dashicons dashicons-arrow-up-alt2" /></button>
|
||||
<button type="button" title="Move down" @click="moveDown(item)"><span class="dashicons dashicons-arrow-down-alt2" /></button>
|
||||
<button type="button" title="Indent (make sub-item)" @click="indent(item)"><span class="dashicons dashicons-arrow-right-alt2" /></button>
|
||||
<button type="button" title="Outdent" :disabled="!item.parent" @click="outdent(item)"><span class="dashicons dashicons-arrow-left-alt2" /></button>
|
||||
<button type="button" title="Remove" @click="removeItem(item.id)"><span class="dashicons dashicons-no-alt" /></button>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.nav-menus-page { max-width: 1200px; margin: 0 auto; }
|
||||
.nav-menus-page__header { margin-bottom: 16px; }
|
||||
.nav-menus-page__eyebrow {
|
||||
margin: 0 0 4px; font-size: 0.75rem; text-transform: uppercase;
|
||||
letter-spacing: 0.06em; color: var(--aurora-text-muted);
|
||||
}
|
||||
.nav-menus-page h1 { margin: 0; font-size: 1.6rem; font-weight: 700; color: var(--aurora-text); }
|
||||
|
||||
.nav-menus-notice { padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; font-size: 0.875rem; }
|
||||
.nav-menus-notice--success { background: rgba(34, 197, 94, 0.12); color: #22c55e; }
|
||||
.nav-menus-notice--error { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
|
||||
|
||||
.nav-menus-layout { display: grid; grid-template-columns: 260px 300px 1fr; gap: 20px; align-items: start; }
|
||||
.nav-menus-side, .nav-menus-add, .nav-menus-structure {
|
||||
background: var(--aurora-bg-subtle); border: 1px solid var(--aurora-border);
|
||||
border-radius: 14px; padding: 18px;
|
||||
}
|
||||
.nav-menus-side h2, .nav-menus-add h2, .nav-menus-structure h2 {
|
||||
font-size: 0.9375rem; margin: 0 0 10px; color: var(--aurora-text);
|
||||
}
|
||||
.nav-menus-list { list-style: none; margin: 0 0 14px; padding: 0; }
|
||||
.nav-menus-list__item {
|
||||
width: 100%; text-align: left; padding: 8px 10px; border-radius: 8px;
|
||||
border: none; background: none; color: var(--aurora-text-muted); cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.nav-menus-list__item span { color: var(--aurora-text-muted); }
|
||||
.nav-menus-list__item:hover { background: var(--aurora-bg); }
|
||||
.nav-menus-list__item.is-active { background: var(--aurora-accent-soft); color: var(--aurora-accent); }
|
||||
|
||||
.nav-menus-create { display: flex; gap: 6px; margin-bottom: 20px; }
|
||||
.nav-menus-create input {
|
||||
flex: 1; padding: 7px 10px; border-radius: 7px; border: 1px solid var(--aurora-border);
|
||||
background: var(--aurora-bg); color: var(--aurora-text); font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.nav-menus-location { display: flex; flex-direction: column; gap: 4px; margin-bottom: 10px; }
|
||||
.nav-menus-location label { font-size: 0.8125rem; color: var(--aurora-text-muted); }
|
||||
.nav-menus-location select,
|
||||
.nav-menus-add__custom input {
|
||||
padding: 7px 10px; border-radius: 7px; border: 1px solid var(--aurora-border);
|
||||
background: var(--aurora-bg); color: var(--aurora-text); font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.nav-menus-add__tabs { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
|
||||
.nav-menus-add__tabs button {
|
||||
padding: 6px 10px; border-radius: 7px; border: 1px solid var(--aurora-border);
|
||||
background: var(--aurora-bg); color: var(--aurora-text-muted); font-size: 0.75rem; cursor: pointer;
|
||||
}
|
||||
.nav-menus-add__tabs button.is-active { background: var(--aurora-accent-soft); color: var(--aurora-accent); border-color: transparent; }
|
||||
|
||||
.nav-menus-add__list { max-height: 320px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; }
|
||||
.nav-menus-add__row {
|
||||
display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 6px;
|
||||
font-size: 0.8125rem; color: var(--aurora-text);
|
||||
}
|
||||
.nav-menus-add__row:hover { background: var(--aurora-bg); }
|
||||
.nav-menus-add__list .button-primary { margin-top: 10px; width: 100%; }
|
||||
|
||||
.nav-menus-add__custom { display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
.nav-menus-structure__head { display: flex; align-items: center; justify-content: space-between; }
|
||||
.nav-menus-item {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 8px 12px; border-radius: 8px; margin-bottom: 4px;
|
||||
background: var(--aurora-bg); border: 1px solid var(--aurora-border);
|
||||
font-size: 0.8125rem; color: var(--aurora-text);
|
||||
}
|
||||
.nav-menus-item__actions { display: flex; gap: 2px; }
|
||||
.nav-menus-item__actions button {
|
||||
border: none; background: none; color: var(--aurora-text-muted); cursor: pointer;
|
||||
width: 26px; height: 26px; border-radius: 6px; display: inline-flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.nav-menus-item__actions button:hover { background: var(--aurora-bg-subtle); color: var(--aurora-text); }
|
||||
.nav-menus-item__actions button:disabled { opacity: 0.35; cursor: default; }
|
||||
|
||||
.nav-menus-empty { color: var(--aurora-text-muted); font-size: 0.8125rem; }
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.nav-menus-layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,205 @@
|
||||
<script setup>
|
||||
import { reactive, ref, onMounted } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
serverData: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const headers = () => ({ 'Content-Type': 'application/json', 'X-WP-Nonce': props.serverData.restNonce });
|
||||
const meUrl = `${props.serverData.restUrl}wp/v2/users/me?context=edit`;
|
||||
|
||||
const form = reactive({
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
nickname: '',
|
||||
email: '',
|
||||
url: '',
|
||||
description: '',
|
||||
});
|
||||
|
||||
const loading = ref(true);
|
||||
const saving = ref(false);
|
||||
const notice = ref('');
|
||||
const error = ref('');
|
||||
|
||||
const load = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await fetch(meUrl, { headers: headers() });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.message || 'Could not load your profile.');
|
||||
form.first_name = data.first_name || '';
|
||||
form.last_name = data.last_name || '';
|
||||
form.nickname = data.nickname || '';
|
||||
form.email = data.email || '';
|
||||
form.url = data.url || '';
|
||||
form.description = data.description || '';
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(load);
|
||||
|
||||
const save = async () => {
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
try {
|
||||
const res = await fetch(meUrl, {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.message || 'Could not save your profile.');
|
||||
notice.value = 'Profile updated.';
|
||||
setTimeout(() => (notice.value = ''), 2500);
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Password ----------------------------------------------------------------
|
||||
const newPassword = ref('');
|
||||
const changingPassword = ref(false);
|
||||
const passwordNotice = ref('');
|
||||
const passwordError = ref('');
|
||||
|
||||
const generatePassword = () => {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%';
|
||||
newPassword.value = Array.from({ length: 16 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
|
||||
};
|
||||
|
||||
const changePassword = async () => {
|
||||
passwordError.value = '';
|
||||
passwordNotice.value = '';
|
||||
if (!newPassword.value) return;
|
||||
|
||||
changingPassword.value = true;
|
||||
try {
|
||||
const res = await fetch(`${props.serverData.restUrl}aurora-admin/v1/profile/password`, {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({ password: newPassword.value }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.message || 'Could not change your password.');
|
||||
passwordNotice.value = 'Password updated.';
|
||||
newPassword.value = '';
|
||||
setTimeout(() => (passwordNotice.value = ''), 2500);
|
||||
} catch (e) {
|
||||
passwordError.value = e.message;
|
||||
} finally {
|
||||
changingPassword.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="profile-page">
|
||||
<header class="profile-page__header">
|
||||
<div>
|
||||
<p class="profile-page__eyebrow">Accounts</p>
|
||||
<h1>Your Profile</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="notice" class="profile-notice profile-notice--success">{{ notice }}</div>
|
||||
<div v-if="error" class="profile-notice profile-notice--error">{{ error }}</div>
|
||||
|
||||
<p v-if="loading" class="profile-empty">Loading…</p>
|
||||
|
||||
<form v-else class="profile-form" @submit.prevent="save">
|
||||
<label class="profile-field">
|
||||
<span>First Name</span>
|
||||
<input v-model="form.first_name" type="text" />
|
||||
</label>
|
||||
<label class="profile-field">
|
||||
<span>Last Name</span>
|
||||
<input v-model="form.last_name" type="text" />
|
||||
</label>
|
||||
<label class="profile-field">
|
||||
<span>Nickname</span>
|
||||
<input v-model="form.nickname" type="text" />
|
||||
</label>
|
||||
<label class="profile-field">
|
||||
<span>Email</span>
|
||||
<input v-model="form.email" type="email" />
|
||||
</label>
|
||||
<label class="profile-field">
|
||||
<span>Website</span>
|
||||
<input v-model="form.url" type="text" />
|
||||
</label>
|
||||
<label class="profile-field">
|
||||
<span>Biographical Info</span>
|
||||
<textarea v-model="form.description" rows="4" />
|
||||
</label>
|
||||
|
||||
<div class="profile-form__actions">
|
||||
<button type="submit" class="button-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving…' : 'Update Profile' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section class="profile-password">
|
||||
<h2>Change Password</h2>
|
||||
<div v-if="passwordNotice" class="profile-notice profile-notice--success">{{ passwordNotice }}</div>
|
||||
<div v-if="passwordError" class="profile-notice profile-notice--error">{{ passwordError }}</div>
|
||||
<div class="profile-password__row">
|
||||
<input v-model="newPassword" type="text" placeholder="New password" />
|
||||
<button type="button" class="button" @click="generatePassword">Generate</button>
|
||||
<button type="button" class="button-primary" :disabled="changingPassword || !newPassword" @click="changePassword">
|
||||
{{ changingPassword ? 'Updating…' : 'Change Password' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.profile-page { max-width: 640px; margin: 0 auto; }
|
||||
.profile-page__header { margin-bottom: 16px; }
|
||||
.profile-page__eyebrow {
|
||||
margin: 0 0 4px; font-size: 0.75rem; text-transform: uppercase;
|
||||
letter-spacing: 0.06em; color: var(--aurora-text-muted);
|
||||
}
|
||||
.profile-page h1 { margin: 0; font-size: 1.6rem; font-weight: 700; color: var(--aurora-text); }
|
||||
.profile-empty { color: var(--aurora-text-muted); font-size: 0.8125rem; }
|
||||
|
||||
.profile-notice { padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; font-size: 0.875rem; }
|
||||
.profile-notice--success { background: rgba(34, 197, 94, 0.12); color: #22c55e; }
|
||||
.profile-notice--error { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
|
||||
.profile-notice--info { background: var(--aurora-bg-subtle); color: var(--aurora-text-muted); }
|
||||
.profile-notice--info a { color: var(--aurora-accent); }
|
||||
|
||||
.profile-form, .profile-password {
|
||||
display: flex; flex-direction: column; gap: 18px;
|
||||
background: var(--aurora-bg-subtle);
|
||||
border: 1px solid var(--aurora-border);
|
||||
border-radius: 14px;
|
||||
padding: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.profile-field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.profile-field span { font-size: 0.8125rem; font-weight: 600; color: var(--aurora-text); }
|
||||
.profile-field input,
|
||||
.profile-field textarea {
|
||||
padding: 9px 12px; border-radius: 8px; border: 1px solid var(--aurora-border);
|
||||
background: var(--aurora-bg); color: var(--aurora-text); font-size: 0.875rem;
|
||||
font-family: inherit; resize: vertical;
|
||||
}
|
||||
.profile-form__actions { margin-top: 4px; }
|
||||
|
||||
.profile-password h2 { margin: 0 0 4px; font-size: 0.9375rem; color: var(--aurora-text); }
|
||||
.profile-password__row { display: flex; gap: 8px; }
|
||||
.profile-password__row input {
|
||||
flex: 1; padding: 9px 12px; border-radius: 8px; border: 1px solid var(--aurora-border);
|
||||
background: var(--aurora-bg); color: var(--aurora-text); font-size: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, reactive, computed, onMounted } from 'vue';
|
||||
import AppListShell from '../_shared/AppListShell.vue';
|
||||
import AppListItem from '../_shared/AppListItem.vue';
|
||||
import { describeCapability } from './capabilityDescriptions.js';
|
||||
|
||||
const props = defineProps({
|
||||
serverData: { type: Object, required: true },
|
||||
@@ -145,10 +146,16 @@ const remove = async () => {
|
||||
<div class="role-editor__section">
|
||||
<h3>Capabilities</h3>
|
||||
<div class="role-editor__caps">
|
||||
<label v-for="cap in allCapabilities" :key="cap" class="role-editor__cap">
|
||||
<input type="checkbox" :checked="draft.capabilities.includes(cap)" @change="toggleCap(cap)" />
|
||||
{{ cap }}
|
||||
</label>
|
||||
<div v-for="cap in allCapabilities" :key="cap" class="role-editor__cap">
|
||||
<label>
|
||||
<input type="checkbox" :checked="draft.capabilities.includes(cap)" @change="toggleCap(cap)" />
|
||||
{{ cap }}
|
||||
</label>
|
||||
<span class="role-editor__cap-info" tabindex="0">
|
||||
<span class="dashicons dashicons-info-outline" />
|
||||
<span class="role-editor__tooltip" role="tooltip">{{ describeCapability(cap) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -186,10 +193,57 @@ const remove = async () => {
|
||||
color: var(--aurora-text-muted); margin: 0 0 8px;
|
||||
}
|
||||
.role-editor__caps {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 8px 16px; max-height: 380px; overflow-y: auto;
|
||||
/* Tooltips (position: absolute, below) need room to render outside the
|
||||
grid cell without getting clipped by the scroll container. */
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.role-editor__cap {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 6px;
|
||||
font-size: 0.8125rem; color: var(--aurora-text);
|
||||
}
|
||||
.role-editor__cap label { display: flex; align-items: center; gap: 6px; min-width: 0; }
|
||||
.role-editor__cap label span,
|
||||
.role-editor__cap label {
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
|
||||
.role-editor__cap-info {
|
||||
position: relative;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 18px; height: 18px; flex-shrink: 0;
|
||||
color: var(--aurora-text-muted); cursor: help;
|
||||
}
|
||||
.role-editor__cap-info .dashicons { font-size: 15px; width: 15px; height: 15px; }
|
||||
.role-editor__cap-info:hover { color: var(--aurora-accent); }
|
||||
|
||||
.role-editor__tooltip {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
right: -8px;
|
||||
z-index: 20;
|
||||
width: 260px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--aurora-frame-border);
|
||||
background: var(--aurora-frame-bg);
|
||||
color: var(--aurora-text);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
white-space: normal;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
.role-editor__cap-info:hover .role-editor__tooltip,
|
||||
.role-editor__cap-info:focus .role-editor__tooltip {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
.role-editor__cap { display: flex; align-items: center; gap: 6px; font-size: 0.8125rem; color: var(--aurora-text); }
|
||||
.role-editor__actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
.role-editor__actions button {
|
||||
border: none; border-radius: 8px; padding: 9px 18px; font-size: 0.875rem; cursor: pointer;
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Human-readable descriptions for WordPress's capability system, shown as
|
||||
* an info tooltip next to each capability checkbox in Role Editor.
|
||||
*
|
||||
* Two groups: the ~60 named capabilities WordPress core actually checks
|
||||
* (edit_posts, manage_options, etc.), and the legacy level_0…level_10
|
||||
* "user level" system from WordPress 1.x/early 2.x. Levels predate named
|
||||
* capabilities and are deprecated, but WordPress still creates them on
|
||||
* every default role for backward compatibility with old themes/plugins
|
||||
* that call current_user_can('level_X') or check $user->user_level
|
||||
* directly instead of a named capability — core itself hasn't checked a
|
||||
* level anywhere for years. They don't map to anything meaningful beyond
|
||||
* that compatibility shim, which is exactly why they look unexplained.
|
||||
*/
|
||||
const LEVEL_MATCH = /^level_(\d{1,2})$/;
|
||||
|
||||
const CORE_CAPABILITIES = {
|
||||
read: 'The baseline capability every logged-in user needs just to access wp-admin at all.',
|
||||
edit_posts: 'Edit their own posts.',
|
||||
edit_others_posts: "Edit posts authored by other users.",
|
||||
edit_private_posts: 'Edit private posts (not publicly visible).',
|
||||
edit_published_posts: 'Edit posts that have already been published.',
|
||||
publish_posts: 'Publish posts, rather than only saving them as drafts.',
|
||||
delete_posts: 'Delete their own posts.',
|
||||
delete_others_posts: "Delete posts authored by other users.",
|
||||
delete_private_posts: 'Delete private posts.',
|
||||
delete_published_posts: 'Delete posts that have already been published.',
|
||||
read_private_posts: "View private posts that aren't publicly visible.",
|
||||
|
||||
edit_pages: 'Edit their own pages.',
|
||||
edit_others_pages: "Edit pages authored by other users.",
|
||||
edit_private_pages: 'Edit private pages.',
|
||||
edit_published_pages: 'Edit pages that have already been published.',
|
||||
publish_pages: 'Publish pages, rather than only saving them as drafts.',
|
||||
delete_pages: 'Delete their own pages.',
|
||||
delete_others_pages: "Delete pages authored by other users.",
|
||||
delete_private_pages: 'Delete private pages.',
|
||||
delete_published_pages: 'Delete pages that have already been published.',
|
||||
read_private_pages: "View private pages that aren't publicly visible.",
|
||||
|
||||
moderate_comments: 'Approve, edit, and delete comments awaiting moderation.',
|
||||
manage_categories: 'Create, edit, and delete post categories and tags.',
|
||||
manage_links: 'Manage the legacy Links (blogroll) feature.',
|
||||
upload_files: 'Upload files to the Media Library.',
|
||||
|
||||
list_users: 'View the list of registered users.',
|
||||
create_users: 'Create new user accounts.',
|
||||
edit_users: "Edit other users' profiles and roles.",
|
||||
delete_users: 'Delete other user accounts.',
|
||||
promote_users: "Change other users' roles.",
|
||||
remove_users: 'Remove other users from a site (multisite: from this one site, not delete the account entirely).',
|
||||
|
||||
manage_options: "Change site-wide settings — one of the broadest capabilities short of being a full administrator.",
|
||||
edit_theme_options: 'Change theme settings such as menus, widgets, and the Customizer.',
|
||||
switch_themes: 'Activate a different installed theme.',
|
||||
install_themes: 'Install new themes from the WordPress theme directory or by file upload.',
|
||||
update_themes: 'Update installed themes to a newer version.',
|
||||
delete_themes: 'Delete theme files from the site.',
|
||||
edit_themes: 'Edit theme source code directly from the admin — high risk, equivalent to direct server file access.',
|
||||
|
||||
activate_plugins: 'Activate and deactivate plugins.',
|
||||
install_plugins: 'Install new plugins from the WordPress plugin directory or by file upload.',
|
||||
update_plugins: 'Update installed plugins to a newer version.',
|
||||
delete_plugins: 'Delete plugin files from the site.',
|
||||
edit_plugins: 'Edit plugin source code directly from the admin — high risk, equivalent to direct server file access.',
|
||||
|
||||
edit_files: 'Edit plugin and theme files directly from the admin (Theme/Plugin Editor) — high risk, equivalent to direct server file access.',
|
||||
edit_dashboard: "Customize the admin dashboard's own widgets.",
|
||||
update_core: 'Update WordPress core to a newer version.',
|
||||
import: 'Import content into the site from an external source (Tools › Import).',
|
||||
export: 'Export site content to a file (Tools › Export).',
|
||||
customize: 'Access the Theme Customizer.',
|
||||
|
||||
unfiltered_html: 'Post raw, unfiltered HTML and JavaScript in content — a real cross-site-scripting (XSS) risk if granted to anyone other than a fully trusted administrator.',
|
||||
unfiltered_upload: "Upload any file type without WordPress's normal file-type restrictions — a real security risk; only Administrators have this by default, and only on single-site installs.",
|
||||
|
||||
manage_network: '(Multisite) Access the network admin dashboard.',
|
||||
manage_sites: '(Multisite) Create, edit, and delete individual sites on the network.',
|
||||
manage_network_users: '(Multisite) Manage user accounts network-wide.',
|
||||
manage_network_plugins: '(Multisite) Manage plugins network-wide.',
|
||||
manage_network_themes: '(Multisite) Manage themes network-wide.',
|
||||
manage_network_options: '(Multisite) Change network-wide settings.',
|
||||
delete_site: '(Multisite) Delete this entire site.',
|
||||
create_sites: '(Multisite) Create new sites on the network.',
|
||||
};
|
||||
|
||||
export const describeCapability = (cap) => {
|
||||
const levelMatch = cap.match(LEVEL_MATCH);
|
||||
if (levelMatch) {
|
||||
const n = Number(levelMatch[1]);
|
||||
return (
|
||||
`Legacy WordPress "user level" ${n} (0–10 scale, 10 highest) from the old pre-2.0 permissions ` +
|
||||
"system. Deprecated in favor of the named capabilities below, but WordPress still assigns the " +
|
||||
"full set of levels a role qualifies for, purely so old themes/plugins that check " +
|
||||
`current_user_can('level_${n}') or a user's numeric level directly still work. Core itself hasn't ` +
|
||||
'checked a level in years — safe to leave alone; removing it only affects compatibility with very old code.'
|
||||
);
|
||||
}
|
||||
|
||||
return CORE_CAPABILITIES[cap] || 'Custom capability — not part of WordPress core. Likely added by a plugin or theme.';
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch } from 'vue';
|
||||
import { ref, reactive, computed, watch, nextTick } from 'vue';
|
||||
import { settingsCategories } from './settingsConfig.js';
|
||||
import SettingsField from './components/SettingsField.vue';
|
||||
import { applyThemePalette, applyFontFamily } from './applyLiveTheme.js';
|
||||
@@ -8,9 +8,27 @@ const props = defineProps({
|
||||
serverData: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const visibleCategories = settingsCategories;
|
||||
|
||||
// Shared REST fetcher for field types that need their own API calls beyond
|
||||
// the generic wp/v2/settings save flow (currently just 'companion-plugin').
|
||||
const api = (path, options = {}) =>
|
||||
fetch(`${props.serverData.restUrl}aurora-admin/v1${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'X-WP-Nonce': props.serverData.restNonce,
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {}),
|
||||
},
|
||||
}).then(async (r) => {
|
||||
const body = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(body.message || 'Request failed');
|
||||
return body;
|
||||
});
|
||||
|
||||
// Working copy of the settings, seeded from the server + field defaults.
|
||||
const settings = reactive({ ...(props.serverData.settings || {}) });
|
||||
settingsCategories.forEach((cat) =>
|
||||
visibleCategories.forEach((cat) =>
|
||||
cat.fields.forEach((f) => {
|
||||
if (settings[f.key] === undefined && f.default !== undefined) {
|
||||
settings[f.key] = f.default;
|
||||
@@ -21,25 +39,37 @@ settingsCategories.forEach((cat) =>
|
||||
// Active category, synced with the URL ?category= param.
|
||||
const initialCat = new URLSearchParams(window.location.search).get('category');
|
||||
const activeId = ref(
|
||||
settingsCategories.some((c) => c.id === initialCat) ? initialCat : settingsCategories[0].id
|
||||
visibleCategories.some((c) => c.id === initialCat) ? initialCat : visibleCategories[0].id
|
||||
);
|
||||
const activeCategory = computed(
|
||||
() => settingsCategories.find((c) => c.id === activeId.value) || settingsCategories[0]
|
||||
() => visibleCategories.find((c) => c.id === activeId.value) || visibleCategories[0]
|
||||
);
|
||||
|
||||
// The content panel scrolls independently of the nav (see .settings__content
|
||||
// below) so picking a category from a scrolled-down spot in the nav doesn't
|
||||
// require scrolling the whole page back up to see it. But the panel is one
|
||||
// persistent DOM node across category switches — its scrollTop doesn't reset
|
||||
// on its own unless the new content happens to be shorter, so without this
|
||||
// a long category could inherit a scrolled-down position from the previous
|
||||
// one and land on the same "content is off-screen" problem this was meant
|
||||
// to fix.
|
||||
const contentEl = ref(null);
|
||||
const selectCategory = (id) => {
|
||||
activeId.value = id;
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('category', id);
|
||||
window.history.replaceState({}, '', url);
|
||||
nextTick(() => {
|
||||
if (contentEl.value) contentEl.value.scrollTop = 0;
|
||||
});
|
||||
};
|
||||
|
||||
// Search filter over the nav.
|
||||
const query = ref('');
|
||||
const filteredCategories = computed(() => {
|
||||
const q = query.value.trim().toLowerCase();
|
||||
if (!q) return settingsCategories;
|
||||
return settingsCategories.filter(
|
||||
if (!q) return visibleCategories;
|
||||
return visibleCategories.filter(
|
||||
(c) =>
|
||||
c.title.toLowerCase().includes(q) || c.subtitle.toLowerCase().includes(q)
|
||||
);
|
||||
@@ -161,7 +191,7 @@ const importSettings = (e) => {
|
||||
</aside>
|
||||
|
||||
<!-- Right content panel -->
|
||||
<section class="settings__content">
|
||||
<section ref="contentEl" class="settings__content">
|
||||
<header class="settings__content-head">
|
||||
<div>
|
||||
<h2 class="settings__content-title">{{ activeCategory.title }}</h2>
|
||||
@@ -181,7 +211,8 @@ const importSettings = (e) => {
|
||||
:key="f.key"
|
||||
:field="f"
|
||||
:options="optionsFor(f)"
|
||||
:license-status="f.key === 'license_key' ? serverData.licenseStatus : null"
|
||||
:disabled="!!f.dependsOn && !settings[f.dependsOn]"
|
||||
:api="api"
|
||||
v-model="settings[f.key]"
|
||||
/>
|
||||
</div>
|
||||
@@ -194,20 +225,31 @@ const importSettings = (e) => {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
min-height: calc(100vh - var(--aurora-toolbar-h) - 60px);
|
||||
/* height, not min-height — the outer #wpbody-content card (shell-
|
||||
frame.css) already caps itself to the viewport and scrolls as a
|
||||
whole. Filling it exactly (rather than growing taller than it) is
|
||||
what lets .settings__nav and .settings__content become their own
|
||||
independent scroll regions below instead of both scrolling together
|
||||
as one long page. */
|
||||
height: calc(100vh - var(--aurora-toolbar-h) - 60px);
|
||||
margin: -32px; /* cancel the frame padding */
|
||||
padding: 22px; /* inset the two boxes; the frame's right gutter adds the rest */
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
/* No background here — html/body already paint the page gradient
|
||||
(shell-frame.css); repainting the same var(--aurora-app-bg) here was
|
||||
just a redundant duplicate layer on top of it. */
|
||||
}
|
||||
|
||||
/* Left nav — no background/border of its own; the page gradient shows
|
||||
straight through so it reads as one surface instead of a boxed panel. */
|
||||
straight through so it reads as one surface instead of a boxed panel.
|
||||
Scrolls independently of the content panel (own height + overflow)
|
||||
so its scroll position is preserved when you switch categories. */
|
||||
.settings__nav {
|
||||
width: 340px;
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
border-radius: 16px;
|
||||
padding: 24px 20px;
|
||||
box-sizing: border-box;
|
||||
@@ -261,11 +303,19 @@ const importSettings = (e) => {
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Right content — no background/border of its own, same reasoning. */
|
||||
/* Right content — no background/border of its own, same reasoning.
|
||||
Own height + overflow (independent of .settings__nav's) is the actual
|
||||
fix: clicking a category always shows its content from the top,
|
||||
whatever the nav's own scroll position is — resetScrollTop on
|
||||
category switch (see selectCategory() above) handles the case where
|
||||
this panel itself was already scrolled down. */
|
||||
.settings__content {
|
||||
flex: 1; min-width: 0;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
border-radius: 16px;
|
||||
padding: 28px 32px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.settings__content-head {
|
||||
display: flex; align-items: flex-start; justify-content: space-between;
|
||||
@@ -287,7 +337,11 @@ const importSettings = (e) => {
|
||||
.settings__fields > :last-child { border-bottom: none; }
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.settings { flex-direction: column; }
|
||||
.settings__nav { width: auto; }
|
||||
/* Independent-scroll panes only make sense side-by-side — stacked on a
|
||||
narrow screen, revert to one natural-height column so the page
|
||||
scrolls as a whole the normal way, same as before this change. */
|
||||
.settings { flex-direction: column; height: auto; overflow: visible; }
|
||||
.settings__nav { width: auto; height: auto; overflow: visible; }
|
||||
.settings__content { height: auto; overflow: visible; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import Toggle from '@/components/Toggle.vue';
|
||||
import { themePresets, buildPalette } from '@/pages/settings/themePresets.js';
|
||||
import { googleFonts } from '@/pages/settings/googleFonts.js';
|
||||
@@ -11,24 +11,15 @@ const props = defineProps({
|
||||
modelValue: { default: undefined },
|
||||
// Options for multiselect fields (resolved by the page from optionsSource).
|
||||
options: { type: Array, default: () => [] },
|
||||
// License::status()'s cached result, only passed for the license_key field.
|
||||
licenseStatus: { type: Object, default: null },
|
||||
// True when this field's `dependsOn` key is falsy — greys out the
|
||||
// control without hiding it, so the relationship stays visible.
|
||||
disabled: { type: Boolean, default: false },
|
||||
// Shared REST fetcher, only used by field types that need their own API
|
||||
// calls beyond the generic wp/v2/settings save flow (companion-plugin).
|
||||
api: { type: Function, default: null },
|
||||
});
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const licenseStatusText = computed(() => {
|
||||
const s = props.licenseStatus;
|
||||
if (!s || s.status === 'none') return null;
|
||||
if (s.status === 'active') {
|
||||
const plan = s.plan ? ` (${s.plan} plan)` : '';
|
||||
return { tone: 'ok', text: `License active${plan}${s.expires_at ? ` — renews/expires ${s.expires_at}` : ''}` };
|
||||
}
|
||||
if (s.status === 'unreachable') {
|
||||
return { tone: 'warn', text: s.message || 'Could not reach the license server — will retry automatically.' };
|
||||
}
|
||||
return { tone: 'error', text: s.message || 'License invalid.' };
|
||||
});
|
||||
|
||||
const val = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
@@ -54,21 +45,6 @@ const toggleValue = (value) => {
|
||||
emit('update:modelValue', next);
|
||||
};
|
||||
|
||||
// Remote sites helpers -------------------------------------------------------
|
||||
const sites = computed(() => (Array.isArray(props.modelValue) ? props.modelValue : []));
|
||||
const newSite = reactive({ url: '', username: '', app_password: '' });
|
||||
const addSite = () => {
|
||||
if (!newSite.url.trim()) return;
|
||||
emit('update:modelValue', [
|
||||
...sites.value,
|
||||
{ url: newSite.url.trim(), username: newSite.username.trim(), app_password: newSite.app_password },
|
||||
]);
|
||||
newSite.url = '';
|
||||
newSite.username = '';
|
||||
newSite.app_password = '';
|
||||
};
|
||||
const removeSite = (i) => emit('update:modelValue', sites.value.filter((_, idx) => idx !== i));
|
||||
|
||||
// Media picker (image fields) ---------------------------------------------
|
||||
const pickImage = () => {
|
||||
const wp = window.wp;
|
||||
@@ -103,16 +79,47 @@ const openFontList = () => {
|
||||
fontQuery.value = props.modelValue || '';
|
||||
fontOpen.value = true;
|
||||
};
|
||||
|
||||
// Companion plugin (Aurora File Manager) install card -----------------------
|
||||
const companionStatus = ref(null); // { installed, active } | null while loading
|
||||
const companionBusy = ref(false);
|
||||
const companionError = ref('');
|
||||
|
||||
const loadCompanionStatus = async () => {
|
||||
if (props.field.type !== 'companion-plugin' || !props.api) return;
|
||||
try {
|
||||
companionStatus.value = await props.api(props.field.statusEndpoint);
|
||||
} catch {
|
||||
companionStatus.value = { installed: false, active: false };
|
||||
}
|
||||
};
|
||||
if (props.field.type === 'companion-plugin') {
|
||||
loadCompanionStatus();
|
||||
}
|
||||
|
||||
const installCompanion = async () => {
|
||||
if (!props.api) return;
|
||||
companionBusy.value = true;
|
||||
companionError.value = '';
|
||||
try {
|
||||
const result = await props.api(props.field.installEndpoint, { method: 'POST' });
|
||||
if (!result.success) {
|
||||
companionError.value = result.message || 'Installation failed.';
|
||||
}
|
||||
await loadCompanionStatus();
|
||||
} catch (e) {
|
||||
companionError.value = e.message || 'Installation failed.';
|
||||
} finally {
|
||||
companionBusy.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="field" :id="field.key === 'license_key' ? 'license' : null">
|
||||
<div class="field">
|
||||
<div class="field__info">
|
||||
<div class="field__label">{{ field.label }}</div>
|
||||
<p v-if="field.description" class="field__desc">{{ field.description }}</p>
|
||||
<p v-if="licenseStatusText" class="field__license-status" :class="`field__license-status--${licenseStatusText.tone}`">
|
||||
{{ licenseStatusText.text }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field__control">
|
||||
@@ -122,14 +129,39 @@ const openFontList = () => {
|
||||
type="text"
|
||||
class="field__input"
|
||||
:placeholder="field.placeholder || ''"
|
||||
:readonly="!!field.readonly"
|
||||
:value="val || ''"
|
||||
@input="val = $event.target.value"
|
||||
/>
|
||||
|
||||
<!-- Textarea (multi-line plain text — moderation/blocklist fields) -->
|
||||
<textarea
|
||||
v-else-if="field.type === 'textarea'"
|
||||
class="field__input field__textarea"
|
||||
:placeholder="field.placeholder || ''"
|
||||
:rows="field.rows || 6"
|
||||
:value="val || ''"
|
||||
@input="val = $event.target.value"
|
||||
></textarea>
|
||||
|
||||
<!-- Select (plain dropdown, distinct from the multiselect checkbox list below) -->
|
||||
<select
|
||||
v-else-if="field.type === 'select'"
|
||||
class="field__input"
|
||||
:value="val ?? ''"
|
||||
:disabled="disabled"
|
||||
@change="val = $event.target.value"
|
||||
>
|
||||
<option v-for="opt in (field.options || options)" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<!-- Toggle -->
|
||||
<Toggle
|
||||
v-else-if="field.type === 'toggle'"
|
||||
:model-value="!!val"
|
||||
:disabled="disabled"
|
||||
@update:model-value="val = $event"
|
||||
/>
|
||||
|
||||
@@ -186,28 +218,6 @@ const openFontList = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Remote sites -->
|
||||
<div v-else-if="field.type === 'remote-sites'" class="field__sites">
|
||||
<div class="field__sites-add">
|
||||
<input v-model="newSite.url" type="text" class="field__input" placeholder="Site URL (e.g., https://example.com)" />
|
||||
<div class="field__sites-row">
|
||||
<input v-model="newSite.username" type="text" class="field__input" placeholder="Username" />
|
||||
<input v-model="newSite.app_password" type="password" class="field__input" placeholder="Application Password" />
|
||||
<button type="button" class="field__newitem" @click="addSite">Add Site</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field__sites-list">
|
||||
<div v-if="!sites.length" class="field__sites-empty">No remote sites configured yet</div>
|
||||
<div v-for="(site, i) in sites" :key="i" class="field__sites-item">
|
||||
<span class="field__sites-url">{{ site.url }}</span>
|
||||
<span class="field__sites-user">{{ site.username }}</span>
|
||||
<button type="button" class="field__repeater-remove" @click="removeSite(i)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="field__sites-help">
|
||||
Application passwords can be created in WordPress under Users > Profile > Application Passwords
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Theme picker (10 fixed color presets, whole-UI preview) -->
|
||||
<div v-else-if="field.type === 'theme-picker'" class="field__theme">
|
||||
@@ -262,6 +272,31 @@ const openFontList = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Companion plugin install card -->
|
||||
<div v-else-if="field.type === 'companion-plugin'" class="field__companion">
|
||||
<div v-if="!companionStatus" class="field__companion-status">Checking…</div>
|
||||
<template v-else>
|
||||
<a
|
||||
v-if="companionStatus.active"
|
||||
class="field__companion-manage"
|
||||
:href="field.manageUrl"
|
||||
>
|
||||
Installed & active — open File Manager →
|
||||
</a>
|
||||
<template v-else>
|
||||
<button
|
||||
type="button"
|
||||
class="field__companion-install"
|
||||
:disabled="companionBusy"
|
||||
@click="installCompanion"
|
||||
>
|
||||
{{ companionBusy ? 'Installing…' : (companionStatus.installed ? 'Activate' : 'Install & Activate') }}
|
||||
</button>
|
||||
<p v-if="companionError" class="field__companion-error">{{ companionError }}</p>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Repeater -->
|
||||
<div v-else-if="field.type === 'repeater'" class="field__repeater">
|
||||
<div v-for="(row, i) in rows" :key="i" class="field__repeater-row">
|
||||
@@ -298,10 +333,6 @@ const openFontList = () => {
|
||||
}
|
||||
.field__label { font-size: 0.95rem; font-weight: 600; color: var(--aurora-text); }
|
||||
.field__desc { margin: 6px 0 0; font-size: 0.8125rem; color: var(--aurora-text-muted); line-height: 1.5; }
|
||||
.field__license-status { margin: 8px 0 0; font-size: 0.8125rem; font-weight: 600; }
|
||||
.field__license-status--ok { color: #34d399; }
|
||||
.field__license-status--warn { color: #fbbf24; }
|
||||
.field__license-status--error { color: #f87171; }
|
||||
.field__control { display: flex; justify-content: flex-start; }
|
||||
|
||||
.field__input {
|
||||
@@ -314,6 +345,7 @@ const openFontList = () => {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.field__input:focus { outline: none; border-color: var(--aurora-accent); }
|
||||
.field__textarea { resize: vertical; font-family: inherit; line-height: 1.5; }
|
||||
|
||||
.field__color { display: flex; align-items: center; gap: 10px; }
|
||||
.field__color input[type='color'] {
|
||||
@@ -379,33 +411,6 @@ const openFontList = () => {
|
||||
}
|
||||
.field__seg--active { background: var(--aurora-accent); color: #fff !important; }
|
||||
|
||||
/* Remote sites */
|
||||
.field__sites { width: 100%; }
|
||||
.field__sites-add .field__input { margin-bottom: 8px; }
|
||||
.field__sites-row { display: flex; gap: 8px; }
|
||||
.field__sites-row .field__input { margin-bottom: 0; }
|
||||
.field__sites-row .field__newitem { flex-shrink: 0; white-space: nowrap; }
|
||||
.field__sites-list {
|
||||
margin-top: 12px;
|
||||
border: 1px solid var(--aurora-border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.field__sites-empty {
|
||||
padding: 18px; text-align: center;
|
||||
font-size: 0.8125rem; color: var(--aurora-text-muted);
|
||||
}
|
||||
.field__sites-item {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--aurora-border);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.field__sites-item:last-child { border-bottom: none; }
|
||||
.field__sites-url { color: var(--aurora-text); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.field__sites-user { color: var(--aurora-text-muted); }
|
||||
.field__sites-help { margin: 10px 0 0; font-size: 0.75rem; color: var(--aurora-text-muted); }
|
||||
|
||||
/* Theme picker */
|
||||
.field__theme { display: flex; flex-wrap: wrap; gap: 10px; width: 100%; }
|
||||
.field__theme-card {
|
||||
@@ -449,6 +454,20 @@ const openFontList = () => {
|
||||
.field__font-item:hover { background: var(--aurora-bg-subtle); }
|
||||
.field__font-item--active { color: var(--aurora-accent); }
|
||||
|
||||
/* Companion plugin install card */
|
||||
.field__companion-status { font-size: 0.8125rem; color: var(--aurora-text-muted); }
|
||||
.field__companion-install {
|
||||
border: none; border-radius: 8px; background: var(--aurora-accent); color: #fff;
|
||||
padding: 8px 16px; font-size: 0.8125rem; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.field__companion-install:disabled { opacity: 0.6; cursor: default; }
|
||||
.field__companion-manage {
|
||||
display: inline-block; font-size: 0.8125rem; font-weight: 600;
|
||||
color: var(--aurora-accent); text-decoration: none;
|
||||
}
|
||||
.field__companion-manage:hover { text-decoration: underline; }
|
||||
.field__companion-error { margin: 8px 0 0; font-size: 0.8125rem; color: #e5484d; }
|
||||
|
||||
@media (max-width: 782px) {
|
||||
.field { grid-template-columns: 1fr; gap: 12px; }
|
||||
}
|
||||
|
||||
@@ -8,16 +8,9 @@ export const settingsCategories = [
|
||||
{
|
||||
id: 'general',
|
||||
title: 'General',
|
||||
subtitle: 'Core plugin settings and license management',
|
||||
subtitle: 'Core plugin settings',
|
||||
icon: 'dashicons-admin-settings',
|
||||
fields: [
|
||||
{
|
||||
key: 'license_key',
|
||||
type: 'text',
|
||||
label: 'License key',
|
||||
description: 'Enter your license key to enable automatic updates.',
|
||||
placeholder: 'AA-XXXX-XXXX-XXXX',
|
||||
},
|
||||
{
|
||||
key: 'logo',
|
||||
type: 'image',
|
||||
@@ -31,20 +24,6 @@ export const settingsCategories = [
|
||||
description:
|
||||
"Set an alternative logo to use in dark mode. If one isn't set it will fall back to the standard logo.",
|
||||
},
|
||||
{
|
||||
key: 'remote_sites',
|
||||
type: 'remote-sites',
|
||||
label: 'Remote Sites',
|
||||
description: 'Configure remote WordPress sites for cross-site operations.',
|
||||
},
|
||||
{
|
||||
key: 'remote_site_switcher_capability',
|
||||
type: 'text',
|
||||
label: 'Remote Site Switcher Capability',
|
||||
description:
|
||||
'Pro feature — requires a license. WordPress capability required to see the remote site switcher in the toolbar. Defaults to "manage_options" (admin users). Users with this capability or below will see the switcher.',
|
||||
placeholder: 'manage_options',
|
||||
},
|
||||
{
|
||||
key: 'disable_theme',
|
||||
type: 'multiselect',
|
||||
@@ -111,7 +90,7 @@ export const settingsCategories = [
|
||||
{
|
||||
id: 'whitelabel',
|
||||
title: 'White Label',
|
||||
subtitle: 'Rebrand and customize interface text and icons (Pro feature — requires a license)',
|
||||
subtitle: 'Rebrand and customize interface text and icons',
|
||||
icon: 'dashicons-tag',
|
||||
fields: [
|
||||
{
|
||||
@@ -215,7 +194,7 @@ export const settingsCategories = [
|
||||
{
|
||||
id: 'analytics',
|
||||
title: 'Analytics',
|
||||
subtitle: 'Built-in analytics and tracking settings (Pro feature — requires a license)',
|
||||
subtitle: 'Built-in analytics and tracking settings',
|
||||
icon: 'dashicons-chart-bar',
|
||||
fields: [
|
||||
{
|
||||
@@ -284,4 +263,97 @@ export const settingsCategories = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'image-formats',
|
||||
title: 'Modern Image Formats',
|
||||
subtitle: 'Automatically generate WebP/AVIF versions of uploaded images',
|
||||
icon: 'dashicons-format-image',
|
||||
fields: [
|
||||
{
|
||||
key: 'image_formats_enabled',
|
||||
type: 'toggle',
|
||||
label: 'Enable modern image formats',
|
||||
description:
|
||||
'Automatically generate a WebP or AVIF copy of every size of a newly uploaded JPEG or PNG image, and serve it to browsers that support it. Only affects new uploads — existing library images are unaffected until re-uploaded or regenerated with a tool like Regenerate Thumbnails.',
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
key: 'image_formats_format',
|
||||
type: 'select',
|
||||
label: 'Output format',
|
||||
description:
|
||||
'Automatic prefers AVIF (smaller files, wider quality range) when the server supports it, falling back to WebP. If your server supports neither, generation is silently skipped — ask your host about enabling GD or Imagick WebP/AVIF support.',
|
||||
dependsOn: 'image_formats_enabled',
|
||||
default: 'auto',
|
||||
options: [
|
||||
{ value: 'auto', label: 'Automatic (AVIF, falling back to WebP)' },
|
||||
{ value: 'webp', label: 'WebP only' },
|
||||
{ value: 'avif', label: 'AVIF only' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'image_formats_picture_element',
|
||||
type: 'toggle',
|
||||
label: 'Serve via <picture> element',
|
||||
description:
|
||||
'Rewrite <img> tags (post content and post thumbnails) into a <picture> element offering the modern format with the original JPEG/PNG as a fallback. Turn this off if it conflicts with your theme’s image styling, while still generating the files for other tools to use.',
|
||||
dependsOn: 'image_formats_enabled',
|
||||
default: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'add-ons',
|
||||
title: 'Add-ons',
|
||||
subtitle: 'Standalone companion plugins',
|
||||
icon: 'dashicons-admin-plugins',
|
||||
fields: [
|
||||
{
|
||||
key: 'file_manager_addon',
|
||||
type: 'companion-plugin',
|
||||
label: 'Aurora File Manager',
|
||||
description:
|
||||
'Browse, edit, and manage your site’s files (themes, plugins, uploads, and more) directly from wp-admin. Distributed as a separate plugin rather than bundled here, since WordPress.org’s Plugin Directory policy doesn’t allow this category of functionality in a directory-listed plugin — restrictions or not.',
|
||||
statusEndpoint: '/companion-plugins/file-manager/status',
|
||||
installEndpoint: '/companion-plugins/file-manager/install',
|
||||
manageUrl: 'admin.php?page=aurora-file-manager',
|
||||
},
|
||||
{
|
||||
key: 'database_explorer_addon',
|
||||
type: 'companion-plugin',
|
||||
label: 'Aurora Database Explorer',
|
||||
description:
|
||||
'A Supabase-style database browser inside wp-admin — see every table with row counts and sizes, browse paginated data, and run your own read-only SELECT/SHOW/DESCRIBE/EXPLAIN queries. Distributed as a separate plugin rather than bundled here, since WordPress.org’s Plugin Directory review treats direct database-query tooling the same way it treats file-management functionality.',
|
||||
statusEndpoint: '/companion-plugins/database-explorer/status',
|
||||
installEndpoint: '/companion-plugins/database-explorer/install',
|
||||
manageUrl: 'admin.php?page=aurora-database-explorer',
|
||||
},
|
||||
{
|
||||
key: 'site_backup_addon',
|
||||
type: 'companion-plugin',
|
||||
label: 'Aurora Site Backup',
|
||||
description:
|
||||
'Full site backup (database + files) and restore-in-place, on demand or on a recurring schedule, stored locally or on your own S3-compatible or Google Drive remote storage. Distributed as a separate plugin rather than bundled here, to keep Aurora Admin’s own footprint away from arbitrary file-write functionality.',
|
||||
statusEndpoint: '/companion-plugins/site-backup/status',
|
||||
installEndpoint: '/companion-plugins/site-backup/install',
|
||||
manageUrl: 'admin.php?page=aurora-site-backup',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'admin-takeover',
|
||||
title: 'Admin Takeover',
|
||||
subtitle: 'Replace the remaining native WordPress admin screens with Aurora',
|
||||
icon: 'dashicons-shield',
|
||||
fields: [
|
||||
{
|
||||
key: 'full_admin_takeover',
|
||||
type: 'toggle',
|
||||
label: 'Full admin takeover',
|
||||
description:
|
||||
'Replace Media Upload, Add User, Profile, Appearance, Tools, and native Settings screens with Aurora equivalents. Off by default — these cover more ground than Posts/Pages/Media/Users/Comments, so it’s opt-in rather than on by default.',
|
||||
default: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
serverData: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const api = (path) => `${props.serverData.restUrl}aurora-admin/v1${path}`;
|
||||
const headers = () => ({ 'Content-Type': 'application/json', 'X-WP-Nonce': props.serverData.restNonce });
|
||||
|
||||
const themes = ref([]);
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
const busy = ref('');
|
||||
|
||||
const load = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await fetch(api('/themes'), { headers: headers() });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.message || 'Could not load themes.');
|
||||
themes.value = data;
|
||||
} catch (e) {
|
||||
error.value = e.message || 'Could not load themes.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(load);
|
||||
|
||||
const activate = async (theme) => {
|
||||
busy.value = theme.stylesheet;
|
||||
try {
|
||||
const res = await fetch(api('/themes/activate'), {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({ stylesheet: theme.stylesheet }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Could not activate theme.');
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
} finally {
|
||||
busy.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (theme) => {
|
||||
if (!confirm(`Delete "${theme.name}"? This can't be undone.`)) return;
|
||||
busy.value = theme.stylesheet;
|
||||
try {
|
||||
const res = await fetch(api(`/themes/${encodeURIComponent(theme.stylesheet)}`), {
|
||||
method: 'DELETE',
|
||||
headers: headers(),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.message || 'Could not delete theme.');
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
setTimeout(() => (error.value = ''), 3000);
|
||||
} finally {
|
||||
busy.value = '';
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="themes-page">
|
||||
<header class="themes-page__header">
|
||||
<div>
|
||||
<p class="themes-page__eyebrow">Appearance</p>
|
||||
<h1>Themes</h1>
|
||||
</div>
|
||||
<div class="themes-page__actions">
|
||||
<a v-if="serverData.addThemeUrl" class="button-primary" :href="serverData.addThemeUrl">
|
||||
Add New Theme
|
||||
</a>
|
||||
<a class="button" :href="serverData.customizeUrl">Customize</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="error" class="themes-notice themes-notice--error">{{ error }}</div>
|
||||
<p v-if="loading" class="themes-empty">Loading…</p>
|
||||
|
||||
<div v-else class="themes-grid">
|
||||
<article v-for="theme in themes" :key="theme.stylesheet" class="theme-card" :class="{ 'theme-card--active': theme.active }">
|
||||
<div class="theme-card__shot">
|
||||
<img v-if="theme.screenshot" :src="theme.screenshot" :alt="theme.name" />
|
||||
<span v-else class="dashicons dashicons-admin-appearance" />
|
||||
</div>
|
||||
<div class="theme-card__body">
|
||||
<h3>{{ theme.name }} <span>{{ theme.version }}</span></h3>
|
||||
<p>{{ theme.description }}</p>
|
||||
</div>
|
||||
<div class="theme-card__actions">
|
||||
<span v-if="theme.active" class="theme-card__badge">Active</span>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="button-primary"
|
||||
:disabled="busy === theme.stylesheet"
|
||||
@click="activate(theme)"
|
||||
>
|
||||
{{ busy === theme.stylesheet ? 'Activating…' : 'Activate' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="!theme.active"
|
||||
type="button"
|
||||
class="icon-button"
|
||||
title="Delete theme"
|
||||
:disabled="busy === theme.stylesheet"
|
||||
@click="remove(theme)"
|
||||
>
|
||||
<span class="dashicons dashicons-trash" />
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.themes-page { max-width: 1100px; margin: 0 auto; }
|
||||
.themes-page__header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 20px; }
|
||||
.themes-page__actions { display: flex; align-items: center; gap: 8px; }
|
||||
.themes-page__eyebrow {
|
||||
margin: 0 0 4px; font-size: 0.75rem; text-transform: uppercase;
|
||||
letter-spacing: 0.06em; color: var(--aurora-text-muted);
|
||||
}
|
||||
.themes-page h1 { margin: 0; font-size: 1.6rem; font-weight: 700; color: var(--aurora-text); }
|
||||
.themes-empty { color: var(--aurora-text-muted); font-size: 0.8125rem; }
|
||||
.themes-notice { padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; font-size: 0.875rem; }
|
||||
.themes-notice--error { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
|
||||
|
||||
.themes-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 18px; }
|
||||
.theme-card {
|
||||
background: var(--aurora-bg-subtle); border: 1px solid var(--aurora-border);
|
||||
border-radius: 14px; overflow: hidden; display: flex; flex-direction: column;
|
||||
}
|
||||
.theme-card--active { border-color: var(--aurora-accent); }
|
||||
.theme-card__shot {
|
||||
aspect-ratio: 4 / 3; background: var(--aurora-bg);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: var(--aurora-text-muted);
|
||||
}
|
||||
.theme-card__shot img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.theme-card__shot .dashicons { font-size: 40px; width: 40px; height: 40px; }
|
||||
.theme-card__body { padding: 14px 16px 8px; flex: 1; }
|
||||
.theme-card__body h3 { margin: 0 0 6px; font-size: 0.9375rem; color: var(--aurora-text); }
|
||||
.theme-card__body h3 span { font-weight: 400; font-size: 0.75rem; color: var(--aurora-text-muted); margin-left: 6px; }
|
||||
.theme-card__body p {
|
||||
margin: 0; font-size: 0.8125rem; color: var(--aurora-text-muted);
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||
}
|
||||
.theme-card__actions { display: flex; align-items: center; gap: 8px; padding: 12px 16px 16px; }
|
||||
.theme-card__badge {
|
||||
font-size: 0.75rem; font-weight: 600; color: var(--aurora-accent);
|
||||
background: var(--aurora-accent-soft); padding: 4px 10px; border-radius: 999px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,152 @@
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
serverData: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
email: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
password: '',
|
||||
roles: [props.serverData.defaultRole || 'subscriber'],
|
||||
});
|
||||
|
||||
const saving = ref(false);
|
||||
const error = ref('');
|
||||
const success = ref('');
|
||||
|
||||
const generatePassword = () => {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%';
|
||||
form.password = Array.from({ length: 16 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
error.value = '';
|
||||
success.value = '';
|
||||
if (!form.username || !form.email) {
|
||||
error.value = 'Username and email are required.';
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const res = await fetch(`${props.serverData.restUrl}wp/v2/users`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': props.serverData.restNonce },
|
||||
body: JSON.stringify({ ...form, password: form.password || undefined }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.message || 'Could not create the user.');
|
||||
|
||||
success.value = `User "${data.username || form.username}" created.`;
|
||||
form.username = '';
|
||||
form.email = '';
|
||||
form.first_name = '';
|
||||
form.last_name = '';
|
||||
form.password = '';
|
||||
} catch (e) {
|
||||
error.value = e.message || 'Could not create the user.';
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="add-user-page">
|
||||
<header class="add-user-page__header">
|
||||
<div>
|
||||
<p class="add-user-page__eyebrow">Accounts</p>
|
||||
<h1>Add New User</h1>
|
||||
</div>
|
||||
<a class="button" :href="serverData.usersUrl">Back to Users</a>
|
||||
</header>
|
||||
|
||||
<div v-if="success" class="add-user-notice add-user-notice--success">{{ success }}</div>
|
||||
<div v-if="error" class="add-user-notice add-user-notice--error">{{ error }}</div>
|
||||
|
||||
<form class="add-user-form" @submit.prevent="submit">
|
||||
<label class="add-user-field">
|
||||
<span>Username <em>(required)</em></span>
|
||||
<input v-model="form.username" type="text" autocomplete="off" />
|
||||
</label>
|
||||
|
||||
<label class="add-user-field">
|
||||
<span>Email <em>(required)</em></span>
|
||||
<input v-model="form.email" type="email" autocomplete="off" />
|
||||
</label>
|
||||
|
||||
<label class="add-user-field">
|
||||
<span>First Name</span>
|
||||
<input v-model="form.first_name" type="text" />
|
||||
</label>
|
||||
|
||||
<label class="add-user-field">
|
||||
<span>Last Name</span>
|
||||
<input v-model="form.last_name" type="text" />
|
||||
</label>
|
||||
|
||||
<label class="add-user-field">
|
||||
<span>Password</span>
|
||||
<div class="add-user-field__row">
|
||||
<input v-model="form.password" type="text" placeholder="Leave blank to auto-generate" />
|
||||
<button type="button" class="button" @click="generatePassword">Generate</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="add-user-field">
|
||||
<span>Role</span>
|
||||
<select v-model="form.roles[0]">
|
||||
<option v-for="role in serverData.roles" :key="role.value" :value="role.value">{{ role.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="add-user-form__actions">
|
||||
<button type="submit" class="button-primary" :disabled="saving">
|
||||
{{ saving ? 'Adding User…' : 'Add New User' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.add-user-page { max-width: 640px; margin: 0 auto; }
|
||||
.add-user-page__header {
|
||||
display: flex; align-items: flex-start; justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.add-user-page__eyebrow {
|
||||
margin: 0 0 4px; font-size: 0.75rem; text-transform: uppercase;
|
||||
letter-spacing: 0.06em; color: var(--aurora-text-muted);
|
||||
}
|
||||
.add-user-page h1 { margin: 0; font-size: 1.6rem; font-weight: 700; color: var(--aurora-text); }
|
||||
|
||||
.add-user-notice {
|
||||
padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; font-size: 0.875rem;
|
||||
}
|
||||
.add-user-notice--success { background: rgba(34, 197, 94, 0.12); color: #22c55e; }
|
||||
.add-user-notice--error { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
|
||||
|
||||
.add-user-form {
|
||||
display: flex; flex-direction: column; gap: 18px;
|
||||
background: var(--aurora-bg-subtle);
|
||||
border: 1px solid var(--aurora-border);
|
||||
border-radius: 14px;
|
||||
padding: 24px;
|
||||
}
|
||||
.add-user-field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.add-user-field span { font-size: 0.8125rem; font-weight: 600; color: var(--aurora-text); }
|
||||
.add-user-field em { font-weight: 400; font-style: normal; color: var(--aurora-text-muted); }
|
||||
.add-user-field input,
|
||||
.add-user-field select {
|
||||
padding: 9px 12px; border-radius: 8px; border: 1px solid var(--aurora-border);
|
||||
background: var(--aurora-bg); color: var(--aurora-text); font-size: 0.875rem;
|
||||
}
|
||||
.add-user-field__row { display: flex; gap: 8px; }
|
||||
.add-user-field__row input { flex: 1; }
|
||||
.add-user-form__actions { margin-top: 4px; }
|
||||
</style>
|
||||
@@ -0,0 +1,144 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
serverData: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const api = (path) => `${props.serverData.restUrl}aurora-admin/v1${path}`;
|
||||
const headers = () => ({ 'Content-Type': 'application/json', 'X-WP-Nonce': props.serverData.restNonce });
|
||||
|
||||
const sidebars = ref([]);
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
|
||||
const load = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await fetch(api('/widgets'), { headers: headers() });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.message || 'Could not load widgets.');
|
||||
sidebars.value = data.sidebars || [];
|
||||
} catch (e) {
|
||||
error.value = e.message || 'Could not load widgets.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(load);
|
||||
|
||||
const move = async (widget, fromSidebar, toSidebar, order = -1) => {
|
||||
await fetch(api('/widgets/move'), {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({ widgetId: widget.id, fromSidebar, toSidebar, order }),
|
||||
});
|
||||
await load();
|
||||
};
|
||||
|
||||
const moveUp = (widget, sidebarId) => {
|
||||
if (widget.order <= 0) return;
|
||||
move(widget, sidebarId, sidebarId, widget.order - 1);
|
||||
};
|
||||
const moveDown = (widget, sidebarId, count) => {
|
||||
if (widget.order >= count - 1) return;
|
||||
move(widget, sidebarId, sidebarId, widget.order + 1);
|
||||
};
|
||||
|
||||
const remove = async (widget) => {
|
||||
if (!confirm(`Delete "${widget.name}"? This can't be undone.`)) return;
|
||||
const res = await fetch(api(`/widgets/${encodeURIComponent(widget.id)}`), { method: 'DELETE', headers: headers() });
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
error.value = data?.message || 'Could not delete widget.';
|
||||
setTimeout(() => (error.value = ''), 3000);
|
||||
return;
|
||||
}
|
||||
await load();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="widgets-page">
|
||||
<header class="widgets-page__header">
|
||||
<div>
|
||||
<p class="widgets-page__eyebrow">Appearance</p>
|
||||
<h1>Widgets</h1>
|
||||
<p class="widgets-page__sub">
|
||||
Move widgets between your theme's sidebars, reorder them, or deactivate them. Adding a brand new
|
||||
widget type still uses WordPress's own block-based widgets screen.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="error" class="widgets-notice widgets-notice--error">{{ error }}</div>
|
||||
<p v-if="loading" class="widgets-empty">Loading…</p>
|
||||
|
||||
<div v-else class="widgets-grid">
|
||||
<section v-for="sidebar in sidebars" :key="sidebar.id" class="widgets-sidebar">
|
||||
<h2>{{ sidebar.name }}</h2>
|
||||
<p v-if="!sidebar.widgets.length" class="widgets-empty">No widgets here.</p>
|
||||
|
||||
<div v-for="widget in sidebar.widgets" :key="widget.id" class="widgets-item">
|
||||
<div class="widgets-item__info">
|
||||
<strong>{{ widget.name }}</strong>
|
||||
<span v-if="widget.title">{{ widget.title }}</span>
|
||||
</div>
|
||||
<div class="widgets-item__actions">
|
||||
<button type="button" title="Move up" @click="moveUp(widget, sidebar.id)"><span class="dashicons dashicons-arrow-up-alt2" /></button>
|
||||
<button type="button" title="Move down" @click="moveDown(widget, sidebar.id, sidebar.widgets.length)"><span class="dashicons dashicons-arrow-down-alt2" /></button>
|
||||
<select :value="sidebar.id" @change="move(widget, sidebar.id, $event.target.value)">
|
||||
<option v-for="s in sidebars" :key="s.id" :value="s.id">{{ s.name }}</option>
|
||||
</select>
|
||||
<button v-if="sidebar.id === 'wp_inactive_widgets'" type="button" title="Delete" @click="remove(widget)">
|
||||
<span class="dashicons dashicons-trash" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.widgets-page { max-width: 1100px; margin: 0 auto; }
|
||||
.widgets-page__header { margin-bottom: 20px; }
|
||||
.widgets-page__eyebrow {
|
||||
margin: 0 0 4px; font-size: 0.75rem; text-transform: uppercase;
|
||||
letter-spacing: 0.06em; color: var(--aurora-text-muted);
|
||||
}
|
||||
.widgets-page h1 { margin: 0 0 6px; font-size: 1.6rem; font-weight: 700; color: var(--aurora-text); }
|
||||
.widgets-page__sub { margin: 0; font-size: 0.8125rem; color: var(--aurora-text-muted); max-width: 640px; }
|
||||
|
||||
.widgets-notice { padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; font-size: 0.875rem; }
|
||||
.widgets-notice--error { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
|
||||
|
||||
.widgets-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px; }
|
||||
.widgets-sidebar {
|
||||
background: var(--aurora-bg-subtle); border: 1px solid var(--aurora-border);
|
||||
border-radius: 14px; padding: 18px;
|
||||
}
|
||||
.widgets-sidebar h2 { font-size: 0.9375rem; margin: 0 0 10px; color: var(--aurora-text); }
|
||||
.widgets-empty { color: var(--aurora-text-muted); font-size: 0.8125rem; }
|
||||
|
||||
.widgets-item {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 10px;
|
||||
padding: 8px 10px; border-radius: 8px; margin-bottom: 6px;
|
||||
background: var(--aurora-bg); border: 1px solid var(--aurora-border);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.widgets-item__info { display: flex; flex-direction: column; min-width: 0; }
|
||||
.widgets-item__info strong { color: var(--aurora-text); }
|
||||
.widgets-item__info span { color: var(--aurora-text-muted); font-size: 0.75rem; }
|
||||
.widgets-item__actions { display: flex; align-items: center; gap: 2px; flex-shrink: 0; }
|
||||
.widgets-item__actions button {
|
||||
border: none; background: none; color: var(--aurora-text-muted); cursor: pointer;
|
||||
width: 26px; height: 26px; border-radius: 6px; display: inline-flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.widgets-item__actions button:hover { background: var(--aurora-bg-subtle); color: var(--aurora-text); }
|
||||
.widgets-item__actions select {
|
||||
font-size: 0.75rem; padding: 4px 6px; border-radius: 6px;
|
||||
border: 1px solid var(--aurora-border); background: var(--aurora-bg); color: var(--aurora-text);
|
||||
}
|
||||
</style>
|
||||
@@ -63,6 +63,7 @@ onMounted(() => {
|
||||
:menu="serverData.menu"
|
||||
:aurora-nav="serverData.auroraNav"
|
||||
:current-url="serverData.currentUrl"
|
||||
:active-parent-file="serverData.activeParentFile || ''"
|
||||
:logo="serverData.settings?.logo || ''"
|
||||
:dark-logo="serverData.settings?.dark_logo || ''"
|
||||
:collapsed="collapsed"
|
||||
|
||||
@@ -11,6 +11,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
activeParentFile: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
logo: {
|
||||
type: String,
|
||||
default: '',
|
||||
@@ -60,7 +64,13 @@ const isItemActive = (item) => {
|
||||
|
||||
const isSectionActive = (item) => {
|
||||
if (isItemActive(item)) return true;
|
||||
return (item.submenu || []).some((sub) => normalize(sub.url) === current.value);
|
||||
if ((item.submenu || []).some((sub) => normalize(sub.url) === current.value)) return true;
|
||||
// Covers native "hidden child" screens WP core itself doesn't list as a
|
||||
// submenu link but still treats as belonging to this section for
|
||||
// highlighting purposes (theme-install.php under Appearance, media-new.php
|
||||
// under Media, user-new.php under Users, ...) — see Shell::print_mount()'s
|
||||
// activeParentFile, sourced from WP core's own $parent_file global.
|
||||
return !!props.activeParentFile && item.id === props.activeParentFile;
|
||||
};
|
||||
|
||||
// Accordion: only one section's submenu open at a time — the WP-native
|
||||
@@ -239,9 +249,6 @@ const filteredAuroraNav = computed(() => {
|
||||
:class="{ 'sidebar__sublink--active': isAuroraActive(item) }"
|
||||
>
|
||||
{{ item.title }}
|
||||
<span v-if="item.locked" class="sidebar__pro-badge" title="Pro feature">
|
||||
<span class="dashicons dashicons-lock" />
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -474,18 +481,6 @@ const filteredAuroraNav = computed(() => {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sidebar__pro-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--aurora-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sidebar__pro-badge .dashicons {
|
||||
font-size: 13px;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
/* --- Aurora branded nav (right under the native menu's own divider) ---- */
|
||||
.sidebar__aurora {
|
||||
padding-bottom: 4px;
|
||||
|
||||
@@ -12,36 +12,12 @@ const props = defineProps({
|
||||
const emit = defineEmits(['toggle-menu']);
|
||||
|
||||
const searchOpen = ref(false);
|
||||
const sitesOpen = ref(false);
|
||||
|
||||
// Settings > General > "Disable global search" hides the search entirely.
|
||||
const searchDisabled = computed(
|
||||
() => !!props.serverData.settings?.disable_global_search
|
||||
);
|
||||
|
||||
// Remote sites (secrets already stripped server-side). The switcher only
|
||||
// shows for users holding the configured capability (canSwitchSites).
|
||||
const remoteSites = computed(() => {
|
||||
const sites = props.serverData.settings?.remote_sites;
|
||||
return Array.isArray(sites) ? sites.filter((s) => s && s.url) : [];
|
||||
});
|
||||
const showSwitcher = computed(
|
||||
() => !!props.serverData.canSwitchSites && remoteSites.value.length > 0
|
||||
);
|
||||
|
||||
const siteAdminUrl = (site) => {
|
||||
let url = (site.url || '').trim();
|
||||
if (!/^https?:\/\//i.test(url)) url = 'https://' + url;
|
||||
return url.replace(/\/+$/, '') + '/wp-admin/';
|
||||
};
|
||||
const siteHost = (site) => {
|
||||
try {
|
||||
return new URL(siteAdminUrl(site)).host;
|
||||
} catch {
|
||||
return site.url;
|
||||
}
|
||||
};
|
||||
|
||||
const onKeydown = (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
|
||||
if (searchDisabled.value) return;
|
||||
@@ -88,35 +64,6 @@ onUnmounted(() => document.removeEventListener('keydown', onKeydown));
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar__right">
|
||||
<div v-if="showSwitcher" class="toolbar__sites">
|
||||
<button
|
||||
type="button"
|
||||
class="toolbar__icon-btn"
|
||||
title="Switch site"
|
||||
@click="sitesOpen = !sitesOpen"
|
||||
>
|
||||
<span class="dashicons dashicons-admin-multisite" />
|
||||
</button>
|
||||
<div v-if="sitesOpen" class="toolbar__dropdown">
|
||||
<div class="toolbar__dropdown-header">
|
||||
<div class="toolbar__dropdown-name">Remote sites</div>
|
||||
</div>
|
||||
<a
|
||||
v-for="(site, i) in remoteSites"
|
||||
:key="i"
|
||||
:href="siteAdminUrl(site)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="toolbar__dropdown-item"
|
||||
>
|
||||
{{ siteHost(site) }}
|
||||
<span v-if="site.username" class="toolbar__site-user">{{ site.username }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchModal
|
||||
:server-data="serverData"
|
||||
:open="searchOpen"
|
||||
@@ -143,8 +90,7 @@ onUnmounted(() => document.removeEventListener('keydown', onKeydown));
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
.toolbar__left,
|
||||
.toolbar__right {
|
||||
.toolbar__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
@@ -218,54 +164,4 @@ onUnmounted(() => document.removeEventListener('keydown', onKeydown));
|
||||
color: var(--aurora-text);
|
||||
}
|
||||
|
||||
.toolbar__sites {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.toolbar__site-user {
|
||||
color: var(--aurora-text-muted);
|
||||
font-size: 0.75rem;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.toolbar__dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
min-width: 200px;
|
||||
background: var(--aurora-bg);
|
||||
border: 1px solid var(--aurora-border);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
overflow: hidden;
|
||||
z-index: 100000;
|
||||
}
|
||||
|
||||
.toolbar__dropdown-header {
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--aurora-border);
|
||||
}
|
||||
|
||||
.toolbar__dropdown-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--aurora-text);
|
||||
}
|
||||
|
||||
.toolbar__dropdown-email {
|
||||
font-size: 0.75rem;
|
||||
color: var(--aurora-text-muted);
|
||||
}
|
||||
|
||||
.toolbar__dropdown-item {
|
||||
display: block;
|
||||
padding: 10px 14px;
|
||||
text-decoration: none;
|
||||
color: var(--aurora-text);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.toolbar__dropdown-item:hover {
|
||||
background: var(--aurora-bg-subtle);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -292,6 +292,80 @@ html.aurora-has-taskbar #site-editor {
|
||||
bottom: calc(24px + var(--aurora-taskbar-h) + var(--aurora-sidebar-gap) * 2) !important;
|
||||
}
|
||||
|
||||
/* Theme installer preview ("Add Themes" > click a theme > full "Preview &
|
||||
Install" overlay, theme-install.php). WP core's own .wp-full-overlay
|
||||
component (shared with the Customizer, but only .theme-install-overlay
|
||||
specifically is touched here — customize.php is out of scope) is a
|
||||
true-viewport fixed overlay by design, so left unstyled it covers
|
||||
Aurora's sidebar/toolbar entirely. Reframed the same way the block
|
||||
editor is above: shrink the overlay's own box to the frame's insets,
|
||||
then independently reposition its two POSITION:FIXED children (sidebar,
|
||||
main) to match — fixed descendants are NOT relative to an ancestor's
|
||||
padding box the way absolutely-positioned ones are, so the overlay's own
|
||||
inset alone doesn't move them; each needs the same left/top/bottom math
|
||||
applied directly. margin-left on .expanded/.collapsed mirrors WP core's
|
||||
own three breakpoints (themes.css) with Aurora's frame offset added on
|
||||
top, so the sidebar collapse/expand slide animation keeps working
|
||||
unmodified — only the resting position changed, not the transition.
|
||||
|
||||
One more WP core mechanism to counteract: opening the overlay adds
|
||||
body.full-overlay-active, and core's own CSS sets
|
||||
`body.full-overlay-active { visibility: hidden }` with only
|
||||
`.theme-install-overlay` (and #customize-container) set back to
|
||||
visible — intended to hide the native admin chrome underneath, which
|
||||
normally doesn't matter since core's own sidebar/toolbar are redundant
|
||||
once covered anyway. Aurora's shell inherits that hidden visibility
|
||||
too (it's nested in body), which is exactly what silently erased the
|
||||
sidebar/toolbar entirely instead of just having them covered. Since
|
||||
descendants can locally override an inherited `visibility: hidden`
|
||||
back to visible, one rule undoes it for Aurora's own root only —
|
||||
everything else under body stays hidden, harmless since it's covered
|
||||
by the overlay's insets anyway. */
|
||||
body.full-overlay-active #aurora-admin-shell-root {
|
||||
visibility: visible !important;
|
||||
}
|
||||
|
||||
html.aurora-ready .theme-install-overlay {
|
||||
top: calc(var(--aurora-toolbar-h) + 16px) !important;
|
||||
right: var(--aurora-gutter-r) !important;
|
||||
bottom: 24px !important;
|
||||
/* WP core's own .wp-full-overlay sets height: 100% — combined with top
|
||||
AND bottom both set here, that's the exact same over-constrained box
|
||||
as .interface-interface-skeleton above: per spec 'bottom' silently
|
||||
loses to the explicit height, so the box ran past the true viewport
|
||||
edge instead of stopping at this inset. Same fix. */
|
||||
height: auto !important;
|
||||
}
|
||||
html.aurora-ready .theme-install-overlay.expanded {
|
||||
margin-left: calc(300px + var(--aurora-sidebar-w) + var(--aurora-sidebar-gap) * 2 + 24px) !important;
|
||||
}
|
||||
html.aurora-ready .theme-install-overlay.collapsed {
|
||||
margin-left: calc(var(--aurora-sidebar-w) + var(--aurora-sidebar-gap) * 2 + 24px) !important;
|
||||
}
|
||||
@media screen and (min-width: 1667px) {
|
||||
html.aurora-ready .theme-install-overlay.expanded {
|
||||
margin-left: calc(18% + var(--aurora-sidebar-w) + var(--aurora-sidebar-gap) * 2 + 24px) !important;
|
||||
}
|
||||
}
|
||||
@media screen and (min-width: 3333px) {
|
||||
html.aurora-ready .theme-install-overlay.expanded {
|
||||
margin-left: calc(600px + var(--aurora-sidebar-w) + var(--aurora-sidebar-gap) * 2 + 24px) !important;
|
||||
}
|
||||
}
|
||||
|
||||
html.aurora-ready .theme-install-overlay .wp-full-overlay-sidebar {
|
||||
left: calc(var(--aurora-sidebar-w) + var(--aurora-sidebar-gap) * 2 + 24px) !important;
|
||||
top: calc(var(--aurora-toolbar-h) + 16px + var(--aurora-editor-winbar-h)) !important;
|
||||
bottom: 24px !important;
|
||||
height: auto !important;
|
||||
border-top-left-radius: var(--aurora-radius-lg);
|
||||
}
|
||||
html.aurora-ready .theme-install-overlay .wp-full-overlay-main {
|
||||
top: var(--aurora-editor-winbar-h) !important;
|
||||
border-top-right-radius: var(--aurora-radius-lg);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
/* macOS-style title bar (windowChrome.js). Most pages get it injected as
|
||||
the first child of #wpbody-content — negative margins cancel the 32px
|
||||
padding above so it spans edge-to-edge like a real window title bar. */
|
||||
@@ -325,6 +399,17 @@ html.aurora-has-taskbar #site-editor {
|
||||
border-top-left-radius: var(--aurora-radius-lg);
|
||||
border-top-right-radius: var(--aurora-radius-lg);
|
||||
}
|
||||
.aurora-winbar__crumb {
|
||||
margin-left: 8px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--aurora-text-muted);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.aurora-winbar__crumb:hover {
|
||||
color: var(--aurora-text);
|
||||
}
|
||||
.aurora-winbar__dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
|
||||
@@ -75,7 +75,67 @@ function isBlockEditorPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// WP core adds a "post-type-{slug}" class to <body> on every post edit
|
||||
// screen — the same signal used here to build a "back to the list" link,
|
||||
// since Posts ("edit.php") and Pages ("edit.php?post_type=page") are now
|
||||
// hijacked in place at their native URLs (see HijackedNativePages.php) and
|
||||
// there's no single admin.php?page=... URL to hardcode anymore.
|
||||
//
|
||||
// The Site Editor (site-editor.php) also carries the "block-editor-page"
|
||||
// body class isBlockEditorPage() matches on, but has no "post-type-*"
|
||||
// class (it edits templates/template parts, not a post) — returning null
|
||||
// here rather than guessing "Posts" is what keeps the crumb from showing
|
||||
// up somewhere it doesn't make sense.
|
||||
function editorBackLink(adminUrl) {
|
||||
const match = [...document.body.classList].find((c) => c.startsWith('post-type-'));
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const postType = match.replace('post-type-', '');
|
||||
const base = adminUrl || '/wp-admin/';
|
||||
if (postType === 'post') {
|
||||
return { label: 'Posts', href: base + 'edit.php' };
|
||||
}
|
||||
if (postType === 'page') {
|
||||
return { label: 'Pages', href: base + 'edit.php?post_type=page' };
|
||||
}
|
||||
return { label: postType, href: base + 'edit.php?post_type=' + encodeURIComponent(postType) };
|
||||
}
|
||||
|
||||
// theme-install.php's "Preview & Install" overlay (WP core's
|
||||
// .wp-full-overlay component) needs a permanently-running observer, not a
|
||||
// one-shot check, for two reasons: (1) core's own Backbone view creates it
|
||||
// lazily the first time a theme is clicked, so it's missing from the
|
||||
// initial DOM at initWindowChrome()'s call time; (2) every subsequent
|
||||
// theme selection re-renders the overlay's contents from scratch (its own
|
||||
// view.render() replaces all children to show the newly-selected theme's
|
||||
// details), wiping out anything inserted into it — confirmed live: the bar
|
||||
// was there after the very first click, gone after picking a second theme.
|
||||
// Kept running for the page's whole lifetime rather than disconnecting
|
||||
// after the first successful insert. Independent of the rest of this
|
||||
// file's dedupe guard (a normal page winbar already exists in
|
||||
// #wpbody-content here too; this is a second, additional bar for the
|
||||
// overlay specifically, not a replacement for it).
|
||||
function watchForThemeOverlay(adminUrl) {
|
||||
const ensureInserted = () => {
|
||||
const overlay = document.querySelector('.theme-install-overlay');
|
||||
if (overlay && !overlay.querySelector('.aurora-winbar')) {
|
||||
const bar = buildBar(adminUrl, false);
|
||||
bar.classList.add('aurora-winbar--editor');
|
||||
overlay.insertBefore(bar, overlay.firstChild);
|
||||
}
|
||||
};
|
||||
|
||||
ensureInserted();
|
||||
new MutationObserver(ensureInserted).observe(document.body, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
export function initWindowChrome(adminUrl) {
|
||||
if (document.body.classList.contains('theme-install-php')) {
|
||||
watchForThemeOverlay(adminUrl);
|
||||
}
|
||||
|
||||
if (document.querySelector('.aurora-winbar')) {
|
||||
return;
|
||||
}
|
||||
@@ -103,6 +163,16 @@ export function initWindowChrome(adminUrl) {
|
||||
if (isBlockEditorPage()) {
|
||||
const bar = buildBar(adminUrl, false);
|
||||
bar.classList.add('aurora-winbar--editor');
|
||||
|
||||
const back = editorBackLink(adminUrl);
|
||||
if (back) {
|
||||
const crumb = document.createElement('a');
|
||||
crumb.className = 'aurora-winbar__crumb';
|
||||
crumb.href = back.href;
|
||||
crumb.textContent = '← ' + back.label;
|
||||
bar.appendChild(crumb);
|
||||
}
|
||||
|
||||
document.body.appendChild(bar);
|
||||
return;
|
||||
}
|
||||
|
||||
+8
-3
@@ -27,15 +27,20 @@ export default defineConfig({
|
||||
'admin-notices': fileURLToPath(new URL('./src/entries/admin-notices.js', import.meta.url)),
|
||||
'role-editor': fileURLToPath(new URL('./src/entries/role-editor.js', import.meta.url)),
|
||||
'activity-log': fileURLToPath(new URL('./src/entries/activity-log.js', import.meta.url)),
|
||||
'database-explorer': fileURLToPath(new URL('./src/entries/database-explorer.js', import.meta.url)),
|
||||
plugins: fileURLToPath(new URL('./src/entries/plugins.js', import.meta.url)),
|
||||
comments: fileURLToPath(new URL('./src/entries/comments.js', import.meta.url)),
|
||||
posts: fileURLToPath(new URL('./src/entries/posts.js', import.meta.url)),
|
||||
pages: fileURLToPath(new URL('./src/entries/pages.js', import.meta.url)),
|
||||
media: fileURLToPath(new URL('./src/entries/media.js', import.meta.url)),
|
||||
users: fileURLToPath(new URL('./src/entries/users.js', import.meta.url)),
|
||||
'file-manager': fileURLToPath(new URL('./src/entries/file-manager.js', import.meta.url)),
|
||||
'locked-feature': fileURLToPath(new URL('./src/entries/locked-feature.js', import.meta.url)),
|
||||
'native-settings': fileURLToPath(new URL('./src/entries/native-settings.js', import.meta.url)),
|
||||
'media-upload': fileURLToPath(new URL('./src/entries/media-upload.js', import.meta.url)),
|
||||
'add-user': fileURLToPath(new URL('./src/entries/add-user.js', import.meta.url)),
|
||||
'nav-menus': fileURLToPath(new URL('./src/entries/nav-menus.js', import.meta.url)),
|
||||
'themes': fileURLToPath(new URL('./src/entries/themes.js', import.meta.url)),
|
||||
'widgets': fileURLToPath(new URL('./src/entries/widgets.js', import.meta.url)),
|
||||
'profile': fileURLToPath(new URL('./src/entries/profile.js', import.meta.url)),
|
||||
'bug-report': fileURLToPath(new URL('./src/entries/bug-report.js', import.meta.url)),
|
||||
},
|
||||
output: {
|
||||
entryFileNames: '[name].[hash].js',
|
||||
|
||||
Reference in New Issue
Block a user