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:
NixReaper
2026-07-12 08:41:18 -05:00
co-authored by Claude Opus 4.8
parent 07d77bda57
commit e2db2ad4b5
88 changed files with 8944 additions and 2730 deletions
+342 -2
View File
@@ -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
View File
@@ -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": {
+60
View File
@@ -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>
+11 -3
View File
@@ -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;
+98
View File
@@ -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 };
}
+10
View File
@@ -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);
}
+10
View File
@@ -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);
}
-10
View File
@@ -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);
}
-10
View File
@@ -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);
}
-10
View File
@@ -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);
}
+10
View File
@@ -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);
}
+10
View File
@@ -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);
}
+10
View File
@@ -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);
}
+10
View File
@@ -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);
}
+10
View File
@@ -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);
}
+10
View File
@@ -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);
}
+26 -3
View File
@@ -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>
-81
View File
@@ -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>
+128
View File
@@ -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>
+9 -6
View File
@@ -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>
-649
View File
@@ -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>
+148
View File
@@ -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
View File
@@ -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>
+193
View File
@@ -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>
+437
View File
@@ -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>
+205
View File
@@ -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>
+60 -6
View File
@@ -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.';
};
+67 -13
View File
@@ -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 &gt; Profile &gt; 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 &amp; 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; }
}
+96 -24
View File
@@ -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,
},
],
},
];
+162
View File
@@ -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>
+152
View File
@@ -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>
+144
View File
@@ -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>
+1
View File
@@ -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 -16
View File
@@ -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;
+1 -105
View File
@@ -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>
+85
View File
@@ -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;
+70
View File
@@ -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
View File
@@ -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',