Snapshot synced v3.6.1 working state as baseline
Machine-sync snapshot: the working tree carried the full 3.0.0-3.6.1 arc (licensing/FileManager/DatabaseExplorer removal, all new replacement screens and REST controllers) that had not been committed on this machine. Captured here as a safety baseline before starting the Adminify-parity revamp. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
07d77bda57
commit
e2db2ad4b5
@@ -72,7 +72,14 @@ const search = defineModel('search', { type: String, default: '' });
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
min-height: calc(100vh - var(--aurora-toolbar-h) - 60px);
|
||||
/* A fixed height (not just min-height) so the two panes below can each
|
||||
scroll independently within it — previously this only set a minimum
|
||||
and let the whole shell grow with content, meaning both panes shared
|
||||
one page-level scrollbar. Selecting an item near the bottom of a long
|
||||
list left the detail panel's newly-loaded content rendered above the
|
||||
current scroll position, looking like nothing happened until you
|
||||
scrolled all the way back up. */
|
||||
height: calc(100vh - var(--aurora-toolbar-h) - 60px);
|
||||
margin: -32px;
|
||||
padding: 22px;
|
||||
box-sizing: border-box;
|
||||
@@ -85,6 +92,8 @@ const search = defineModel('search', { type: String, default: '' });
|
||||
.app-list-shell__nav {
|
||||
width: 340px;
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
border-radius: 16px;
|
||||
padding: 24px 20px;
|
||||
box-sizing: border-box;
|
||||
@@ -150,6 +159,8 @@ const search = defineModel('search', { type: String, default: '' });
|
||||
/* Right — no background/border of its own, same reasoning. */
|
||||
.app-list-shell__content {
|
||||
flex: 1; min-width: 0;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
border-radius: 16px;
|
||||
padding: 28px 32px;
|
||||
min-height: 480px;
|
||||
@@ -172,7 +183,19 @@ const search = defineModel('search', { type: String, default: '' });
|
||||
.app-list-shell__empty-text { font-size: 0.8125rem; color: var(--aurora-text-muted); max-width: 320px; margin: 0; }
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.app-list-shell { flex-direction: column; }
|
||||
.app-list-shell__nav { width: auto; }
|
||||
/* Stacked instead of side-by-side, so the fixed-height/independent-
|
||||
scroll treatment above (built for two side-by-side panes) would just
|
||||
squash both panes into half-height boxes — switch to natural height
|
||||
with each pane capped and independently scrollable instead. */
|
||||
.app-list-shell { flex-direction: column; height: auto; }
|
||||
.app-list-shell__nav {
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-height: 40vh;
|
||||
}
|
||||
.app-list-shell__content {
|
||||
height: auto;
|
||||
max-height: 60vh;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
feature: { type: String, required: true },
|
||||
description: { type: String, default: '' },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="upgrade-notice">
|
||||
<div class="upgrade-notice__card">
|
||||
<span class="upgrade-notice__icon">
|
||||
<span class="dashicons dashicons-lock" />
|
||||
</span>
|
||||
<h1 class="upgrade-notice__title">{{ feature }} is a Pro feature</h1>
|
||||
<p class="upgrade-notice__text">
|
||||
{{ description || `Upgrade to Aurora Admin Pro to unlock ${feature}.` }}
|
||||
</p>
|
||||
<a class="upgrade-notice__cta" href="admin.php?page=aurora-admin#license">
|
||||
Enter a license key
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.upgrade-notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
.upgrade-notice__card {
|
||||
max-width: 380px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.upgrade-notice__icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--aurora-accent-soft);
|
||||
color: var(--aurora-accent);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.upgrade-notice__icon .dashicons {
|
||||
font-size: 26px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
.upgrade-notice__title {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--aurora-text);
|
||||
}
|
||||
.upgrade-notice__text {
|
||||
font-size: 0.875rem;
|
||||
color: var(--aurora-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
.upgrade-notice__cta {
|
||||
margin-top: 8px;
|
||||
padding: 8px 18px;
|
||||
border-radius: 8px;
|
||||
background: var(--aurora-accent);
|
||||
color: var(--aurora-accent-text);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.upgrade-notice__cta:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,128 @@
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
serverData: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const description = ref('');
|
||||
const sending = ref(false);
|
||||
const notice = ref('');
|
||||
const error = ref('');
|
||||
|
||||
const diagnostics = reactive({
|
||||
pluginVersion: props.serverData.pluginVersion || '',
|
||||
wpVersion: props.serverData.wpVersion || '',
|
||||
phpVersion: props.serverData.phpVersion || '',
|
||||
theme: props.serverData.theme || '',
|
||||
});
|
||||
|
||||
const send = async () => {
|
||||
if (!description.value.trim()) return;
|
||||
|
||||
sending.value = true;
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
try {
|
||||
const res = await fetch(`${props.serverData.restUrl}aurora-admin/v1/bug-report`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-WP-Nonce': props.serverData.restNonce,
|
||||
},
|
||||
body: JSON.stringify({ description: description.value }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.success) throw new Error(data?.message || 'Could not send your report.');
|
||||
notice.value = 'Thanks — your report was sent.';
|
||||
description.value = '';
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
} finally {
|
||||
sending.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="bug-report-page">
|
||||
<header class="bug-report-page__header">
|
||||
<p class="bug-report-page__eyebrow">Support</p>
|
||||
<h1>Report a Bug</h1>
|
||||
<p class="bug-report-page__sub">
|
||||
Tell us what went wrong. We'll get your diagnostics automatically —
|
||||
just describe the issue below.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div v-if="notice" class="bug-report-notice bug-report-notice--success">{{ notice }}</div>
|
||||
<div v-if="error" class="bug-report-notice bug-report-notice--error">{{ error }}</div>
|
||||
|
||||
<form class="bug-report-form" @submit.prevent="send">
|
||||
<label class="bug-report-field">
|
||||
<span>What happened?</span>
|
||||
<textarea
|
||||
v-model="description"
|
||||
rows="8"
|
||||
placeholder="Describe the bug, what you expected, and the steps to reproduce it…"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="bug-report-diagnostics">
|
||||
<h2>Diagnostics (sent automatically)</h2>
|
||||
<dl>
|
||||
<div><dt>Plugin version</dt><dd>{{ diagnostics.pluginVersion || '—' }}</dd></div>
|
||||
<div><dt>WordPress version</dt><dd>{{ diagnostics.wpVersion || '—' }}</dd></div>
|
||||
<div><dt>PHP version</dt><dd>{{ diagnostics.phpVersion || '—' }}</dd></div>
|
||||
<div><dt>Active theme</dt><dd>{{ diagnostics.theme || '—' }}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="bug-report-form__actions">
|
||||
<button type="submit" class="button-primary" :disabled="sending || !description.trim()">
|
||||
{{ sending ? 'Sending…' : 'Send Report' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bug-report-page { max-width: 640px; margin: 0 auto; }
|
||||
.bug-report-page__header { margin-bottom: 16px; }
|
||||
.bug-report-page__eyebrow {
|
||||
margin: 0 0 4px; font-size: 0.75rem; text-transform: uppercase;
|
||||
letter-spacing: 0.06em; color: var(--aurora-text-muted);
|
||||
}
|
||||
.bug-report-page h1 { margin: 0; font-size: 1.6rem; font-weight: 700; color: var(--aurora-text); }
|
||||
.bug-report-page__sub { margin: 8px 0 0; font-size: 0.875rem; color: var(--aurora-text-muted); }
|
||||
|
||||
.bug-report-notice { padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; font-size: 0.875rem; }
|
||||
.bug-report-notice--success { background: rgba(34, 197, 94, 0.12); color: #22c55e; }
|
||||
.bug-report-notice--error { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
|
||||
|
||||
.bug-report-form { display: flex; flex-direction: column; gap: 20px; }
|
||||
.bug-report-field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.bug-report-field span { font-size: 0.8125rem; font-weight: 600; color: var(--aurora-text); }
|
||||
.bug-report-field textarea {
|
||||
padding: 10px 12px; border-radius: 8px; border: 1px solid var(--aurora-border);
|
||||
background: var(--aurora-bg-subtle); color: var(--aurora-text); font-size: 0.875rem;
|
||||
font-family: inherit; resize: vertical;
|
||||
}
|
||||
|
||||
.bug-report-diagnostics {
|
||||
background: var(--aurora-bg-subtle); border: 1px solid var(--aurora-border);
|
||||
border-radius: 10px; padding: 14px 16px;
|
||||
}
|
||||
.bug-report-diagnostics h2 {
|
||||
margin: 0 0 10px; font-size: 0.75rem; text-transform: uppercase;
|
||||
letter-spacing: 0.03em; color: var(--aurora-text-muted); font-weight: 600;
|
||||
}
|
||||
.bug-report-diagnostics dl { margin: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.bug-report-diagnostics dl > div { display: flex; justify-content: space-between; font-size: 0.8125rem; }
|
||||
.bug-report-diagnostics dt { color: var(--aurora-text-muted); }
|
||||
.bug-report-diagnostics dd { margin: 0; color: var(--aurora-text); font-weight: 500; }
|
||||
|
||||
.bug-report-form__actions { display: flex; justify-content: flex-end; }
|
||||
</style>
|
||||
@@ -13,7 +13,6 @@ import ActiveUsers from './cards/ActiveUsers.vue';
|
||||
import DeviceUsage from './cards/DeviceUsage.vue';
|
||||
import Engagement from './cards/Engagement.vue';
|
||||
import TopList from './cards/TopList.vue';
|
||||
import UpgradeNotice from '../_shared/UpgradeNotice.vue';
|
||||
|
||||
const props = defineProps({
|
||||
serverData: { type: Object, required: true },
|
||||
@@ -108,11 +107,9 @@ onMounted(() => reload(range.value[0], range.value[1]));
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UpgradeNotice
|
||||
v-else-if="analyticsError"
|
||||
feature="Analytics"
|
||||
description="Upgrade to Aurora Admin Pro to unlock first-party visitor analytics for this dashboard."
|
||||
/>
|
||||
<div v-else-if="analyticsError" class="dash__analytics-error">
|
||||
Could not load analytics. Try refreshing the page.
|
||||
</div>
|
||||
|
||||
<div v-else class="dash__grid">
|
||||
<div class="dash__cell" style="grid-column: span 8">
|
||||
@@ -209,6 +206,12 @@ onMounted(() => reload(range.value[0], range.value[1]));
|
||||
padding: 3px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.dash__analytics-error {
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
color: var(--aurora-text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.dash__tab {
|
||||
border: none; background: none; cursor: pointer;
|
||||
padding: 6px 16px; border-radius: 8px;
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue';
|
||||
import AppListShell from '../_shared/AppListShell.vue';
|
||||
import AppListItem from '../_shared/AppListItem.vue';
|
||||
|
||||
const props = defineProps({
|
||||
serverData: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const restBase = `${props.serverData.restUrl}aurora-admin/v1`;
|
||||
const headers = { 'Content-Type': 'application/json', 'X-WP-Nonce': props.serverData.restNonce };
|
||||
const api = (path, opts = {}) =>
|
||||
fetch(`${restBase}${path}`, { ...opts, headers }).then(async (r) => {
|
||||
const body = await r.json();
|
||||
if (!r.ok) throw new Error(body?.message || 'Request failed');
|
||||
return body;
|
||||
});
|
||||
|
||||
const tables = ref([]);
|
||||
const search = ref('');
|
||||
const selectedTable = ref(null);
|
||||
const tableData = ref(null);
|
||||
const tablePage = ref(1);
|
||||
const loadingTable = ref(false);
|
||||
|
||||
const sqlMode = ref(false);
|
||||
const sqlQuery = ref('SELECT * FROM ' + (props.serverData.tablePrefix || 'wp_') + 'options LIMIT 10');
|
||||
const sqlResult = ref(null);
|
||||
const sqlError = ref('');
|
||||
const sqlRunning = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
tables.value = await api('/db-tables');
|
||||
});
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = search.value.trim().toLowerCase();
|
||||
if (!q) return tables.value;
|
||||
return tables.value.filter((t) => t.name.toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
const formatSize = (bytes) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
||||
};
|
||||
|
||||
const loadTable = async (name, page = 1) => {
|
||||
sqlMode.value = false;
|
||||
selectedTable.value = name;
|
||||
tablePage.value = page;
|
||||
loadingTable.value = true;
|
||||
try {
|
||||
tableData.value = await api(`/db-tables/${encodeURIComponent(name)}?page=${page}`);
|
||||
} finally {
|
||||
loadingTable.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(tablePage, (p) => {
|
||||
if (selectedTable.value) loadTable(selectedTable.value, p);
|
||||
});
|
||||
|
||||
const openSqlEditor = () => {
|
||||
sqlMode.value = true;
|
||||
selectedTable.value = null;
|
||||
};
|
||||
|
||||
const runQuery = async () => {
|
||||
sqlRunning.value = true;
|
||||
sqlError.value = '';
|
||||
sqlResult.value = null;
|
||||
try {
|
||||
sqlResult.value = await api('/db-query', { method: 'POST', body: JSON.stringify({ query: sqlQuery.value }) });
|
||||
} catch (e) {
|
||||
sqlError.value = e.message;
|
||||
} finally {
|
||||
sqlRunning.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const hasSelection = computed(() => !!selectedTable.value || sqlMode.value);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppListShell
|
||||
v-model:search="search"
|
||||
title="Database Explorer"
|
||||
search-placeholder="Search tables…"
|
||||
:count-label="`${filtered.length} TABLES`"
|
||||
detail-icon="dashicons-database"
|
||||
detail-title="Table Details"
|
||||
detail-text="Select a table from the list to view its structure and data."
|
||||
:has-selection="hasSelection"
|
||||
>
|
||||
<template #header-actions>
|
||||
<button type="button" @click="openSqlEditor">
|
||||
<span class="dashicons dashicons-editor-code" /> SQL
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template #detail-empty-actions>
|
||||
<button type="button" class="is-primary" style="margin-top: 14px;" @click="openSqlEditor">
|
||||
Open SQL Editor
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template #list>
|
||||
<AppListItem
|
||||
v-for="t in filtered"
|
||||
:key="t.name"
|
||||
icon="dashicons-database"
|
||||
:title="t.name"
|
||||
:subtitle="`${t.rows} rows · ${formatSize(t.sizeBytes)}`"
|
||||
:active="t.name === selectedTable"
|
||||
@click="loadTable(t.name)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #detail>
|
||||
<div v-if="sqlMode" class="db-sql">
|
||||
<h2>SQL Editor</h2>
|
||||
<p class="db-sql__hint">Only SELECT / SHOW / DESCRIBE / EXPLAIN queries are allowed.</p>
|
||||
<textarea v-model="sqlQuery" rows="4" spellcheck="false" />
|
||||
<div class="db-sql__actions">
|
||||
<button type="button" class="is-primary" :disabled="sqlRunning" @click="runQuery">
|
||||
{{ sqlRunning ? 'Running…' : 'Run query' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="sqlError" class="db-sql__error">{{ sqlError }}</p>
|
||||
<div v-if="sqlResult" class="db-table-wrap">
|
||||
<p class="db-table__count">{{ sqlResult.count }} rows</p>
|
||||
<table v-if="sqlResult.rows.length" class="db-table">
|
||||
<thead>
|
||||
<tr><th v-for="k in Object.keys(sqlResult.rows[0])" :key="k">{{ k }}</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, i) in sqlResult.rows" :key="i">
|
||||
<td v-for="(v, k) in row" :key="k">{{ v }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="selectedTable" class="db-table-detail">
|
||||
<h2>{{ selectedTable }}</h2>
|
||||
<div v-if="loadingTable">Loading…</div>
|
||||
<template v-else-if="tableData">
|
||||
<div class="db-table-wrap">
|
||||
<table class="db-table">
|
||||
<thead>
|
||||
<tr><th v-for="c in tableData.columns" :key="c.name">{{ c.name }}</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, i) in tableData.rows" :key="i">
|
||||
<td v-for="c in tableData.columns" :key="c.name">{{ row[c.name] }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="db-table__pager">
|
||||
<span>Page {{ tableData.page }} of {{ tableData.totalPages || 1 }} · {{ tableData.total }} rows</span>
|
||||
<button type="button" :disabled="tablePage <= 1" @click="tablePage--">‹</button>
|
||||
<button type="button" :disabled="tablePage >= tableData.totalPages" @click="tablePage++">›</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</AppListShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.db-sql h2, .db-table-detail h2 { margin: 0 0 10px; font-size: 1.1rem; color: var(--aurora-text); }
|
||||
.db-sql__hint { font-size: 0.75rem; color: var(--aurora-text-muted); margin: 0 0 10px; }
|
||||
.db-sql textarea {
|
||||
width: 100%; box-sizing: border-box; font-family: monospace; font-size: 0.8125rem;
|
||||
padding: 10px; border-radius: 8px; border: 1px solid var(--aurora-border);
|
||||
background: var(--aurora-bg-subtle); color: var(--aurora-text);
|
||||
}
|
||||
.db-sql__actions { display: flex; justify-content: flex-end; margin: 10px 0; }
|
||||
.db-sql__actions button, .db-table__pager button {
|
||||
border: none; border-radius: 8px; padding: 8px 16px; font-size: 0.8125rem; cursor: pointer;
|
||||
background: var(--aurora-accent); color: #fff;
|
||||
}
|
||||
.db-sql__actions button:disabled { opacity: 0.5; cursor: default; }
|
||||
.db-sql__error { color: #ef4444; font-size: 0.8125rem; }
|
||||
|
||||
.db-table-wrap { overflow-x: auto; max-height: 420px; overflow-y: auto; border: 1px solid var(--aurora-frame-border); border-radius: 8px; }
|
||||
.db-table { width: 100%; border-collapse: collapse; font-size: 0.75rem; }
|
||||
.db-table th, .db-table td {
|
||||
padding: 6px 10px; text-align: left; white-space: nowrap;
|
||||
border-bottom: 1px solid var(--aurora-frame-border); color: var(--aurora-text);
|
||||
}
|
||||
.db-table th { background: var(--aurora-bg-subtle); position: sticky; top: 0; }
|
||||
.db-table__count { font-size: 0.75rem; color: var(--aurora-text-muted); margin: 8px 0; }
|
||||
.db-table__pager {
|
||||
display: flex; align-items: center; gap: 8px; margin-top: 12px;
|
||||
font-size: 0.75rem; color: var(--aurora-text-muted);
|
||||
}
|
||||
.db-table__pager button { background: var(--aurora-bg-subtle); color: var(--aurora-text); padding: 4px 10px; }
|
||||
</style>
|
||||
@@ -1,649 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
serverData: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const path = ref('');
|
||||
const breadcrumb = ref([]);
|
||||
const items = ref([]);
|
||||
const loading = ref(true);
|
||||
const busy = ref(false);
|
||||
const error = ref('');
|
||||
const notice = ref('');
|
||||
const search = ref('');
|
||||
const fileInput = ref(null);
|
||||
|
||||
// Text editor state — a single file open at a time.
|
||||
const editing = ref(null); // { path, name, content, isWritable }
|
||||
const editorContent = ref('');
|
||||
const savingEdit = ref(false);
|
||||
|
||||
const apiBase = computed(() => `${props.serverData.restUrl}aurora-admin/v1`);
|
||||
|
||||
const request = async (path, options = {}) => {
|
||||
const response = await fetch(`${apiBase.value}${path}`, {
|
||||
credentials: 'same-origin',
|
||||
...options,
|
||||
headers: {
|
||||
'X-WP-Nonce': props.serverData.restNonce,
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || data.error || 'Request failed');
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
const load = async (targetPath = path.value) => {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const params = new URLSearchParams({ path: targetPath });
|
||||
const data = await request(`/file-manager/list?${params.toString()}`);
|
||||
path.value = data.path || '';
|
||||
breadcrumb.value = data.breadcrumb || [];
|
||||
items.value = data.items || [];
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Could not load this folder';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => load(''));
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
const q = search.value.trim().toLowerCase();
|
||||
if (!q) return items.value;
|
||||
return items.value.filter((item) => item.name.toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
const openFolder = (item) => {
|
||||
if (item.type !== 'dir') return;
|
||||
load(item.path);
|
||||
};
|
||||
|
||||
const goToCrumb = (crumbPath) => load(crumbPath);
|
||||
const goToRoot = () => load('');
|
||||
|
||||
const newFolder = async () => {
|
||||
const name = window.prompt('New folder name:');
|
||||
if (!name) return;
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
try {
|
||||
const data = await request('/file-manager/folder', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: path.value, name }),
|
||||
});
|
||||
notice.value = data.message || 'Folder created.';
|
||||
await load();
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Could not create folder';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const newFile = async () => {
|
||||
const name = window.prompt('New file name:');
|
||||
if (!name) return;
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
try {
|
||||
const data = await request('/file-manager/file', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: path.value, name }),
|
||||
});
|
||||
notice.value = data.message || 'File created.';
|
||||
await load();
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Could not create file';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const triggerUpload = () => fileInput.value?.click();
|
||||
|
||||
const onFileSelected = async (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
if (!file) return;
|
||||
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('path', path.value);
|
||||
formData.append('file', file);
|
||||
const data = await request('/file-manager/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
notice.value = data.message || 'File uploaded.';
|
||||
await load();
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Upload failed';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const renameItem = async (item) => {
|
||||
const newName = window.prompt('Rename to:', item.name);
|
||||
if (!newName || newName === item.name) return;
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
try {
|
||||
const data = await request('/file-manager/rename', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: item.path, newName }),
|
||||
});
|
||||
notice.value = data.message || 'Renamed.';
|
||||
await load();
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Rename failed';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteItem = async (item) => {
|
||||
const warning =
|
||||
item.type === 'dir'
|
||||
? `Permanently delete the folder "${item.name}" and everything inside it? This cannot be undone.`
|
||||
: `Permanently delete "${item.name}"? This cannot be undone.`;
|
||||
if (!window.confirm(warning)) return;
|
||||
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
try {
|
||||
const data = await request('/file-manager/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: item.path }),
|
||||
});
|
||||
notice.value = data.message || 'Deleted.';
|
||||
await load();
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Delete failed';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const downloadUrl = (item) => {
|
||||
const params = new URLSearchParams({
|
||||
path: item.path,
|
||||
_wpnonce: props.serverData.restNonce,
|
||||
});
|
||||
return `${apiBase.value}/file-manager/download?${params.toString()}`;
|
||||
};
|
||||
|
||||
const openEditor = async (item) => {
|
||||
error.value = '';
|
||||
try {
|
||||
const params = new URLSearchParams({ path: item.path });
|
||||
const data = await request(`/file-manager/read?${params.toString()}`);
|
||||
editing.value = { path: item.path, name: item.name, isWritable: data.isWritable };
|
||||
editorContent.value = data.content;
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Could not open this file';
|
||||
}
|
||||
};
|
||||
|
||||
const closeEditor = () => {
|
||||
editing.value = null;
|
||||
editorContent.value = '';
|
||||
};
|
||||
|
||||
const saveEditor = async () => {
|
||||
if (!editing.value) return;
|
||||
savingEdit.value = true;
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
try {
|
||||
const data = await request('/file-manager/write', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: editing.value.path, content: editorContent.value }),
|
||||
});
|
||||
notice.value = data.message || 'Saved.';
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Save failed';
|
||||
} finally {
|
||||
savingEdit.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const icon = (item) => {
|
||||
if (item.type === 'dir') return 'dashicons-portfolio';
|
||||
const ext = item.extension;
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'ico', 'bmp'].includes(ext)) return 'dashicons-format-image';
|
||||
if (['zip', 'gz', 'tar', 'rar', '7z'].includes(ext)) return 'dashicons-media-archive';
|
||||
if (['mp3', 'wav', 'ogg'].includes(ext)) return 'dashicons-format-audio';
|
||||
if (['mp4', 'mov', 'avi', 'webm'].includes(ext)) return 'dashicons-format-video';
|
||||
if (['php', 'js', 'css', 'json', 'html', 'htm', 'xml', 'sql', 'yml', 'yaml'].includes(ext)) return 'dashicons-media-code';
|
||||
return 'dashicons-media-default';
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="fm-page">
|
||||
<header class="fm-page__header">
|
||||
<div>
|
||||
<p class="fm-page__eyebrow">Tools</p>
|
||||
<h1>File Manager</h1>
|
||||
</div>
|
||||
<div class="fm-page__actions">
|
||||
<button type="button" class="icon-button" title="New folder" :disabled="busy" @click="newFolder">
|
||||
<span class="dashicons dashicons-plus-alt2" /> Folder
|
||||
</button>
|
||||
<button type="button" class="icon-button" title="New file" :disabled="busy" @click="newFile">
|
||||
<span class="dashicons dashicons-plus-alt2" /> File
|
||||
</button>
|
||||
<button type="button" class="icon-button" title="Upload file" :disabled="busy" @click="triggerUpload">
|
||||
<span class="dashicons dashicons-upload" /> Upload
|
||||
</button>
|
||||
<input ref="fileInput" type="file" hidden @change="onFileSelected" />
|
||||
<button type="button" class="icon-button" title="Refresh" :disabled="loading" @click="load()">
|
||||
<span class="dashicons dashicons-update" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="fm-breadcrumb" aria-label="Folder path">
|
||||
<button type="button" class="fm-breadcrumb__item" @click="goToRoot">WordPress root</button>
|
||||
<template v-for="crumb in breadcrumb" :key="crumb.path">
|
||||
<span class="fm-breadcrumb__sep">/</span>
|
||||
<button type="button" class="fm-breadcrumb__item" @click="goToCrumb(crumb.path)">{{ crumb.label }}</button>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<section class="fm-toolbar">
|
||||
<label class="fm-search">
|
||||
<span class="dashicons dashicons-search" />
|
||||
<input v-model="search" type="search" placeholder="Filter this folder..." />
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<div v-if="notice" class="fm-notice fm-notice--success">{{ notice }}</div>
|
||||
<div v-if="error" class="fm-notice fm-notice--error">{{ error }}</div>
|
||||
|
||||
<section class="fm-table" aria-label="Files and folders">
|
||||
<div class="fm-table__head">
|
||||
<div>Name</div>
|
||||
<div>Size</div>
|
||||
<div>Modified</div>
|
||||
<div>Actions</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="fm-table__empty">Loading...</div>
|
||||
<div v-else-if="!filteredItems.length" class="fm-table__empty">This folder is empty.</div>
|
||||
|
||||
<article v-for="item in filteredItems" v-else :key="item.name" class="fm-row">
|
||||
<div
|
||||
class="fm-row__name"
|
||||
:class="{ 'fm-row__name--clickable': item.type === 'dir' }"
|
||||
@click="openFolder(item)"
|
||||
>
|
||||
<span class="dashicons" :class="icon(item)" />
|
||||
<span>{{ item.name }}</span>
|
||||
</div>
|
||||
<div class="fm-row__cell">{{ item.sizeLabel || '—' }}</div>
|
||||
<div class="fm-row__cell">{{ item.modified }}</div>
|
||||
<div class="fm-row__actions">
|
||||
<button
|
||||
v-if="item.isEditable"
|
||||
type="button"
|
||||
class="icon-button icon-button--inline"
|
||||
title="Edit"
|
||||
@click="openEditor(item)"
|
||||
>
|
||||
<span class="dashicons dashicons-edit" />
|
||||
</button>
|
||||
<a
|
||||
v-if="item.type === 'file'"
|
||||
class="icon-button icon-button--inline"
|
||||
title="Download"
|
||||
:href="downloadUrl(item)"
|
||||
>
|
||||
<span class="dashicons dashicons-download" />
|
||||
</a>
|
||||
<button type="button" class="icon-button icon-button--inline" title="Rename" @click="renameItem(item)">
|
||||
<span class="dashicons dashicons-tag" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-button icon-button--inline"
|
||||
title="Delete"
|
||||
:disabled="!item.isWritable"
|
||||
@click="deleteItem(item)"
|
||||
>
|
||||
<span class="dashicons dashicons-trash" />
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<!-- Text editor overlay -->
|
||||
<div v-if="editing" class="fm-editor-backdrop" @click.self="closeEditor">
|
||||
<div class="fm-editor">
|
||||
<header class="fm-editor__header">
|
||||
<strong>{{ editing.name }}</strong>
|
||||
<span v-if="!editing.isWritable" class="fm-editor__readonly">Read-only (not writable on disk)</span>
|
||||
<div class="fm-editor__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="button-primary"
|
||||
:disabled="!editing.isWritable || savingEdit"
|
||||
@click="saveEditor"
|
||||
>
|
||||
{{ savingEdit ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
<button type="button" class="icon-button" @click="closeEditor">
|
||||
<span class="dashicons dashicons-no-alt" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<textarea
|
||||
v-model="editorContent"
|
||||
class="fm-editor__textarea"
|
||||
spellcheck="false"
|
||||
:readonly="!editing.isWritable"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fm-page {
|
||||
min-height: calc(100vh - 112px);
|
||||
padding: 0;
|
||||
color: var(--aurora-text);
|
||||
background: transparent;
|
||||
}
|
||||
.fm-page__header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.fm-page__eyebrow {
|
||||
margin: 0 0 7px;
|
||||
color: var(--aurora-text-muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.fm-page h1 {
|
||||
margin: 0;
|
||||
color: var(--aurora-text);
|
||||
font-size: 1.45rem;
|
||||
line-height: 1.15;
|
||||
}
|
||||
.fm-page__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.icon-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 38px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--aurora-border);
|
||||
border-radius: 8px;
|
||||
color: var(--aurora-text);
|
||||
background: var(--aurora-bg-subtle);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.icon-button:hover:not(:disabled) {
|
||||
border-color: var(--aurora-text-muted);
|
||||
}
|
||||
.icon-button--inline {
|
||||
width: 30px;
|
||||
min-height: 30px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--aurora-text-muted);
|
||||
justify-content: center;
|
||||
}
|
||||
.icon-button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.button-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 34px;
|
||||
padding: 0 14px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: var(--aurora-accent);
|
||||
color: var(--aurora-accent-text);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.button-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.fm-breadcrumb {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 14px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.fm-breadcrumb__item {
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--aurora-text-muted);
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.fm-breadcrumb__item:hover {
|
||||
color: var(--aurora-text);
|
||||
background: var(--aurora-bg-subtle);
|
||||
}
|
||||
.fm-breadcrumb__sep {
|
||||
color: var(--aurora-text-muted);
|
||||
}
|
||||
.fm-toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.fm-search {
|
||||
width: min(320px, 100%);
|
||||
min-height: 38px;
|
||||
border: 1px solid var(--aurora-border);
|
||||
border-radius: 8px;
|
||||
background: var(--aurora-bg-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 12px;
|
||||
color: var(--aurora-text-muted);
|
||||
}
|
||||
.fm-search input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
color: var(--aurora-text);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
.fm-notice {
|
||||
margin: 0 0 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--aurora-bg-subtle);
|
||||
color: var(--aurora-text);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
.fm-table {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--aurora-frame-border);
|
||||
border-radius: 8px;
|
||||
background: var(--aurora-frame-bg);
|
||||
}
|
||||
.fm-table__head,
|
||||
.fm-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(200px, 1.8fr) 120px 180px 130px;
|
||||
align-items: center;
|
||||
}
|
||||
.fm-table__head {
|
||||
min-height: 44px;
|
||||
border-bottom: 1px solid var(--aurora-border);
|
||||
color: var(--aurora-text);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 850;
|
||||
}
|
||||
.fm-table__head > div {
|
||||
padding: 0 16px;
|
||||
}
|
||||
.fm-row {
|
||||
min-height: 48px;
|
||||
border-bottom: 1px solid var(--aurora-border);
|
||||
}
|
||||
.fm-row:hover {
|
||||
background: var(--aurora-bg-subtle);
|
||||
}
|
||||
.fm-row__name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
padding: 10px 16px;
|
||||
color: var(--aurora-text);
|
||||
font-size: 0.84rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
.fm-row__name span:last-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.fm-row__name .dashicons {
|
||||
color: var(--aurora-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.fm-row__name--clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.fm-row__name--clickable:hover {
|
||||
color: var(--aurora-accent);
|
||||
}
|
||||
.fm-row__cell {
|
||||
min-width: 0;
|
||||
padding: 10px 16px;
|
||||
color: var(--aurora-text-muted);
|
||||
font-size: 0.78rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.fm-row__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
.fm-table__empty {
|
||||
padding: 38px 18px;
|
||||
color: var(--aurora-text-muted);
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
}
|
||||
.fm-editor-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100000;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 30px;
|
||||
}
|
||||
.fm-editor {
|
||||
width: min(900px, 100%);
|
||||
height: min(640px, 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--aurora-frame-border);
|
||||
border-radius: 10px;
|
||||
background: var(--aurora-frame-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.fm-editor__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--aurora-border);
|
||||
color: var(--aurora-text);
|
||||
}
|
||||
.fm-editor__readonly {
|
||||
font-size: 0.75rem;
|
||||
color: var(--aurora-text-muted);
|
||||
}
|
||||
.fm-editor__actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.fm-editor__textarea {
|
||||
flex: 1;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
resize: none;
|
||||
padding: 16px;
|
||||
background: var(--aurora-bg-subtle);
|
||||
color: var(--aurora-text);
|
||||
font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.fm-table__head {
|
||||
display: none;
|
||||
}
|
||||
.fm-row {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 6px 0;
|
||||
}
|
||||
.fm-row__cell,
|
||||
.fm-row__actions {
|
||||
padding: 4px 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,148 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
serverData: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const files = ref([]);
|
||||
const dragging = ref(false);
|
||||
const fileInput = ref(null);
|
||||
|
||||
const addFiles = (fileList) => {
|
||||
files.value.push(
|
||||
...Array.from(fileList).map((file) => ({
|
||||
file,
|
||||
name: file.name,
|
||||
status: 'pending', // pending | uploading | done | error
|
||||
message: '',
|
||||
url: '',
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
||||
const onDrop = (e) => {
|
||||
dragging.value = false;
|
||||
addFiles(e.dataTransfer.files);
|
||||
};
|
||||
|
||||
const onPick = (e) => {
|
||||
addFiles(e.target.files);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const uploadOne = async (entry) => {
|
||||
entry.status = 'uploading';
|
||||
try {
|
||||
const res = await fetch(`${props.serverData.restUrl}wp/v2/media`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-WP-Nonce': props.serverData.restNonce,
|
||||
'Content-Disposition': `attachment; filename="${entry.file.name}"`,
|
||||
'Content-Type': entry.file.type || 'application/octet-stream',
|
||||
},
|
||||
body: entry.file,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.message || 'Upload failed.');
|
||||
entry.status = 'done';
|
||||
entry.url = data.source_url || '';
|
||||
} catch (e) {
|
||||
entry.status = 'error';
|
||||
entry.message = e.message || 'Upload failed.';
|
||||
}
|
||||
};
|
||||
|
||||
const uploadAll = () => {
|
||||
files.value.filter((f) => f.status === 'pending').forEach(uploadOne);
|
||||
};
|
||||
|
||||
const clearDone = () => {
|
||||
files.value = files.value.filter((f) => f.status !== 'done');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="media-upload-page">
|
||||
<header class="media-upload-page__header">
|
||||
<div>
|
||||
<p class="media-upload-page__eyebrow">Media</p>
|
||||
<h1>Add Media File</h1>
|
||||
</div>
|
||||
<a class="button" :href="serverData.libraryUrl">Back to Media Library</a>
|
||||
</header>
|
||||
|
||||
<div
|
||||
class="media-upload-drop"
|
||||
:class="{ 'media-upload-drop--active': dragging }"
|
||||
@dragover.prevent="dragging = true"
|
||||
@dragleave.prevent="dragging = false"
|
||||
@drop.prevent="onDrop"
|
||||
@click="fileInput.click()"
|
||||
>
|
||||
<span class="dashicons dashicons-upload" />
|
||||
<p>Drop files here, or click to select files</p>
|
||||
<input ref="fileInput" type="file" multiple hidden @change="onPick" />
|
||||
</div>
|
||||
|
||||
<section v-if="files.length" class="media-upload-list">
|
||||
<div class="media-upload-list__actions">
|
||||
<button type="button" class="button-primary" @click="uploadAll">Upload All</button>
|
||||
<button type="button" class="button" @click="clearDone">Clear Completed</button>
|
||||
</div>
|
||||
|
||||
<div v-for="entry in files" :key="entry.name + entry.file.lastModified" class="media-upload-item">
|
||||
<span class="media-upload-item__name">{{ entry.name }}</span>
|
||||
<span class="media-upload-item__status" :class="`media-upload-item__status--${entry.status}`">
|
||||
<template v-if="entry.status === 'pending'">Ready</template>
|
||||
<template v-else-if="entry.status === 'uploading'">Uploading…</template>
|
||||
<template v-else-if="entry.status === 'done'">
|
||||
Uploaded — <a :href="entry.url" target="_blank" rel="noopener">View</a>
|
||||
</template>
|
||||
<template v-else>{{ entry.message }}</template>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.media-upload-page { max-width: 720px; margin: 0 auto; }
|
||||
.media-upload-page__header {
|
||||
display: flex; align-items: flex-start; justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.media-upload-page__eyebrow {
|
||||
margin: 0 0 4px; font-size: 0.75rem; text-transform: uppercase;
|
||||
letter-spacing: 0.06em; color: var(--aurora-text-muted);
|
||||
}
|
||||
.media-upload-page h1 { margin: 0; font-size: 1.6rem; font-weight: 700; color: var(--aurora-text); }
|
||||
|
||||
.media-upload-drop {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 10px; padding: 48px 24px; text-align: center; cursor: pointer;
|
||||
border: 2px dashed var(--aurora-border);
|
||||
border-radius: 14px;
|
||||
color: var(--aurora-text-muted);
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.media-upload-drop .dashicons { font-size: 32px; width: 32px; height: 32px; }
|
||||
.media-upload-drop--active,
|
||||
.media-upload-drop:hover { border-color: var(--aurora-accent); color: var(--aurora-text); }
|
||||
|
||||
.media-upload-list { margin-top: 20px; }
|
||||
.media-upload-list__actions { display: flex; gap: 8px; margin-bottom: 12px; }
|
||||
|
||||
.media-upload-item {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px 14px; border-radius: 8px;
|
||||
background: var(--aurora-bg-subtle);
|
||||
border: 1px solid var(--aurora-border);
|
||||
margin-bottom: 6px; font-size: 0.875rem;
|
||||
}
|
||||
.media-upload-item__name { color: var(--aurora-text); }
|
||||
.media-upload-item__status { color: var(--aurora-text-muted); }
|
||||
.media-upload-item__status--done { color: #22c55e; }
|
||||
.media-upload-item__status--error { color: #ef4444; }
|
||||
.media-upload-item__status a { color: inherit; text-decoration: underline; }
|
||||
</style>
|
||||
+386
-67
@@ -1,10 +1,14 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import MediaFolderTree from './MediaFolderTree.vue';
|
||||
|
||||
const props = defineProps({
|
||||
serverData: { type: Object, required: true },
|
||||
});
|
||||
|
||||
// Media Folders is a core feature — always on, no gating.
|
||||
const foldersAvailable = true;
|
||||
|
||||
const items = ref([]);
|
||||
const stats = ref({ all: 0, image: 0, audio: 0, video: 0, application: 0, unattached: 0 });
|
||||
const pagination = ref({ page: 1, perPage: 24, total: 0, totalPages: 1 });
|
||||
@@ -15,6 +19,16 @@ const notice = ref('');
|
||||
const search = ref('');
|
||||
const type = ref('all');
|
||||
|
||||
// Media Folders (paid) state — folderId 0 = All Media, -1 = Uncategorized,
|
||||
// matching MediaFolders::ALL_MEDIA / ::UNCATEGORIZED on the PHP side.
|
||||
const folderId = ref(0);
|
||||
const folderTree = ref([]);
|
||||
const allMediaCount = ref(0);
|
||||
const uncategorizedCount = ref(0);
|
||||
const foldersLoading = ref(false);
|
||||
const expanded = reactive({});
|
||||
const moveMenuFor = ref(0);
|
||||
|
||||
const apiBase = computed(() => `${props.serverData.restUrl}aurora-admin/v1`);
|
||||
|
||||
const request = async (path, options = {}) => {
|
||||
@@ -44,6 +58,7 @@ const load = async () => {
|
||||
page: String(pagination.value.page),
|
||||
perPage: String(pagination.value.perPage),
|
||||
});
|
||||
params.set('folderId', String(folderId.value));
|
||||
const data = await request(`/media?${params.toString()}`);
|
||||
items.value = data.items || [];
|
||||
stats.value = data.stats || stats.value;
|
||||
@@ -55,7 +70,99 @@ const load = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(load);
|
||||
const loadFolders = async () => {
|
||||
foldersLoading.value = true;
|
||||
try {
|
||||
const data = await request('/media-folders');
|
||||
folderTree.value = data.tree || [];
|
||||
allMediaCount.value = data.allMediaCount || 0;
|
||||
uncategorizedCount.value = data.uncategorizedCount || 0;
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Could not load folders';
|
||||
} finally {
|
||||
foldersLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const flattenFolders = (nodes, depth = 0, out = []) => {
|
||||
for (const node of nodes) {
|
||||
out.push({ id: node.id, name: node.name, depth });
|
||||
if (node.children && node.children.length) flattenFolders(node.children, depth + 1, out);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const flatFolders = computed(() => flattenFolders(folderTree.value));
|
||||
|
||||
const selectFolder = (id) => {
|
||||
folderId.value = id;
|
||||
pagination.value.page = 1;
|
||||
load();
|
||||
};
|
||||
|
||||
const toggleFolder = (id) => {
|
||||
expanded[id] = !expanded[id];
|
||||
};
|
||||
|
||||
const createFolder = async (parent) => {
|
||||
const name = window.prompt('New folder name:');
|
||||
if (!name) return;
|
||||
try {
|
||||
await request('/media-folders', { method: 'POST', body: JSON.stringify({ name, parent }) });
|
||||
if (parent) expanded[parent] = true;
|
||||
await loadFolders();
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Could not create folder';
|
||||
}
|
||||
};
|
||||
|
||||
const renameFolder = async (node) => {
|
||||
const name = window.prompt('Rename folder:', node.name);
|
||||
if (!name || name === node.name) return;
|
||||
try {
|
||||
await request(`/media-folders/${node.id}`, { method: 'POST', body: JSON.stringify({ name }) });
|
||||
await loadFolders();
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Could not rename folder';
|
||||
}
|
||||
};
|
||||
|
||||
const deleteFolder = async (node) => {
|
||||
if (!window.confirm(`Delete "${node.name}"? Its media will become uncategorized.`)) return;
|
||||
try {
|
||||
await request(`/media-folders/${node.id}`, { method: 'DELETE' });
|
||||
if (folderId.value === node.id) selectFolder(0);
|
||||
else await loadFolders();
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Could not delete folder';
|
||||
}
|
||||
};
|
||||
|
||||
const reparentFolder = async ({ id, parent }) => {
|
||||
try {
|
||||
await request(`/media-folders/${id}`, { method: 'POST', body: JSON.stringify({ parent }) });
|
||||
await loadFolders();
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Could not move folder';
|
||||
}
|
||||
};
|
||||
|
||||
const assignToFolder = async (folderIdTarget, attachmentIds) => {
|
||||
try {
|
||||
await request('/media-folders/assign', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ folderId: folderIdTarget, attachmentIds }),
|
||||
});
|
||||
moveMenuFor.value = 0;
|
||||
await Promise.all([load(), loadFolders()]);
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Could not move media';
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
load();
|
||||
loadFolders();
|
||||
});
|
||||
|
||||
let searchTimer = 0;
|
||||
watch(search, () => {
|
||||
@@ -136,77 +243,168 @@ const typeIcon = (mimeType) => {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="media-page__toolbar" aria-label="Media filters and search">
|
||||
<div class="media-page__filters">
|
||||
<button
|
||||
v-for="filter in filters"
|
||||
:key="filter.key"
|
||||
type="button"
|
||||
class="filter-pill"
|
||||
:class="{ 'is-active': type === filter.key }"
|
||||
@click="setType(filter.key)"
|
||||
>
|
||||
<span>{{ filter.label }}</span>
|
||||
<strong>{{ filter.count }}</strong>
|
||||
</button>
|
||||
</div>
|
||||
<label class="media-page__search">
|
||||
<span class="dashicons dashicons-search" />
|
||||
<input v-model="search" type="search" placeholder="Search media..." />
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<div v-if="notice" class="media-notice media-notice--success">{{ notice }}</div>
|
||||
<div v-if="error" class="media-notice media-notice--error">{{ error }}</div>
|
||||
|
||||
<div v-if="loading" class="media-grid__empty">Loading media...</div>
|
||||
<div v-else-if="!items.length" class="media-grid__empty">No media items match your filters.</div>
|
||||
|
||||
<section v-else class="media-grid" aria-label="Media items">
|
||||
<article v-for="item in items" :key="item.id" class="media-card">
|
||||
<div class="media-card__preview">
|
||||
<img v-if="item.isImage && item.thumbnailUrl" :src="item.thumbnailUrl" :alt="item.title" />
|
||||
<span v-else class="dashicons media-card__icon" :class="typeIcon(item.mimeType)" />
|
||||
<div class="media-card__overlay">
|
||||
<a class="icon-button icon-button--inline" title="Edit" :href="item.editUrl">
|
||||
<span class="dashicons dashicons-edit" />
|
||||
</a>
|
||||
<a
|
||||
class="icon-button icon-button--inline"
|
||||
title="View file"
|
||||
:href="item.fileUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<span class="dashicons dashicons-visibility" />
|
||||
</a>
|
||||
<div class="media-page__body">
|
||||
<aside class="media-page__sidebar">
|
||||
<div class="mf-pinned">
|
||||
<button
|
||||
v-if="item.canDelete"
|
||||
type="button"
|
||||
class="icon-button icon-button--inline"
|
||||
title="Delete permanently"
|
||||
:disabled="busyId === item.id"
|
||||
@click="deleteItem(item)"
|
||||
class="mf-pinned__row"
|
||||
:class="{ 'mf-pinned__row--active': folderId === 0 }"
|
||||
@dragover.prevent
|
||||
@drop.prevent="
|
||||
(e) => {
|
||||
const p = e.dataTransfer.getData('text/plain');
|
||||
if (p.startsWith('aurora-attachments:')) assignToFolder(0, p.split(':')[1].split(',').map(Number));
|
||||
}
|
||||
"
|
||||
@click="selectFolder(0)"
|
||||
>
|
||||
<span class="dashicons dashicons-trash" />
|
||||
<span class="dashicons dashicons-admin-media" />
|
||||
<span>All Media</span>
|
||||
<span class="mf-pinned__count">{{ allMediaCount }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="mf-pinned__row"
|
||||
:class="{ 'mf-pinned__row--active': folderId === -1 }"
|
||||
@dragover.prevent
|
||||
@drop.prevent="
|
||||
(e) => {
|
||||
const p = e.dataTransfer.getData('text/plain');
|
||||
if (p.startsWith('aurora-attachments:')) assignToFolder(-1, p.split(':')[1].split(',').map(Number));
|
||||
}
|
||||
"
|
||||
@click="selectFolder(-1)"
|
||||
>
|
||||
<span class="dashicons dashicons-editor-help" />
|
||||
<span>Uncategorized</span>
|
||||
<span class="mf-pinned__count">{{ uncategorizedCount }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="media-card__meta">
|
||||
<strong :title="item.title">{{ item.title }}</strong>
|
||||
<span>{{ item.typeLabel }}<template v-if="item.dimensions"> · {{ item.dimensions }}</template></span>
|
||||
<span v-if="item.attachedToTitle">
|
||||
Attached to
|
||||
<a v-if="item.attachedToEditUrl" :href="item.attachedToEditUrl">{{ item.attachedToTitle }}</a>
|
||||
<template v-else>{{ item.attachedToTitle }}</template>
|
||||
</span>
|
||||
<span v-else class="media-card__unattached">Unattached</span>
|
||||
<span>{{ item.date }}<template v-if="item.fileSize"> · {{ item.fileSize }}</template></span>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<footer v-if="!loading && items.length" class="media-page__footer">
|
||||
<div class="mf-sidebar__header">
|
||||
<span>Folders</span>
|
||||
<button type="button" class="icon-button icon-button--tiny" title="New folder" @click="createFolder(0)">
|
||||
<span class="dashicons dashicons-plus-alt2" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="foldersLoading" class="mf-sidebar__loading">Loading folders…</p>
|
||||
<p v-else-if="!folderTree.length" class="mf-sidebar__empty">No folders yet.</p>
|
||||
<MediaFolderTree
|
||||
v-else
|
||||
:nodes="folderTree"
|
||||
:active-folder-id="folderId"
|
||||
:expanded="expanded"
|
||||
@select="selectFolder"
|
||||
@toggle="toggleFolder"
|
||||
@create-child="createFolder"
|
||||
@rename="renameFolder"
|
||||
@delete="deleteFolder"
|
||||
@reparent="reparentFolder"
|
||||
@assign="({ folderId: fid, attachmentIds }) => assignToFolder(fid, attachmentIds)"
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<div class="media-page__main">
|
||||
<section class="media-page__toolbar" aria-label="Media filters and search">
|
||||
<div class="media-page__filters">
|
||||
<button
|
||||
v-for="filter in filters"
|
||||
:key="filter.key"
|
||||
type="button"
|
||||
class="filter-pill"
|
||||
:class="{ 'is-active': type === filter.key }"
|
||||
@click="setType(filter.key)"
|
||||
>
|
||||
<span>{{ filter.label }}</span>
|
||||
<strong>{{ filter.count }}</strong>
|
||||
</button>
|
||||
</div>
|
||||
<label class="media-page__search">
|
||||
<span class="dashicons dashicons-search" />
|
||||
<input v-model="search" type="search" placeholder="Search media..." />
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<div v-if="notice" class="media-notice media-notice--success">{{ notice }}</div>
|
||||
<div v-if="error" class="media-notice media-notice--error">{{ error }}</div>
|
||||
|
||||
<div v-if="loading" class="media-grid__empty">Loading media...</div>
|
||||
<div v-else-if="!items.length" class="media-grid__empty">No media items match your filters.</div>
|
||||
|
||||
<section v-else class="media-grid" aria-label="Media items">
|
||||
<article
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="media-card"
|
||||
:draggable="foldersAvailable"
|
||||
@dragstart="foldersAvailable && $event.dataTransfer.setData('text/plain', `aurora-attachments:${item.id}`)"
|
||||
>
|
||||
<div class="media-card__preview">
|
||||
<img v-if="item.isImage && item.thumbnailUrl" :src="item.thumbnailUrl" :alt="item.title" />
|
||||
<span v-else class="dashicons media-card__icon" :class="typeIcon(item.mimeType)" />
|
||||
<div class="media-card__overlay">
|
||||
<a class="icon-button icon-button--inline" title="Edit" :href="item.editUrl">
|
||||
<span class="dashicons dashicons-edit" />
|
||||
</a>
|
||||
<a
|
||||
class="icon-button icon-button--inline"
|
||||
title="View file"
|
||||
:href="item.fileUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<span class="dashicons dashicons-visibility" />
|
||||
</a>
|
||||
<button
|
||||
v-if="foldersAvailable"
|
||||
type="button"
|
||||
class="icon-button icon-button--inline"
|
||||
title="Move to folder"
|
||||
@click.stop="moveMenuFor = moveMenuFor === item.id ? 0 : item.id"
|
||||
>
|
||||
<span class="dashicons dashicons-portfolio" />
|
||||
</button>
|
||||
<button
|
||||
v-if="item.canDelete"
|
||||
type="button"
|
||||
class="icon-button icon-button--inline"
|
||||
title="Delete permanently"
|
||||
:disabled="busyId === item.id"
|
||||
@click="deleteItem(item)"
|
||||
>
|
||||
<span class="dashicons dashicons-trash" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="moveMenuFor === item.id" class="media-card__move-menu" @click.stop>
|
||||
<button type="button" @click="assignToFolder(0, [item.id])">Uncategorized</button>
|
||||
<button
|
||||
v-for="f in flatFolders"
|
||||
:key="f.id"
|
||||
type="button"
|
||||
:style="{ paddingLeft: 10 + f.depth * 14 + 'px' }"
|
||||
@click="assignToFolder(f.id, [item.id])"
|
||||
>
|
||||
{{ f.name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="media-card__meta">
|
||||
<strong :title="item.title">{{ item.title }}</strong>
|
||||
<span>{{ item.typeLabel }}<template v-if="item.dimensions"> · {{ item.dimensions }}</template></span>
|
||||
<span v-if="item.attachedToTitle">
|
||||
Attached to
|
||||
<a v-if="item.attachedToEditUrl" :href="item.attachedToEditUrl">{{ item.attachedToTitle }}</a>
|
||||
<template v-else>{{ item.attachedToTitle }}</template>
|
||||
</span>
|
||||
<span v-else class="media-card__unattached">Unattached</span>
|
||||
<span>{{ item.date }}<template v-if="item.fileSize"> · {{ item.fileSize }}</template></span>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<footer v-if="!loading && items.length" class="media-page__footer">
|
||||
<div>Page Size: {{ pagination.perPage }}</div>
|
||||
<div>{{ rowRange }} of {{ pagination.total }}</div>
|
||||
<div class="media-page__pager">
|
||||
@@ -233,6 +431,8 @@ const typeIcon = (mimeType) => {
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -515,5 +715,124 @@ const typeIcon = (mimeType) => {
|
||||
.media-page__footer {
|
||||
gap: 10px;
|
||||
}
|
||||
.media-page__body {
|
||||
flex-direction: column;
|
||||
}
|
||||
.media-page__sidebar {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.media-page__body {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
}
|
||||
.media-page__sidebar {
|
||||
flex-shrink: 0;
|
||||
width: 220px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--aurora-frame-border);
|
||||
border-radius: 10px;
|
||||
background: var(--aurora-frame-bg);
|
||||
}
|
||||
.media-page__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.mf-pinned {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid var(--aurora-border);
|
||||
}
|
||||
.mf-pinned__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: none;
|
||||
padding: 6px 6px;
|
||||
color: var(--aurora-text);
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.mf-pinned__row:hover {
|
||||
background: var(--aurora-bg-subtle);
|
||||
}
|
||||
.mf-pinned__row--active {
|
||||
background: var(--aurora-accent-soft);
|
||||
}
|
||||
.mf-pinned__row .dashicons {
|
||||
font-size: 15px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
color: var(--aurora-text-muted);
|
||||
}
|
||||
.mf-pinned__row span:nth-child(2) {
|
||||
flex: 1;
|
||||
}
|
||||
.mf-pinned__count {
|
||||
font-size: 0.7rem;
|
||||
color: var(--aurora-text-muted);
|
||||
}
|
||||
.mf-sidebar__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
color: var(--aurora-text-muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.icon-button--tiny {
|
||||
width: 22px;
|
||||
min-height: 22px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--aurora-text-muted);
|
||||
}
|
||||
.icon-button--tiny:hover {
|
||||
color: var(--aurora-text);
|
||||
}
|
||||
.mf-sidebar__loading,
|
||||
.mf-sidebar__empty {
|
||||
margin: 0;
|
||||
color: var(--aurora-text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.media-card__move-menu {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
max-height: 160px;
|
||||
min-width: 150px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--aurora-border);
|
||||
border-radius: 8px;
|
||||
background: var(--aurora-frame-bg);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
padding: 4px;
|
||||
}
|
||||
.media-card__move-menu button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
border: none;
|
||||
background: none;
|
||||
border-radius: 6px;
|
||||
color: var(--aurora-text);
|
||||
font-size: 0.78rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.media-card__move-menu button:hover {
|
||||
background: var(--aurora-bg-subtle);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
nodes: { type: Array, required: true },
|
||||
activeFolderId: { type: Number, required: true },
|
||||
expanded: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select', 'toggle', 'create-child', 'rename', 'delete', 'reparent', 'assign']);
|
||||
|
||||
// Drag-and-drop reparenting (folder onto folder) and media-into-folder both
|
||||
// go through the same 'text/plain' payload, prefixed so drop handlers can
|
||||
// tell them apart — custom MIME types are unreliable to read during
|
||||
// dragover in some browsers, plain text is not.
|
||||
const dragOverId = ref(0);
|
||||
|
||||
const onDragStart = (event, node) => {
|
||||
event.dataTransfer.setData('text/plain', `aurora-folder:${node.id}`);
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
const onDragOver = (event, node) => {
|
||||
event.preventDefault();
|
||||
dragOverId.value = node.id;
|
||||
};
|
||||
|
||||
const onDragLeave = (node) => {
|
||||
if (dragOverId.value === node.id) dragOverId.value = 0;
|
||||
};
|
||||
|
||||
const onDrop = (event, node) => {
|
||||
event.preventDefault();
|
||||
dragOverId.value = 0;
|
||||
const payload = event.dataTransfer.getData('text/plain');
|
||||
if (!payload) return;
|
||||
|
||||
if (payload.startsWith('aurora-folder:')) {
|
||||
const draggedId = Number(payload.split(':')[1]);
|
||||
if (draggedId && draggedId !== node.id) {
|
||||
emit('reparent', { id: draggedId, parent: node.id });
|
||||
}
|
||||
} else if (payload.startsWith('aurora-attachments:')) {
|
||||
const ids = payload
|
||||
.split(':')[1]
|
||||
.split(',')
|
||||
.map(Number)
|
||||
.filter(Boolean);
|
||||
if (ids.length) {
|
||||
emit('assign', { folderId: node.id, attachmentIds: ids });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const contextMenu = ref({ open: false, x: 0, y: 0, node: null });
|
||||
|
||||
const openMenu = (event, node) => {
|
||||
contextMenu.value = { open: true, x: event.clientX, y: event.clientY, node };
|
||||
setTimeout(() => document.addEventListener('click', closeMenu), 0);
|
||||
};
|
||||
|
||||
const closeMenu = () => {
|
||||
contextMenu.value.open = false;
|
||||
document.removeEventListener('click', closeMenu);
|
||||
};
|
||||
|
||||
const runFromMenu = (fn) => {
|
||||
closeMenu();
|
||||
fn();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="mf-tree">
|
||||
<li v-for="node in nodes" :key="node.id">
|
||||
<div
|
||||
class="mf-tree__row"
|
||||
:class="{ 'mf-tree__row--active': node.id === activeFolderId, 'mf-tree__row--dragover': dragOverId === node.id }"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart($event, node)"
|
||||
@dragover="onDragOver($event, node)"
|
||||
@dragleave="onDragLeave(node)"
|
||||
@drop="onDrop($event, node)"
|
||||
@contextmenu.prevent="openMenu($event, node)"
|
||||
>
|
||||
<button
|
||||
v-if="node.children && node.children.length"
|
||||
type="button"
|
||||
class="mf-tree__toggle"
|
||||
@click="emit('toggle', node.id)"
|
||||
>
|
||||
<span class="dashicons dashicons-arrow-right-alt2" :class="{ 'mf-tree__toggle-icon--open': expanded[node.id] }" />
|
||||
</button>
|
||||
<span v-else class="mf-tree__toggle-spacer" />
|
||||
<button type="button" class="mf-tree__label" @click="emit('select', node.id)">
|
||||
<span class="dashicons dashicons-portfolio" :style="node.color ? { color: node.color } : {}" />
|
||||
<span class="mf-tree__name">{{ node.name }}</span>
|
||||
<span class="mf-tree__count">{{ node.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<MediaFolderTree
|
||||
v-if="expanded[node.id] && node.children && node.children.length"
|
||||
:nodes="node.children"
|
||||
:active-folder-id="activeFolderId"
|
||||
:expanded="expanded"
|
||||
@select="emit('select', $event)"
|
||||
@toggle="emit('toggle', $event)"
|
||||
@create-child="emit('create-child', $event)"
|
||||
@rename="emit('rename', $event)"
|
||||
@delete="emit('delete', $event)"
|
||||
@reparent="emit('reparent', $event)"
|
||||
@assign="emit('assign', $event)"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<Teleport to="body">
|
||||
<ul v-if="contextMenu.open" class="mf-context-menu" :style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }" @contextmenu.prevent>
|
||||
<li><button type="button" @click="runFromMenu(() => emit('create-child', contextMenu.node.id))"><span class="dashicons dashicons-plus" /> New Subfolder</button></li>
|
||||
<li><button type="button" @click="runFromMenu(() => emit('rename', contextMenu.node))"><span class="dashicons dashicons-edit" /> Rename</button></li>
|
||||
<li class="mf-context-menu__sep" />
|
||||
<li><button type="button" class="mf-context-menu__danger" @click="runFromMenu(() => emit('delete', contextMenu.node))"><span class="dashicons dashicons-trash" /> Delete</button></li>
|
||||
</ul>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mf-tree { list-style: none; margin: 0; padding-left: 14px; }
|
||||
.mf-tree:first-of-type { padding-left: 0; }
|
||||
.mf-tree__row {
|
||||
display: flex; align-items: center; gap: 2px; border-radius: 6px;
|
||||
}
|
||||
.mf-tree__row:hover { background: var(--aurora-bg-subtle); }
|
||||
.mf-tree__row--active { background: var(--aurora-accent-soft); }
|
||||
.mf-tree__row--dragover { outline: 2px dashed var(--aurora-accent); outline-offset: -2px; }
|
||||
.mf-tree__toggle,
|
||||
.mf-tree__toggle-spacer {
|
||||
width: 18px; height: 24px; flex-shrink: 0;
|
||||
}
|
||||
.mf-tree__toggle {
|
||||
border: none; background: none; cursor: pointer;
|
||||
color: var(--aurora-text-muted); display: inline-flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.mf-tree__toggle .dashicons { font-size: 14px; width: 14px; height: 14px; transition: transform 0.15s; }
|
||||
.mf-tree__toggle-icon--open { transform: rotate(90deg); }
|
||||
.mf-tree__label {
|
||||
flex: 1; min-width: 0; display: flex; align-items: center; gap: 6px;
|
||||
border: none; background: none; cursor: pointer; text-align: left;
|
||||
padding: 3px 4px; color: var(--aurora-text); font-size: 0.8125rem;
|
||||
}
|
||||
.mf-tree__name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
|
||||
.mf-tree__label .dashicons:first-child { color: var(--aurora-text-muted); flex-shrink: 0; font-size: 15px; width: 15px; height: 15px; }
|
||||
.mf-tree__count { font-size: 0.7rem; color: var(--aurora-text-muted); flex-shrink: 0; }
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.mf-context-menu {
|
||||
position: fixed;
|
||||
z-index: 100000;
|
||||
min-width: 170px;
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
list-style: none;
|
||||
border: 1px solid var(--aurora-border);
|
||||
border-radius: 8px;
|
||||
background: var(--aurora-frame-bg);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.mf-context-menu button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
border: none;
|
||||
background: none;
|
||||
border-radius: 6px;
|
||||
color: var(--aurora-text);
|
||||
font-size: 0.8rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mf-context-menu button:hover { background: var(--aurora-bg-subtle); }
|
||||
.mf-context-menu button .dashicons { font-size: 16px; width: 16px; height: 16px; color: var(--aurora-text-muted); }
|
||||
.mf-context-menu__danger { color: #ef4444 !important; }
|
||||
.mf-context-menu__danger .dashicons { color: #ef4444 !important; }
|
||||
.mf-context-menu__sep {
|
||||
height: 1px;
|
||||
margin: 4px 6px;
|
||||
background: var(--aurora-border);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,215 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import SettingsField from '@/pages/settings/components/SettingsField.vue';
|
||||
|
||||
const props = defineProps({
|
||||
serverData: { type: Object, required: true },
|
||||
});
|
||||
|
||||
// Permalinks (rewrite rules) and Discussion (core's REST settings schema
|
||||
// only exposes 2 of its ~15 real options — see DiscussionData.php) both
|
||||
// have their own aurora-admin/v1 routes instead of going through
|
||||
// wp/v2/settings like every other native-settings screen.
|
||||
const isPermalinks = props.serverData.screen === 'permalinks';
|
||||
const isDiscussion = props.serverData.screen === 'discussion';
|
||||
|
||||
const values = reactive({ ...(props.serverData.values || {}) });
|
||||
|
||||
// Mirrors options-permalink.php's own preset list — the actual reason
|
||||
// people visit this screen; the "Custom Structure" text field alone
|
||||
// (this screen's only original field) skipped the part of the native
|
||||
// page virtually everyone actually uses instead of hand-writing tags.
|
||||
const permalinkPresets = [
|
||||
{ key: 'plain', label: 'Plain', structure: '', example: '?p=123' },
|
||||
{
|
||||
key: 'day',
|
||||
label: 'Day and name',
|
||||
structure: '/%year%/%monthnum%/%day%/%postname%/',
|
||||
example: '/2026/01/28/sample-post/',
|
||||
},
|
||||
{
|
||||
key: 'month',
|
||||
label: 'Month and name',
|
||||
structure: '/%year%/%monthnum%/%postname%/',
|
||||
example: '/2026/01/sample-post/',
|
||||
},
|
||||
{
|
||||
key: 'numeric',
|
||||
label: 'Numeric',
|
||||
structure: '/archives/%post_id%',
|
||||
example: '/archives/123',
|
||||
},
|
||||
{
|
||||
key: 'postname',
|
||||
label: 'Post name',
|
||||
structure: '/%postname%/',
|
||||
example: '/sample-post/',
|
||||
},
|
||||
{ key: 'custom', label: 'Custom Structure', structure: null, example: '' },
|
||||
];
|
||||
|
||||
// Fields can declare showIf: { key, equals } to only appear once another
|
||||
// field's current value matches — e.g. the front-page/posts-page pickers
|
||||
// only make sense once "Homepage Displays" is set to "A static page",
|
||||
// matching the native Reading screen's own conditional reveal.
|
||||
const visibleFields = computed(() =>
|
||||
props.serverData.fields.filter((f) => !f.showIf || values[f.showIf.key] === f.showIf.equals)
|
||||
);
|
||||
|
||||
const selectedPreset = computed(() => {
|
||||
const match = permalinkPresets.find(
|
||||
(p) => p.structure !== null && p.structure === (values.permalink_structure || '')
|
||||
);
|
||||
return match ? match.key : 'custom';
|
||||
});
|
||||
|
||||
const choosePreset = (preset) => {
|
||||
if (preset.key === 'custom') {
|
||||
// Leave whatever custom value is already there (or empty) — this
|
||||
// option just reveals the text field instead of assigning a value.
|
||||
if (selectedPreset.value !== 'custom') values.permalink_structure = '';
|
||||
return;
|
||||
}
|
||||
values.permalink_structure = preset.structure;
|
||||
};
|
||||
|
||||
const saving = ref(false);
|
||||
const savedMsg = ref('');
|
||||
|
||||
const save = async () => {
|
||||
saving.value = true;
|
||||
savedMsg.value = '';
|
||||
try {
|
||||
const url = isPermalinks
|
||||
? `${props.serverData.restUrl}aurora-admin/v1/permalinks`
|
||||
: isDiscussion
|
||||
? `${props.serverData.restUrl}aurora-admin/v1/discussion-settings`
|
||||
: `${props.serverData.restUrl}wp/v2/settings`;
|
||||
|
||||
const body = isPermalinks
|
||||
? { structure: values.permalink_structure || '' }
|
||||
: isDiscussion
|
||||
? {
|
||||
values: Object.fromEntries(
|
||||
props.serverData.fields.filter((f) => !f.readonly).map((f) => [f.key, values[f.key]])
|
||||
),
|
||||
}
|
||||
: Object.fromEntries(
|
||||
props.serverData.fields
|
||||
.filter((f) => !f.readonly)
|
||||
.map((f) => [f.key, values[f.key]])
|
||||
);
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': props.serverData.restNonce },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
savedMsg.value = 'Saved';
|
||||
setTimeout(() => (savedMsg.value = ''), 2500);
|
||||
} catch {
|
||||
savedMsg.value = 'Could not save';
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="native-settings">
|
||||
<header class="native-settings__head">
|
||||
<div>
|
||||
<h2 class="native-settings__title">{{ serverData.title }}</h2>
|
||||
<p class="native-settings__sub">{{ serverData.subtitle }}</p>
|
||||
</div>
|
||||
<div class="native-settings__save-wrap">
|
||||
<span v-if="savedMsg" class="native-settings__saved">{{ savedMsg }}</span>
|
||||
<button type="button" class="native-settings__save" :disabled="saving" @click="save">
|
||||
{{ saving ? 'Saving…' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="isPermalinks" class="native-settings__presets">
|
||||
<label v-for="preset in permalinkPresets" :key="preset.key" class="native-settings__preset">
|
||||
<input
|
||||
type="radio"
|
||||
name="permalink-preset"
|
||||
:checked="selectedPreset === preset.key"
|
||||
@change="choosePreset(preset)"
|
||||
/>
|
||||
<span class="native-settings__preset-label">{{ preset.label }}</span>
|
||||
<code v-if="preset.example" class="native-settings__preset-example">{{ preset.example }}</code>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="native-settings__fields">
|
||||
<SettingsField
|
||||
v-for="f in visibleFields"
|
||||
:key="f.key"
|
||||
:field="f"
|
||||
v-model="values[f.key]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.native-settings {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.native-settings__head {
|
||||
display: flex; align-items: flex-start; justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding-bottom: 22px;
|
||||
margin-bottom: 4px;
|
||||
border-bottom: 1px solid var(--aurora-frame-border);
|
||||
}
|
||||
.native-settings__title { font-size: 1.6rem; font-weight: 700; margin: 0; color: var(--aurora-text); }
|
||||
.native-settings__sub { margin: 6px 0 0; font-size: 0.875rem; color: var(--aurora-text-muted); }
|
||||
.native-settings__save-wrap { display: flex; align-items: center; gap: 12px; flex-shrink: 0; }
|
||||
.native-settings__saved { font-size: 0.8125rem; color: var(--aurora-text-muted); }
|
||||
.native-settings__save {
|
||||
background: var(--aurora-accent); color: #fff; border: none;
|
||||
border-radius: 9px; padding: 9px 20px; font-size: 0.875rem; font-weight: 500; cursor: pointer;
|
||||
}
|
||||
.native-settings__save:disabled { opacity: 0.6; cursor: default; }
|
||||
.native-settings__fields > :last-child { border-bottom: none; }
|
||||
|
||||
.native-settings__presets {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 18px 0;
|
||||
border-bottom: 1px solid var(--aurora-frame-border);
|
||||
}
|
||||
.native-settings__preset {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 0;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
color: var(--aurora-text);
|
||||
}
|
||||
.native-settings__preset input[type='radio'] {
|
||||
accent-color: var(--aurora-accent);
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.native-settings__preset-label {
|
||||
min-width: 150px;
|
||||
flex-shrink: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
.native-settings__preset-example {
|
||||
color: var(--aurora-text-muted);
|
||||
font-size: 0.8125rem;
|
||||
background: var(--aurora-bg-subtle);
|
||||
padding: 2px 8px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,437 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
serverData: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const api = (path) => `${props.serverData.restUrl}aurora-admin/v1${path}`;
|
||||
const headers = () => ({ 'Content-Type': 'application/json', 'X-WP-Nonce': props.serverData.restNonce });
|
||||
|
||||
const menus = ref([]);
|
||||
const activeMenuId = ref(null);
|
||||
const items = ref([]);
|
||||
const locations = ref([]);
|
||||
const pickers = ref({ pages: [], posts: [], categories: [] });
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
const notice = ref('');
|
||||
|
||||
const activeMenu = computed(() => menus.value.find((m) => m.id === activeMenuId.value));
|
||||
|
||||
// Flat list, ordered so parents appear before children and children are
|
||||
// visually indented — simpler than a tree render, same end result.
|
||||
const orderedItems = computed(() => {
|
||||
const byParent = {};
|
||||
items.value.forEach((i) => {
|
||||
(byParent[i.parent] ||= []).push(i);
|
||||
});
|
||||
Object.values(byParent).forEach((list) => list.sort((a, b) => a.order - b.order));
|
||||
|
||||
const walk = (parentId, depth) => {
|
||||
const out = [];
|
||||
(byParent[parentId] || []).forEach((item) => {
|
||||
out.push({ ...item, depth });
|
||||
out.push(...walk(item.id, depth + 1));
|
||||
});
|
||||
return out;
|
||||
};
|
||||
return walk(0, 0);
|
||||
});
|
||||
|
||||
const loadMenus = async () => {
|
||||
try {
|
||||
const res = await fetch(api('/nav-menus'), { headers: headers() });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.message || 'Could not load menus.');
|
||||
menus.value = data;
|
||||
if (!activeMenuId.value && menus.value.length) {
|
||||
activeMenuId.value = menus.value[0].id;
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e.message || 'Could not load menus.';
|
||||
}
|
||||
};
|
||||
|
||||
const loadItems = async () => {
|
||||
if (!activeMenuId.value) {
|
||||
items.value = [];
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await fetch(api(`/nav-menus/${activeMenuId.value}/items`), { headers: headers() });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.message || 'Could not load menu items.');
|
||||
items.value = data;
|
||||
} catch (e) {
|
||||
error.value = e.message || 'Could not load menu items.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadLocations = async () => {
|
||||
try {
|
||||
const res = await fetch(api('/nav-menus/locations'), { headers: headers() });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.message || 'Could not load locations.');
|
||||
locations.value = data;
|
||||
} catch (e) {
|
||||
error.value = e.message || 'Could not load locations.';
|
||||
}
|
||||
};
|
||||
|
||||
const loadPickers = async () => {
|
||||
try {
|
||||
const res = await fetch(api('/nav-menus/pickers'), { headers: headers() });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.message || 'Could not load pickers.');
|
||||
pickers.value = data;
|
||||
} catch (e) {
|
||||
error.value = e.message || 'Could not load pickers.';
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadMenus(), loadLocations(), loadPickers()]);
|
||||
await loadItems();
|
||||
});
|
||||
|
||||
const selectMenu = async (id) => {
|
||||
activeMenuId.value = id;
|
||||
await loadItems();
|
||||
};
|
||||
|
||||
// Create menu -----------------------------------------------------------
|
||||
const newMenuName = ref('');
|
||||
const creatingMenu = ref(false);
|
||||
const createMenu = async () => {
|
||||
if (!newMenuName.value.trim()) return;
|
||||
creatingMenu.value = true;
|
||||
try {
|
||||
const res = await fetch(api('/nav-menus'), {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({ name: newMenuName.value.trim() }),
|
||||
});
|
||||
const menu = await res.json();
|
||||
if (!res.ok) throw new Error(menu?.message || 'Could not create menu.');
|
||||
menus.value.push(menu);
|
||||
newMenuName.value = '';
|
||||
await selectMenu(menu.id);
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
} finally {
|
||||
creatingMenu.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteMenu = async () => {
|
||||
if (!activeMenu.value) return;
|
||||
if (!confirm(`Delete menu "${activeMenu.value.name}"? This can't be undone.`)) return;
|
||||
await fetch(api(`/nav-menus/${activeMenuId.value}`), { method: 'DELETE', headers: headers() });
|
||||
menus.value = menus.value.filter((m) => m.id !== activeMenuId.value);
|
||||
activeMenuId.value = menus.value[0]?.id || null;
|
||||
await loadItems();
|
||||
};
|
||||
|
||||
// Add items ---------------------------------------------------------------
|
||||
const addTab = ref('pages');
|
||||
const customLink = ref({ title: '', url: 'https://' });
|
||||
const selectedIds = ref([]);
|
||||
|
||||
const toggleSelected = (id) => {
|
||||
const i = selectedIds.value.indexOf(id);
|
||||
if (i === -1) selectedIds.value.push(id);
|
||||
else selectedIds.value.splice(i, 1);
|
||||
};
|
||||
|
||||
const addSelectedItems = async () => {
|
||||
if (!activeMenuId.value) return;
|
||||
const list = pickers.value[addTab.value] || [];
|
||||
const toAdd = list.filter((p) => selectedIds.value.includes(p.id));
|
||||
for (const obj of toAdd) {
|
||||
const type = addTab.value === 'pages' ? 'page' : addTab.value === 'posts' ? 'post' : 'category';
|
||||
await fetch(api(`/nav-menus/${activeMenuId.value}/items`), {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({ type, objectId: obj.id, title: obj.title, parent: 0, order: items.value.length + 1 }),
|
||||
});
|
||||
}
|
||||
selectedIds.value = [];
|
||||
await loadItems();
|
||||
await loadMenus();
|
||||
};
|
||||
|
||||
const addCustomLink = async () => {
|
||||
if (!activeMenuId.value || !customLink.value.url) return;
|
||||
await fetch(api(`/nav-menus/${activeMenuId.value}/items`), {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({
|
||||
type: 'custom',
|
||||
url: customLink.value.url,
|
||||
title: customLink.value.title || customLink.value.url,
|
||||
parent: 0,
|
||||
order: items.value.length + 1,
|
||||
}),
|
||||
});
|
||||
customLink.value = { title: '', url: 'https://' };
|
||||
await loadItems();
|
||||
await loadMenus();
|
||||
};
|
||||
|
||||
// Structure editing -------------------------------------------------------
|
||||
const removeItem = async (id) => {
|
||||
await fetch(api(`/nav-menus/items/${id}`), { method: 'DELETE', headers: headers() });
|
||||
await loadItems();
|
||||
await loadMenus();
|
||||
};
|
||||
|
||||
const persistOrder = async () => {
|
||||
const payload = orderedItems.value.map((it, idx) => ({ id: it.id, parent: it.parent, order: idx + 1 }));
|
||||
await fetch(api(`/nav-menus/${activeMenuId.value}/reorder`), {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({ items: payload }),
|
||||
});
|
||||
};
|
||||
|
||||
const moveUp = async (item) => {
|
||||
const idx = items.value.findIndex((i) => i.id === item.id);
|
||||
const siblings = items.value.filter((i) => i.parent === item.parent).sort((a, b) => a.order - b.order);
|
||||
const pos = siblings.findIndex((i) => i.id === item.id);
|
||||
if (pos <= 0) return;
|
||||
[siblings[pos - 1].order, siblings[pos].order] = [siblings[pos].order, siblings[pos - 1].order];
|
||||
await persistOrder();
|
||||
await loadItems();
|
||||
};
|
||||
|
||||
const moveDown = async (item) => {
|
||||
const siblings = items.value.filter((i) => i.parent === item.parent).sort((a, b) => a.order - b.order);
|
||||
const pos = siblings.findIndex((i) => i.id === item.id);
|
||||
if (pos === -1 || pos >= siblings.length - 1) return;
|
||||
[siblings[pos + 1].order, siblings[pos].order] = [siblings[pos].order, siblings[pos + 1].order];
|
||||
await persistOrder();
|
||||
await loadItems();
|
||||
};
|
||||
|
||||
const indent = async (item) => {
|
||||
const siblings = items.value.filter((i) => i.parent === item.parent).sort((a, b) => a.order - b.order);
|
||||
const pos = siblings.findIndex((i) => i.id === item.id);
|
||||
if (pos <= 0) return;
|
||||
item.parent = siblings[pos - 1].id;
|
||||
await persistOrder();
|
||||
await loadItems();
|
||||
};
|
||||
|
||||
const outdent = async (item) => {
|
||||
const parentItem = items.value.find((i) => i.id === item.parent);
|
||||
if (!parentItem) return;
|
||||
item.parent = parentItem.parent;
|
||||
await persistOrder();
|
||||
await loadItems();
|
||||
};
|
||||
|
||||
// Locations ---------------------------------------------------------------
|
||||
const assignLocation = async (slug, menuId) => {
|
||||
await fetch(api('/nav-menus/locations'), {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({ location: slug, menuId: menuId || 0 }),
|
||||
});
|
||||
notice.value = 'Location updated.';
|
||||
setTimeout(() => (notice.value = ''), 2000);
|
||||
await loadLocations();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="nav-menus-page">
|
||||
<header class="nav-menus-page__header">
|
||||
<div>
|
||||
<p class="nav-menus-page__eyebrow">Appearance</p>
|
||||
<h1>Menus</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="notice" class="nav-menus-notice nav-menus-notice--success">{{ notice }}</div>
|
||||
<div v-if="error" class="nav-menus-notice nav-menus-notice--error">{{ error }}</div>
|
||||
|
||||
<div class="nav-menus-layout">
|
||||
<!-- Left: menu list + locations -->
|
||||
<aside class="nav-menus-side">
|
||||
<h2>Your Menus</h2>
|
||||
<ul class="nav-menus-list">
|
||||
<li v-for="menu in menus" :key="menu.id">
|
||||
<button
|
||||
type="button"
|
||||
class="nav-menus-list__item"
|
||||
:class="{ 'is-active': menu.id === activeMenuId }"
|
||||
@click="selectMenu(menu.id)"
|
||||
>
|
||||
{{ menu.name }} <span>({{ menu.itemCount }})</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="nav-menus-create">
|
||||
<input v-model="newMenuName" type="text" placeholder="New menu name" @keyup.enter="createMenu" />
|
||||
<button type="button" class="button" :disabled="creatingMenu" @click="createMenu">Create</button>
|
||||
</div>
|
||||
|
||||
<h2>Manage Locations</h2>
|
||||
<div v-for="loc in locations" :key="loc.slug" class="nav-menus-location">
|
||||
<label>{{ loc.description }}</label>
|
||||
<select :value="loc.menuId || ''" @change="assignLocation(loc.slug, $event.target.value ? Number($event.target.value) : 0)">
|
||||
<option value="">— Not assigned —</option>
|
||||
<option v-for="menu in menus" :key="menu.id" :value="menu.id">{{ menu.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<p v-if="!locations.length" class="nav-menus-empty">This theme registered no menu locations.</p>
|
||||
</aside>
|
||||
|
||||
<!-- Middle: add items -->
|
||||
<section class="nav-menus-add">
|
||||
<h2>Add Menu Items</h2>
|
||||
<div class="nav-menus-add__tabs">
|
||||
<button type="button" :class="{ 'is-active': addTab === 'pages' }" @click="addTab = 'pages'">Pages</button>
|
||||
<button type="button" :class="{ 'is-active': addTab === 'posts' }" @click="addTab = 'posts'">Posts</button>
|
||||
<button type="button" :class="{ 'is-active': addTab === 'categories' }" @click="addTab = 'categories'">Categories</button>
|
||||
<button type="button" :class="{ 'is-active': addTab === 'custom' }" @click="addTab = 'custom'">Custom Link</button>
|
||||
</div>
|
||||
|
||||
<div v-if="addTab !== 'custom'" class="nav-menus-add__list">
|
||||
<label v-for="opt in pickers[addTab]" :key="opt.id" class="nav-menus-add__row">
|
||||
<input type="checkbox" :checked="selectedIds.includes(opt.id)" @change="toggleSelected(opt.id)" />
|
||||
<span>{{ opt.title }}</span>
|
||||
</label>
|
||||
<p v-if="!pickers[addTab]?.length" class="nav-menus-empty">Nothing to show.</p>
|
||||
<button v-if="pickers[addTab]?.length" type="button" class="button-primary" :disabled="!activeMenuId" @click="addSelectedItems">
|
||||
Add to Menu
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="nav-menus-add__custom">
|
||||
<input v-model="customLink.title" type="text" placeholder="Link Text" />
|
||||
<input v-model="customLink.url" type="text" placeholder="https://example.com" />
|
||||
<button type="button" class="button-primary" :disabled="!activeMenuId" @click="addCustomLink">Add to Menu</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Right: current structure -->
|
||||
<section class="nav-menus-structure">
|
||||
<div class="nav-menus-structure__head">
|
||||
<h2>{{ activeMenu?.name || 'Select a menu' }}</h2>
|
||||
<button v-if="activeMenu" type="button" class="icon-button" title="Delete menu" @click="deleteMenu">
|
||||
<span class="dashicons dashicons-trash" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="loading" class="nav-menus-empty">Loading…</p>
|
||||
<p v-else-if="!orderedItems.length" class="nav-menus-empty">No items in this menu yet — add some from the left.</p>
|
||||
|
||||
<div
|
||||
v-for="item in orderedItems"
|
||||
:key="item.id"
|
||||
class="nav-menus-item"
|
||||
:style="{ marginLeft: `${item.depth * 24}px` }"
|
||||
>
|
||||
<span class="nav-menus-item__title">{{ item.title }}</span>
|
||||
<span class="nav-menus-item__actions">
|
||||
<button type="button" title="Move up" @click="moveUp(item)"><span class="dashicons dashicons-arrow-up-alt2" /></button>
|
||||
<button type="button" title="Move down" @click="moveDown(item)"><span class="dashicons dashicons-arrow-down-alt2" /></button>
|
||||
<button type="button" title="Indent (make sub-item)" @click="indent(item)"><span class="dashicons dashicons-arrow-right-alt2" /></button>
|
||||
<button type="button" title="Outdent" :disabled="!item.parent" @click="outdent(item)"><span class="dashicons dashicons-arrow-left-alt2" /></button>
|
||||
<button type="button" title="Remove" @click="removeItem(item.id)"><span class="dashicons dashicons-no-alt" /></button>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.nav-menus-page { max-width: 1200px; margin: 0 auto; }
|
||||
.nav-menus-page__header { margin-bottom: 16px; }
|
||||
.nav-menus-page__eyebrow {
|
||||
margin: 0 0 4px; font-size: 0.75rem; text-transform: uppercase;
|
||||
letter-spacing: 0.06em; color: var(--aurora-text-muted);
|
||||
}
|
||||
.nav-menus-page h1 { margin: 0; font-size: 1.6rem; font-weight: 700; color: var(--aurora-text); }
|
||||
|
||||
.nav-menus-notice { padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; font-size: 0.875rem; }
|
||||
.nav-menus-notice--success { background: rgba(34, 197, 94, 0.12); color: #22c55e; }
|
||||
.nav-menus-notice--error { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
|
||||
|
||||
.nav-menus-layout { display: grid; grid-template-columns: 260px 300px 1fr; gap: 20px; align-items: start; }
|
||||
.nav-menus-side, .nav-menus-add, .nav-menus-structure {
|
||||
background: var(--aurora-bg-subtle); border: 1px solid var(--aurora-border);
|
||||
border-radius: 14px; padding: 18px;
|
||||
}
|
||||
.nav-menus-side h2, .nav-menus-add h2, .nav-menus-structure h2 {
|
||||
font-size: 0.9375rem; margin: 0 0 10px; color: var(--aurora-text);
|
||||
}
|
||||
.nav-menus-list { list-style: none; margin: 0 0 14px; padding: 0; }
|
||||
.nav-menus-list__item {
|
||||
width: 100%; text-align: left; padding: 8px 10px; border-radius: 8px;
|
||||
border: none; background: none; color: var(--aurora-text-muted); cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.nav-menus-list__item span { color: var(--aurora-text-muted); }
|
||||
.nav-menus-list__item:hover { background: var(--aurora-bg); }
|
||||
.nav-menus-list__item.is-active { background: var(--aurora-accent-soft); color: var(--aurora-accent); }
|
||||
|
||||
.nav-menus-create { display: flex; gap: 6px; margin-bottom: 20px; }
|
||||
.nav-menus-create input {
|
||||
flex: 1; padding: 7px 10px; border-radius: 7px; border: 1px solid var(--aurora-border);
|
||||
background: var(--aurora-bg); color: var(--aurora-text); font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.nav-menus-location { display: flex; flex-direction: column; gap: 4px; margin-bottom: 10px; }
|
||||
.nav-menus-location label { font-size: 0.8125rem; color: var(--aurora-text-muted); }
|
||||
.nav-menus-location select,
|
||||
.nav-menus-add__custom input {
|
||||
padding: 7px 10px; border-radius: 7px; border: 1px solid var(--aurora-border);
|
||||
background: var(--aurora-bg); color: var(--aurora-text); font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.nav-menus-add__tabs { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
|
||||
.nav-menus-add__tabs button {
|
||||
padding: 6px 10px; border-radius: 7px; border: 1px solid var(--aurora-border);
|
||||
background: var(--aurora-bg); color: var(--aurora-text-muted); font-size: 0.75rem; cursor: pointer;
|
||||
}
|
||||
.nav-menus-add__tabs button.is-active { background: var(--aurora-accent-soft); color: var(--aurora-accent); border-color: transparent; }
|
||||
|
||||
.nav-menus-add__list { max-height: 320px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; }
|
||||
.nav-menus-add__row {
|
||||
display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 6px;
|
||||
font-size: 0.8125rem; color: var(--aurora-text);
|
||||
}
|
||||
.nav-menus-add__row:hover { background: var(--aurora-bg); }
|
||||
.nav-menus-add__list .button-primary { margin-top: 10px; width: 100%; }
|
||||
|
||||
.nav-menus-add__custom { display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
.nav-menus-structure__head { display: flex; align-items: center; justify-content: space-between; }
|
||||
.nav-menus-item {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 8px 12px; border-radius: 8px; margin-bottom: 4px;
|
||||
background: var(--aurora-bg); border: 1px solid var(--aurora-border);
|
||||
font-size: 0.8125rem; color: var(--aurora-text);
|
||||
}
|
||||
.nav-menus-item__actions { display: flex; gap: 2px; }
|
||||
.nav-menus-item__actions button {
|
||||
border: none; background: none; color: var(--aurora-text-muted); cursor: pointer;
|
||||
width: 26px; height: 26px; border-radius: 6px; display: inline-flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.nav-menus-item__actions button:hover { background: var(--aurora-bg-subtle); color: var(--aurora-text); }
|
||||
.nav-menus-item__actions button:disabled { opacity: 0.35; cursor: default; }
|
||||
|
||||
.nav-menus-empty { color: var(--aurora-text-muted); font-size: 0.8125rem; }
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.nav-menus-layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,205 @@
|
||||
<script setup>
|
||||
import { reactive, ref, onMounted } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
serverData: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const headers = () => ({ 'Content-Type': 'application/json', 'X-WP-Nonce': props.serverData.restNonce });
|
||||
const meUrl = `${props.serverData.restUrl}wp/v2/users/me?context=edit`;
|
||||
|
||||
const form = reactive({
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
nickname: '',
|
||||
email: '',
|
||||
url: '',
|
||||
description: '',
|
||||
});
|
||||
|
||||
const loading = ref(true);
|
||||
const saving = ref(false);
|
||||
const notice = ref('');
|
||||
const error = ref('');
|
||||
|
||||
const load = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await fetch(meUrl, { headers: headers() });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.message || 'Could not load your profile.');
|
||||
form.first_name = data.first_name || '';
|
||||
form.last_name = data.last_name || '';
|
||||
form.nickname = data.nickname || '';
|
||||
form.email = data.email || '';
|
||||
form.url = data.url || '';
|
||||
form.description = data.description || '';
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(load);
|
||||
|
||||
const save = async () => {
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
try {
|
||||
const res = await fetch(meUrl, {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.message || 'Could not save your profile.');
|
||||
notice.value = 'Profile updated.';
|
||||
setTimeout(() => (notice.value = ''), 2500);
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Password ----------------------------------------------------------------
|
||||
const newPassword = ref('');
|
||||
const changingPassword = ref(false);
|
||||
const passwordNotice = ref('');
|
||||
const passwordError = ref('');
|
||||
|
||||
const generatePassword = () => {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%';
|
||||
newPassword.value = Array.from({ length: 16 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
|
||||
};
|
||||
|
||||
const changePassword = async () => {
|
||||
passwordError.value = '';
|
||||
passwordNotice.value = '';
|
||||
if (!newPassword.value) return;
|
||||
|
||||
changingPassword.value = true;
|
||||
try {
|
||||
const res = await fetch(`${props.serverData.restUrl}aurora-admin/v1/profile/password`, {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({ password: newPassword.value }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.message || 'Could not change your password.');
|
||||
passwordNotice.value = 'Password updated.';
|
||||
newPassword.value = '';
|
||||
setTimeout(() => (passwordNotice.value = ''), 2500);
|
||||
} catch (e) {
|
||||
passwordError.value = e.message;
|
||||
} finally {
|
||||
changingPassword.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="profile-page">
|
||||
<header class="profile-page__header">
|
||||
<div>
|
||||
<p class="profile-page__eyebrow">Accounts</p>
|
||||
<h1>Your Profile</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="notice" class="profile-notice profile-notice--success">{{ notice }}</div>
|
||||
<div v-if="error" class="profile-notice profile-notice--error">{{ error }}</div>
|
||||
|
||||
<p v-if="loading" class="profile-empty">Loading…</p>
|
||||
|
||||
<form v-else class="profile-form" @submit.prevent="save">
|
||||
<label class="profile-field">
|
||||
<span>First Name</span>
|
||||
<input v-model="form.first_name" type="text" />
|
||||
</label>
|
||||
<label class="profile-field">
|
||||
<span>Last Name</span>
|
||||
<input v-model="form.last_name" type="text" />
|
||||
</label>
|
||||
<label class="profile-field">
|
||||
<span>Nickname</span>
|
||||
<input v-model="form.nickname" type="text" />
|
||||
</label>
|
||||
<label class="profile-field">
|
||||
<span>Email</span>
|
||||
<input v-model="form.email" type="email" />
|
||||
</label>
|
||||
<label class="profile-field">
|
||||
<span>Website</span>
|
||||
<input v-model="form.url" type="text" />
|
||||
</label>
|
||||
<label class="profile-field">
|
||||
<span>Biographical Info</span>
|
||||
<textarea v-model="form.description" rows="4" />
|
||||
</label>
|
||||
|
||||
<div class="profile-form__actions">
|
||||
<button type="submit" class="button-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving…' : 'Update Profile' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section class="profile-password">
|
||||
<h2>Change Password</h2>
|
||||
<div v-if="passwordNotice" class="profile-notice profile-notice--success">{{ passwordNotice }}</div>
|
||||
<div v-if="passwordError" class="profile-notice profile-notice--error">{{ passwordError }}</div>
|
||||
<div class="profile-password__row">
|
||||
<input v-model="newPassword" type="text" placeholder="New password" />
|
||||
<button type="button" class="button" @click="generatePassword">Generate</button>
|
||||
<button type="button" class="button-primary" :disabled="changingPassword || !newPassword" @click="changePassword">
|
||||
{{ changingPassword ? 'Updating…' : 'Change Password' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.profile-page { max-width: 640px; margin: 0 auto; }
|
||||
.profile-page__header { margin-bottom: 16px; }
|
||||
.profile-page__eyebrow {
|
||||
margin: 0 0 4px; font-size: 0.75rem; text-transform: uppercase;
|
||||
letter-spacing: 0.06em; color: var(--aurora-text-muted);
|
||||
}
|
||||
.profile-page h1 { margin: 0; font-size: 1.6rem; font-weight: 700; color: var(--aurora-text); }
|
||||
.profile-empty { color: var(--aurora-text-muted); font-size: 0.8125rem; }
|
||||
|
||||
.profile-notice { padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; font-size: 0.875rem; }
|
||||
.profile-notice--success { background: rgba(34, 197, 94, 0.12); color: #22c55e; }
|
||||
.profile-notice--error { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
|
||||
.profile-notice--info { background: var(--aurora-bg-subtle); color: var(--aurora-text-muted); }
|
||||
.profile-notice--info a { color: var(--aurora-accent); }
|
||||
|
||||
.profile-form, .profile-password {
|
||||
display: flex; flex-direction: column; gap: 18px;
|
||||
background: var(--aurora-bg-subtle);
|
||||
border: 1px solid var(--aurora-border);
|
||||
border-radius: 14px;
|
||||
padding: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.profile-field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.profile-field span { font-size: 0.8125rem; font-weight: 600; color: var(--aurora-text); }
|
||||
.profile-field input,
|
||||
.profile-field textarea {
|
||||
padding: 9px 12px; border-radius: 8px; border: 1px solid var(--aurora-border);
|
||||
background: var(--aurora-bg); color: var(--aurora-text); font-size: 0.875rem;
|
||||
font-family: inherit; resize: vertical;
|
||||
}
|
||||
.profile-form__actions { margin-top: 4px; }
|
||||
|
||||
.profile-password h2 { margin: 0 0 4px; font-size: 0.9375rem; color: var(--aurora-text); }
|
||||
.profile-password__row { display: flex; gap: 8px; }
|
||||
.profile-password__row input {
|
||||
flex: 1; padding: 9px 12px; border-radius: 8px; border: 1px solid var(--aurora-border);
|
||||
background: var(--aurora-bg); color: var(--aurora-text); font-size: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, reactive, computed, onMounted } from 'vue';
|
||||
import AppListShell from '../_shared/AppListShell.vue';
|
||||
import AppListItem from '../_shared/AppListItem.vue';
|
||||
import { describeCapability } from './capabilityDescriptions.js';
|
||||
|
||||
const props = defineProps({
|
||||
serverData: { type: Object, required: true },
|
||||
@@ -145,10 +146,16 @@ const remove = async () => {
|
||||
<div class="role-editor__section">
|
||||
<h3>Capabilities</h3>
|
||||
<div class="role-editor__caps">
|
||||
<label v-for="cap in allCapabilities" :key="cap" class="role-editor__cap">
|
||||
<input type="checkbox" :checked="draft.capabilities.includes(cap)" @change="toggleCap(cap)" />
|
||||
{{ cap }}
|
||||
</label>
|
||||
<div v-for="cap in allCapabilities" :key="cap" class="role-editor__cap">
|
||||
<label>
|
||||
<input type="checkbox" :checked="draft.capabilities.includes(cap)" @change="toggleCap(cap)" />
|
||||
{{ cap }}
|
||||
</label>
|
||||
<span class="role-editor__cap-info" tabindex="0">
|
||||
<span class="dashicons dashicons-info-outline" />
|
||||
<span class="role-editor__tooltip" role="tooltip">{{ describeCapability(cap) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -186,10 +193,57 @@ const remove = async () => {
|
||||
color: var(--aurora-text-muted); margin: 0 0 8px;
|
||||
}
|
||||
.role-editor__caps {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 8px 16px; max-height: 380px; overflow-y: auto;
|
||||
/* Tooltips (position: absolute, below) need room to render outside the
|
||||
grid cell without getting clipped by the scroll container. */
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.role-editor__cap {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 6px;
|
||||
font-size: 0.8125rem; color: var(--aurora-text);
|
||||
}
|
||||
.role-editor__cap label { display: flex; align-items: center; gap: 6px; min-width: 0; }
|
||||
.role-editor__cap label span,
|
||||
.role-editor__cap label {
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
|
||||
.role-editor__cap-info {
|
||||
position: relative;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 18px; height: 18px; flex-shrink: 0;
|
||||
color: var(--aurora-text-muted); cursor: help;
|
||||
}
|
||||
.role-editor__cap-info .dashicons { font-size: 15px; width: 15px; height: 15px; }
|
||||
.role-editor__cap-info:hover { color: var(--aurora-accent); }
|
||||
|
||||
.role-editor__tooltip {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
right: -8px;
|
||||
z-index: 20;
|
||||
width: 260px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--aurora-frame-border);
|
||||
background: var(--aurora-frame-bg);
|
||||
color: var(--aurora-text);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
white-space: normal;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
.role-editor__cap-info:hover .role-editor__tooltip,
|
||||
.role-editor__cap-info:focus .role-editor__tooltip {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
.role-editor__cap { display: flex; align-items: center; gap: 6px; font-size: 0.8125rem; color: var(--aurora-text); }
|
||||
.role-editor__actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
.role-editor__actions button {
|
||||
border: none; border-radius: 8px; padding: 9px 18px; font-size: 0.875rem; cursor: pointer;
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Human-readable descriptions for WordPress's capability system, shown as
|
||||
* an info tooltip next to each capability checkbox in Role Editor.
|
||||
*
|
||||
* Two groups: the ~60 named capabilities WordPress core actually checks
|
||||
* (edit_posts, manage_options, etc.), and the legacy level_0…level_10
|
||||
* "user level" system from WordPress 1.x/early 2.x. Levels predate named
|
||||
* capabilities and are deprecated, but WordPress still creates them on
|
||||
* every default role for backward compatibility with old themes/plugins
|
||||
* that call current_user_can('level_X') or check $user->user_level
|
||||
* directly instead of a named capability — core itself hasn't checked a
|
||||
* level anywhere for years. They don't map to anything meaningful beyond
|
||||
* that compatibility shim, which is exactly why they look unexplained.
|
||||
*/
|
||||
const LEVEL_MATCH = /^level_(\d{1,2})$/;
|
||||
|
||||
const CORE_CAPABILITIES = {
|
||||
read: 'The baseline capability every logged-in user needs just to access wp-admin at all.',
|
||||
edit_posts: 'Edit their own posts.',
|
||||
edit_others_posts: "Edit posts authored by other users.",
|
||||
edit_private_posts: 'Edit private posts (not publicly visible).',
|
||||
edit_published_posts: 'Edit posts that have already been published.',
|
||||
publish_posts: 'Publish posts, rather than only saving them as drafts.',
|
||||
delete_posts: 'Delete their own posts.',
|
||||
delete_others_posts: "Delete posts authored by other users.",
|
||||
delete_private_posts: 'Delete private posts.',
|
||||
delete_published_posts: 'Delete posts that have already been published.',
|
||||
read_private_posts: "View private posts that aren't publicly visible.",
|
||||
|
||||
edit_pages: 'Edit their own pages.',
|
||||
edit_others_pages: "Edit pages authored by other users.",
|
||||
edit_private_pages: 'Edit private pages.',
|
||||
edit_published_pages: 'Edit pages that have already been published.',
|
||||
publish_pages: 'Publish pages, rather than only saving them as drafts.',
|
||||
delete_pages: 'Delete their own pages.',
|
||||
delete_others_pages: "Delete pages authored by other users.",
|
||||
delete_private_pages: 'Delete private pages.',
|
||||
delete_published_pages: 'Delete pages that have already been published.',
|
||||
read_private_pages: "View private pages that aren't publicly visible.",
|
||||
|
||||
moderate_comments: 'Approve, edit, and delete comments awaiting moderation.',
|
||||
manage_categories: 'Create, edit, and delete post categories and tags.',
|
||||
manage_links: 'Manage the legacy Links (blogroll) feature.',
|
||||
upload_files: 'Upload files to the Media Library.',
|
||||
|
||||
list_users: 'View the list of registered users.',
|
||||
create_users: 'Create new user accounts.',
|
||||
edit_users: "Edit other users' profiles and roles.",
|
||||
delete_users: 'Delete other user accounts.',
|
||||
promote_users: "Change other users' roles.",
|
||||
remove_users: 'Remove other users from a site (multisite: from this one site, not delete the account entirely).',
|
||||
|
||||
manage_options: "Change site-wide settings — one of the broadest capabilities short of being a full administrator.",
|
||||
edit_theme_options: 'Change theme settings such as menus, widgets, and the Customizer.',
|
||||
switch_themes: 'Activate a different installed theme.',
|
||||
install_themes: 'Install new themes from the WordPress theme directory or by file upload.',
|
||||
update_themes: 'Update installed themes to a newer version.',
|
||||
delete_themes: 'Delete theme files from the site.',
|
||||
edit_themes: 'Edit theme source code directly from the admin — high risk, equivalent to direct server file access.',
|
||||
|
||||
activate_plugins: 'Activate and deactivate plugins.',
|
||||
install_plugins: 'Install new plugins from the WordPress plugin directory or by file upload.',
|
||||
update_plugins: 'Update installed plugins to a newer version.',
|
||||
delete_plugins: 'Delete plugin files from the site.',
|
||||
edit_plugins: 'Edit plugin source code directly from the admin — high risk, equivalent to direct server file access.',
|
||||
|
||||
edit_files: 'Edit plugin and theme files directly from the admin (Theme/Plugin Editor) — high risk, equivalent to direct server file access.',
|
||||
edit_dashboard: "Customize the admin dashboard's own widgets.",
|
||||
update_core: 'Update WordPress core to a newer version.',
|
||||
import: 'Import content into the site from an external source (Tools › Import).',
|
||||
export: 'Export site content to a file (Tools › Export).',
|
||||
customize: 'Access the Theme Customizer.',
|
||||
|
||||
unfiltered_html: 'Post raw, unfiltered HTML and JavaScript in content — a real cross-site-scripting (XSS) risk if granted to anyone other than a fully trusted administrator.',
|
||||
unfiltered_upload: "Upload any file type without WordPress's normal file-type restrictions — a real security risk; only Administrators have this by default, and only on single-site installs.",
|
||||
|
||||
manage_network: '(Multisite) Access the network admin dashboard.',
|
||||
manage_sites: '(Multisite) Create, edit, and delete individual sites on the network.',
|
||||
manage_network_users: '(Multisite) Manage user accounts network-wide.',
|
||||
manage_network_plugins: '(Multisite) Manage plugins network-wide.',
|
||||
manage_network_themes: '(Multisite) Manage themes network-wide.',
|
||||
manage_network_options: '(Multisite) Change network-wide settings.',
|
||||
delete_site: '(Multisite) Delete this entire site.',
|
||||
create_sites: '(Multisite) Create new sites on the network.',
|
||||
};
|
||||
|
||||
export const describeCapability = (cap) => {
|
||||
const levelMatch = cap.match(LEVEL_MATCH);
|
||||
if (levelMatch) {
|
||||
const n = Number(levelMatch[1]);
|
||||
return (
|
||||
`Legacy WordPress "user level" ${n} (0–10 scale, 10 highest) from the old pre-2.0 permissions ` +
|
||||
"system. Deprecated in favor of the named capabilities below, but WordPress still assigns the " +
|
||||
"full set of levels a role qualifies for, purely so old themes/plugins that check " +
|
||||
`current_user_can('level_${n}') or a user's numeric level directly still work. Core itself hasn't ` +
|
||||
'checked a level in years — safe to leave alone; removing it only affects compatibility with very old code.'
|
||||
);
|
||||
}
|
||||
|
||||
return CORE_CAPABILITIES[cap] || 'Custom capability — not part of WordPress core. Likely added by a plugin or theme.';
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch } from 'vue';
|
||||
import { ref, reactive, computed, watch, nextTick } from 'vue';
|
||||
import { settingsCategories } from './settingsConfig.js';
|
||||
import SettingsField from './components/SettingsField.vue';
|
||||
import { applyThemePalette, applyFontFamily } from './applyLiveTheme.js';
|
||||
@@ -8,9 +8,27 @@ const props = defineProps({
|
||||
serverData: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const visibleCategories = settingsCategories;
|
||||
|
||||
// Shared REST fetcher for field types that need their own API calls beyond
|
||||
// the generic wp/v2/settings save flow (currently just 'companion-plugin').
|
||||
const api = (path, options = {}) =>
|
||||
fetch(`${props.serverData.restUrl}aurora-admin/v1${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'X-WP-Nonce': props.serverData.restNonce,
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {}),
|
||||
},
|
||||
}).then(async (r) => {
|
||||
const body = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(body.message || 'Request failed');
|
||||
return body;
|
||||
});
|
||||
|
||||
// Working copy of the settings, seeded from the server + field defaults.
|
||||
const settings = reactive({ ...(props.serverData.settings || {}) });
|
||||
settingsCategories.forEach((cat) =>
|
||||
visibleCategories.forEach((cat) =>
|
||||
cat.fields.forEach((f) => {
|
||||
if (settings[f.key] === undefined && f.default !== undefined) {
|
||||
settings[f.key] = f.default;
|
||||
@@ -21,25 +39,37 @@ settingsCategories.forEach((cat) =>
|
||||
// Active category, synced with the URL ?category= param.
|
||||
const initialCat = new URLSearchParams(window.location.search).get('category');
|
||||
const activeId = ref(
|
||||
settingsCategories.some((c) => c.id === initialCat) ? initialCat : settingsCategories[0].id
|
||||
visibleCategories.some((c) => c.id === initialCat) ? initialCat : visibleCategories[0].id
|
||||
);
|
||||
const activeCategory = computed(
|
||||
() => settingsCategories.find((c) => c.id === activeId.value) || settingsCategories[0]
|
||||
() => visibleCategories.find((c) => c.id === activeId.value) || visibleCategories[0]
|
||||
);
|
||||
|
||||
// The content panel scrolls independently of the nav (see .settings__content
|
||||
// below) so picking a category from a scrolled-down spot in the nav doesn't
|
||||
// require scrolling the whole page back up to see it. But the panel is one
|
||||
// persistent DOM node across category switches — its scrollTop doesn't reset
|
||||
// on its own unless the new content happens to be shorter, so without this
|
||||
// a long category could inherit a scrolled-down position from the previous
|
||||
// one and land on the same "content is off-screen" problem this was meant
|
||||
// to fix.
|
||||
const contentEl = ref(null);
|
||||
const selectCategory = (id) => {
|
||||
activeId.value = id;
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('category', id);
|
||||
window.history.replaceState({}, '', url);
|
||||
nextTick(() => {
|
||||
if (contentEl.value) contentEl.value.scrollTop = 0;
|
||||
});
|
||||
};
|
||||
|
||||
// Search filter over the nav.
|
||||
const query = ref('');
|
||||
const filteredCategories = computed(() => {
|
||||
const q = query.value.trim().toLowerCase();
|
||||
if (!q) return settingsCategories;
|
||||
return settingsCategories.filter(
|
||||
if (!q) return visibleCategories;
|
||||
return visibleCategories.filter(
|
||||
(c) =>
|
||||
c.title.toLowerCase().includes(q) || c.subtitle.toLowerCase().includes(q)
|
||||
);
|
||||
@@ -161,7 +191,7 @@ const importSettings = (e) => {
|
||||
</aside>
|
||||
|
||||
<!-- Right content panel -->
|
||||
<section class="settings__content">
|
||||
<section ref="contentEl" class="settings__content">
|
||||
<header class="settings__content-head">
|
||||
<div>
|
||||
<h2 class="settings__content-title">{{ activeCategory.title }}</h2>
|
||||
@@ -181,7 +211,8 @@ const importSettings = (e) => {
|
||||
:key="f.key"
|
||||
:field="f"
|
||||
:options="optionsFor(f)"
|
||||
:license-status="f.key === 'license_key' ? serverData.licenseStatus : null"
|
||||
:disabled="!!f.dependsOn && !settings[f.dependsOn]"
|
||||
:api="api"
|
||||
v-model="settings[f.key]"
|
||||
/>
|
||||
</div>
|
||||
@@ -194,20 +225,31 @@ const importSettings = (e) => {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
min-height: calc(100vh - var(--aurora-toolbar-h) - 60px);
|
||||
/* height, not min-height — the outer #wpbody-content card (shell-
|
||||
frame.css) already caps itself to the viewport and scrolls as a
|
||||
whole. Filling it exactly (rather than growing taller than it) is
|
||||
what lets .settings__nav and .settings__content become their own
|
||||
independent scroll regions below instead of both scrolling together
|
||||
as one long page. */
|
||||
height: calc(100vh - var(--aurora-toolbar-h) - 60px);
|
||||
margin: -32px; /* cancel the frame padding */
|
||||
padding: 22px; /* inset the two boxes; the frame's right gutter adds the rest */
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
/* No background here — html/body already paint the page gradient
|
||||
(shell-frame.css); repainting the same var(--aurora-app-bg) here was
|
||||
just a redundant duplicate layer on top of it. */
|
||||
}
|
||||
|
||||
/* Left nav — no background/border of its own; the page gradient shows
|
||||
straight through so it reads as one surface instead of a boxed panel. */
|
||||
straight through so it reads as one surface instead of a boxed panel.
|
||||
Scrolls independently of the content panel (own height + overflow)
|
||||
so its scroll position is preserved when you switch categories. */
|
||||
.settings__nav {
|
||||
width: 340px;
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
border-radius: 16px;
|
||||
padding: 24px 20px;
|
||||
box-sizing: border-box;
|
||||
@@ -261,11 +303,19 @@ const importSettings = (e) => {
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Right content — no background/border of its own, same reasoning. */
|
||||
/* Right content — no background/border of its own, same reasoning.
|
||||
Own height + overflow (independent of .settings__nav's) is the actual
|
||||
fix: clicking a category always shows its content from the top,
|
||||
whatever the nav's own scroll position is — resetScrollTop on
|
||||
category switch (see selectCategory() above) handles the case where
|
||||
this panel itself was already scrolled down. */
|
||||
.settings__content {
|
||||
flex: 1; min-width: 0;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
border-radius: 16px;
|
||||
padding: 28px 32px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.settings__content-head {
|
||||
display: flex; align-items: flex-start; justify-content: space-between;
|
||||
@@ -287,7 +337,11 @@ const importSettings = (e) => {
|
||||
.settings__fields > :last-child { border-bottom: none; }
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.settings { flex-direction: column; }
|
||||
.settings__nav { width: auto; }
|
||||
/* Independent-scroll panes only make sense side-by-side — stacked on a
|
||||
narrow screen, revert to one natural-height column so the page
|
||||
scrolls as a whole the normal way, same as before this change. */
|
||||
.settings { flex-direction: column; height: auto; overflow: visible; }
|
||||
.settings__nav { width: auto; height: auto; overflow: visible; }
|
||||
.settings__content { height: auto; overflow: visible; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import Toggle from '@/components/Toggle.vue';
|
||||
import { themePresets, buildPalette } from '@/pages/settings/themePresets.js';
|
||||
import { googleFonts } from '@/pages/settings/googleFonts.js';
|
||||
@@ -11,24 +11,15 @@ const props = defineProps({
|
||||
modelValue: { default: undefined },
|
||||
// Options for multiselect fields (resolved by the page from optionsSource).
|
||||
options: { type: Array, default: () => [] },
|
||||
// License::status()'s cached result, only passed for the license_key field.
|
||||
licenseStatus: { type: Object, default: null },
|
||||
// True when this field's `dependsOn` key is falsy — greys out the
|
||||
// control without hiding it, so the relationship stays visible.
|
||||
disabled: { type: Boolean, default: false },
|
||||
// Shared REST fetcher, only used by field types that need their own API
|
||||
// calls beyond the generic wp/v2/settings save flow (companion-plugin).
|
||||
api: { type: Function, default: null },
|
||||
});
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const licenseStatusText = computed(() => {
|
||||
const s = props.licenseStatus;
|
||||
if (!s || s.status === 'none') return null;
|
||||
if (s.status === 'active') {
|
||||
const plan = s.plan ? ` (${s.plan} plan)` : '';
|
||||
return { tone: 'ok', text: `License active${plan}${s.expires_at ? ` — renews/expires ${s.expires_at}` : ''}` };
|
||||
}
|
||||
if (s.status === 'unreachable') {
|
||||
return { tone: 'warn', text: s.message || 'Could not reach the license server — will retry automatically.' };
|
||||
}
|
||||
return { tone: 'error', text: s.message || 'License invalid.' };
|
||||
});
|
||||
|
||||
const val = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
@@ -54,21 +45,6 @@ const toggleValue = (value) => {
|
||||
emit('update:modelValue', next);
|
||||
};
|
||||
|
||||
// Remote sites helpers -------------------------------------------------------
|
||||
const sites = computed(() => (Array.isArray(props.modelValue) ? props.modelValue : []));
|
||||
const newSite = reactive({ url: '', username: '', app_password: '' });
|
||||
const addSite = () => {
|
||||
if (!newSite.url.trim()) return;
|
||||
emit('update:modelValue', [
|
||||
...sites.value,
|
||||
{ url: newSite.url.trim(), username: newSite.username.trim(), app_password: newSite.app_password },
|
||||
]);
|
||||
newSite.url = '';
|
||||
newSite.username = '';
|
||||
newSite.app_password = '';
|
||||
};
|
||||
const removeSite = (i) => emit('update:modelValue', sites.value.filter((_, idx) => idx !== i));
|
||||
|
||||
// Media picker (image fields) ---------------------------------------------
|
||||
const pickImage = () => {
|
||||
const wp = window.wp;
|
||||
@@ -103,16 +79,47 @@ const openFontList = () => {
|
||||
fontQuery.value = props.modelValue || '';
|
||||
fontOpen.value = true;
|
||||
};
|
||||
|
||||
// Companion plugin (Aurora File Manager) install card -----------------------
|
||||
const companionStatus = ref(null); // { installed, active } | null while loading
|
||||
const companionBusy = ref(false);
|
||||
const companionError = ref('');
|
||||
|
||||
const loadCompanionStatus = async () => {
|
||||
if (props.field.type !== 'companion-plugin' || !props.api) return;
|
||||
try {
|
||||
companionStatus.value = await props.api(props.field.statusEndpoint);
|
||||
} catch {
|
||||
companionStatus.value = { installed: false, active: false };
|
||||
}
|
||||
};
|
||||
if (props.field.type === 'companion-plugin') {
|
||||
loadCompanionStatus();
|
||||
}
|
||||
|
||||
const installCompanion = async () => {
|
||||
if (!props.api) return;
|
||||
companionBusy.value = true;
|
||||
companionError.value = '';
|
||||
try {
|
||||
const result = await props.api(props.field.installEndpoint, { method: 'POST' });
|
||||
if (!result.success) {
|
||||
companionError.value = result.message || 'Installation failed.';
|
||||
}
|
||||
await loadCompanionStatus();
|
||||
} catch (e) {
|
||||
companionError.value = e.message || 'Installation failed.';
|
||||
} finally {
|
||||
companionBusy.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="field" :id="field.key === 'license_key' ? 'license' : null">
|
||||
<div class="field">
|
||||
<div class="field__info">
|
||||
<div class="field__label">{{ field.label }}</div>
|
||||
<p v-if="field.description" class="field__desc">{{ field.description }}</p>
|
||||
<p v-if="licenseStatusText" class="field__license-status" :class="`field__license-status--${licenseStatusText.tone}`">
|
||||
{{ licenseStatusText.text }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field__control">
|
||||
@@ -122,14 +129,39 @@ const openFontList = () => {
|
||||
type="text"
|
||||
class="field__input"
|
||||
:placeholder="field.placeholder || ''"
|
||||
:readonly="!!field.readonly"
|
||||
:value="val || ''"
|
||||
@input="val = $event.target.value"
|
||||
/>
|
||||
|
||||
<!-- Textarea (multi-line plain text — moderation/blocklist fields) -->
|
||||
<textarea
|
||||
v-else-if="field.type === 'textarea'"
|
||||
class="field__input field__textarea"
|
||||
:placeholder="field.placeholder || ''"
|
||||
:rows="field.rows || 6"
|
||||
:value="val || ''"
|
||||
@input="val = $event.target.value"
|
||||
></textarea>
|
||||
|
||||
<!-- Select (plain dropdown, distinct from the multiselect checkbox list below) -->
|
||||
<select
|
||||
v-else-if="field.type === 'select'"
|
||||
class="field__input"
|
||||
:value="val ?? ''"
|
||||
:disabled="disabled"
|
||||
@change="val = $event.target.value"
|
||||
>
|
||||
<option v-for="opt in (field.options || options)" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<!-- Toggle -->
|
||||
<Toggle
|
||||
v-else-if="field.type === 'toggle'"
|
||||
:model-value="!!val"
|
||||
:disabled="disabled"
|
||||
@update:model-value="val = $event"
|
||||
/>
|
||||
|
||||
@@ -186,28 +218,6 @@ const openFontList = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Remote sites -->
|
||||
<div v-else-if="field.type === 'remote-sites'" class="field__sites">
|
||||
<div class="field__sites-add">
|
||||
<input v-model="newSite.url" type="text" class="field__input" placeholder="Site URL (e.g., https://example.com)" />
|
||||
<div class="field__sites-row">
|
||||
<input v-model="newSite.username" type="text" class="field__input" placeholder="Username" />
|
||||
<input v-model="newSite.app_password" type="password" class="field__input" placeholder="Application Password" />
|
||||
<button type="button" class="field__newitem" @click="addSite">Add Site</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field__sites-list">
|
||||
<div v-if="!sites.length" class="field__sites-empty">No remote sites configured yet</div>
|
||||
<div v-for="(site, i) in sites" :key="i" class="field__sites-item">
|
||||
<span class="field__sites-url">{{ site.url }}</span>
|
||||
<span class="field__sites-user">{{ site.username }}</span>
|
||||
<button type="button" class="field__repeater-remove" @click="removeSite(i)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="field__sites-help">
|
||||
Application passwords can be created in WordPress under Users > Profile > Application Passwords
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Theme picker (10 fixed color presets, whole-UI preview) -->
|
||||
<div v-else-if="field.type === 'theme-picker'" class="field__theme">
|
||||
@@ -262,6 +272,31 @@ const openFontList = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Companion plugin install card -->
|
||||
<div v-else-if="field.type === 'companion-plugin'" class="field__companion">
|
||||
<div v-if="!companionStatus" class="field__companion-status">Checking…</div>
|
||||
<template v-else>
|
||||
<a
|
||||
v-if="companionStatus.active"
|
||||
class="field__companion-manage"
|
||||
:href="field.manageUrl"
|
||||
>
|
||||
Installed & active — open File Manager →
|
||||
</a>
|
||||
<template v-else>
|
||||
<button
|
||||
type="button"
|
||||
class="field__companion-install"
|
||||
:disabled="companionBusy"
|
||||
@click="installCompanion"
|
||||
>
|
||||
{{ companionBusy ? 'Installing…' : (companionStatus.installed ? 'Activate' : 'Install & Activate') }}
|
||||
</button>
|
||||
<p v-if="companionError" class="field__companion-error">{{ companionError }}</p>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Repeater -->
|
||||
<div v-else-if="field.type === 'repeater'" class="field__repeater">
|
||||
<div v-for="(row, i) in rows" :key="i" class="field__repeater-row">
|
||||
@@ -298,10 +333,6 @@ const openFontList = () => {
|
||||
}
|
||||
.field__label { font-size: 0.95rem; font-weight: 600; color: var(--aurora-text); }
|
||||
.field__desc { margin: 6px 0 0; font-size: 0.8125rem; color: var(--aurora-text-muted); line-height: 1.5; }
|
||||
.field__license-status { margin: 8px 0 0; font-size: 0.8125rem; font-weight: 600; }
|
||||
.field__license-status--ok { color: #34d399; }
|
||||
.field__license-status--warn { color: #fbbf24; }
|
||||
.field__license-status--error { color: #f87171; }
|
||||
.field__control { display: flex; justify-content: flex-start; }
|
||||
|
||||
.field__input {
|
||||
@@ -314,6 +345,7 @@ const openFontList = () => {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.field__input:focus { outline: none; border-color: var(--aurora-accent); }
|
||||
.field__textarea { resize: vertical; font-family: inherit; line-height: 1.5; }
|
||||
|
||||
.field__color { display: flex; align-items: center; gap: 10px; }
|
||||
.field__color input[type='color'] {
|
||||
@@ -379,33 +411,6 @@ const openFontList = () => {
|
||||
}
|
||||
.field__seg--active { background: var(--aurora-accent); color: #fff !important; }
|
||||
|
||||
/* Remote sites */
|
||||
.field__sites { width: 100%; }
|
||||
.field__sites-add .field__input { margin-bottom: 8px; }
|
||||
.field__sites-row { display: flex; gap: 8px; }
|
||||
.field__sites-row .field__input { margin-bottom: 0; }
|
||||
.field__sites-row .field__newitem { flex-shrink: 0; white-space: nowrap; }
|
||||
.field__sites-list {
|
||||
margin-top: 12px;
|
||||
border: 1px solid var(--aurora-border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.field__sites-empty {
|
||||
padding: 18px; text-align: center;
|
||||
font-size: 0.8125rem; color: var(--aurora-text-muted);
|
||||
}
|
||||
.field__sites-item {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--aurora-border);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.field__sites-item:last-child { border-bottom: none; }
|
||||
.field__sites-url { color: var(--aurora-text); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.field__sites-user { color: var(--aurora-text-muted); }
|
||||
.field__sites-help { margin: 10px 0 0; font-size: 0.75rem; color: var(--aurora-text-muted); }
|
||||
|
||||
/* Theme picker */
|
||||
.field__theme { display: flex; flex-wrap: wrap; gap: 10px; width: 100%; }
|
||||
.field__theme-card {
|
||||
@@ -449,6 +454,20 @@ const openFontList = () => {
|
||||
.field__font-item:hover { background: var(--aurora-bg-subtle); }
|
||||
.field__font-item--active { color: var(--aurora-accent); }
|
||||
|
||||
/* Companion plugin install card */
|
||||
.field__companion-status { font-size: 0.8125rem; color: var(--aurora-text-muted); }
|
||||
.field__companion-install {
|
||||
border: none; border-radius: 8px; background: var(--aurora-accent); color: #fff;
|
||||
padding: 8px 16px; font-size: 0.8125rem; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.field__companion-install:disabled { opacity: 0.6; cursor: default; }
|
||||
.field__companion-manage {
|
||||
display: inline-block; font-size: 0.8125rem; font-weight: 600;
|
||||
color: var(--aurora-accent); text-decoration: none;
|
||||
}
|
||||
.field__companion-manage:hover { text-decoration: underline; }
|
||||
.field__companion-error { margin: 8px 0 0; font-size: 0.8125rem; color: #e5484d; }
|
||||
|
||||
@media (max-width: 782px) {
|
||||
.field { grid-template-columns: 1fr; gap: 12px; }
|
||||
}
|
||||
|
||||
@@ -8,16 +8,9 @@ export const settingsCategories = [
|
||||
{
|
||||
id: 'general',
|
||||
title: 'General',
|
||||
subtitle: 'Core plugin settings and license management',
|
||||
subtitle: 'Core plugin settings',
|
||||
icon: 'dashicons-admin-settings',
|
||||
fields: [
|
||||
{
|
||||
key: 'license_key',
|
||||
type: 'text',
|
||||
label: 'License key',
|
||||
description: 'Enter your license key to enable automatic updates.',
|
||||
placeholder: 'AA-XXXX-XXXX-XXXX',
|
||||
},
|
||||
{
|
||||
key: 'logo',
|
||||
type: 'image',
|
||||
@@ -31,20 +24,6 @@ export const settingsCategories = [
|
||||
description:
|
||||
"Set an alternative logo to use in dark mode. If one isn't set it will fall back to the standard logo.",
|
||||
},
|
||||
{
|
||||
key: 'remote_sites',
|
||||
type: 'remote-sites',
|
||||
label: 'Remote Sites',
|
||||
description: 'Configure remote WordPress sites for cross-site operations.',
|
||||
},
|
||||
{
|
||||
key: 'remote_site_switcher_capability',
|
||||
type: 'text',
|
||||
label: 'Remote Site Switcher Capability',
|
||||
description:
|
||||
'Pro feature — requires a license. WordPress capability required to see the remote site switcher in the toolbar. Defaults to "manage_options" (admin users). Users with this capability or below will see the switcher.',
|
||||
placeholder: 'manage_options',
|
||||
},
|
||||
{
|
||||
key: 'disable_theme',
|
||||
type: 'multiselect',
|
||||
@@ -111,7 +90,7 @@ export const settingsCategories = [
|
||||
{
|
||||
id: 'whitelabel',
|
||||
title: 'White Label',
|
||||
subtitle: 'Rebrand and customize interface text and icons (Pro feature — requires a license)',
|
||||
subtitle: 'Rebrand and customize interface text and icons',
|
||||
icon: 'dashicons-tag',
|
||||
fields: [
|
||||
{
|
||||
@@ -215,7 +194,7 @@ export const settingsCategories = [
|
||||
{
|
||||
id: 'analytics',
|
||||
title: 'Analytics',
|
||||
subtitle: 'Built-in analytics and tracking settings (Pro feature — requires a license)',
|
||||
subtitle: 'Built-in analytics and tracking settings',
|
||||
icon: 'dashicons-chart-bar',
|
||||
fields: [
|
||||
{
|
||||
@@ -284,4 +263,97 @@ export const settingsCategories = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'image-formats',
|
||||
title: 'Modern Image Formats',
|
||||
subtitle: 'Automatically generate WebP/AVIF versions of uploaded images',
|
||||
icon: 'dashicons-format-image',
|
||||
fields: [
|
||||
{
|
||||
key: 'image_formats_enabled',
|
||||
type: 'toggle',
|
||||
label: 'Enable modern image formats',
|
||||
description:
|
||||
'Automatically generate a WebP or AVIF copy of every size of a newly uploaded JPEG or PNG image, and serve it to browsers that support it. Only affects new uploads — existing library images are unaffected until re-uploaded or regenerated with a tool like Regenerate Thumbnails.',
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
key: 'image_formats_format',
|
||||
type: 'select',
|
||||
label: 'Output format',
|
||||
description:
|
||||
'Automatic prefers AVIF (smaller files, wider quality range) when the server supports it, falling back to WebP. If your server supports neither, generation is silently skipped — ask your host about enabling GD or Imagick WebP/AVIF support.',
|
||||
dependsOn: 'image_formats_enabled',
|
||||
default: 'auto',
|
||||
options: [
|
||||
{ value: 'auto', label: 'Automatic (AVIF, falling back to WebP)' },
|
||||
{ value: 'webp', label: 'WebP only' },
|
||||
{ value: 'avif', label: 'AVIF only' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'image_formats_picture_element',
|
||||
type: 'toggle',
|
||||
label: 'Serve via <picture> element',
|
||||
description:
|
||||
'Rewrite <img> tags (post content and post thumbnails) into a <picture> element offering the modern format with the original JPEG/PNG as a fallback. Turn this off if it conflicts with your theme’s image styling, while still generating the files for other tools to use.',
|
||||
dependsOn: 'image_formats_enabled',
|
||||
default: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'add-ons',
|
||||
title: 'Add-ons',
|
||||
subtitle: 'Standalone companion plugins',
|
||||
icon: 'dashicons-admin-plugins',
|
||||
fields: [
|
||||
{
|
||||
key: 'file_manager_addon',
|
||||
type: 'companion-plugin',
|
||||
label: 'Aurora File Manager',
|
||||
description:
|
||||
'Browse, edit, and manage your site’s files (themes, plugins, uploads, and more) directly from wp-admin. Distributed as a separate plugin rather than bundled here, since WordPress.org’s Plugin Directory policy doesn’t allow this category of functionality in a directory-listed plugin — restrictions or not.',
|
||||
statusEndpoint: '/companion-plugins/file-manager/status',
|
||||
installEndpoint: '/companion-plugins/file-manager/install',
|
||||
manageUrl: 'admin.php?page=aurora-file-manager',
|
||||
},
|
||||
{
|
||||
key: 'database_explorer_addon',
|
||||
type: 'companion-plugin',
|
||||
label: 'Aurora Database Explorer',
|
||||
description:
|
||||
'A Supabase-style database browser inside wp-admin — see every table with row counts and sizes, browse paginated data, and run your own read-only SELECT/SHOW/DESCRIBE/EXPLAIN queries. Distributed as a separate plugin rather than bundled here, since WordPress.org’s Plugin Directory review treats direct database-query tooling the same way it treats file-management functionality.',
|
||||
statusEndpoint: '/companion-plugins/database-explorer/status',
|
||||
installEndpoint: '/companion-plugins/database-explorer/install',
|
||||
manageUrl: 'admin.php?page=aurora-database-explorer',
|
||||
},
|
||||
{
|
||||
key: 'site_backup_addon',
|
||||
type: 'companion-plugin',
|
||||
label: 'Aurora Site Backup',
|
||||
description:
|
||||
'Full site backup (database + files) and restore-in-place, on demand or on a recurring schedule, stored locally or on your own S3-compatible or Google Drive remote storage. Distributed as a separate plugin rather than bundled here, to keep Aurora Admin’s own footprint away from arbitrary file-write functionality.',
|
||||
statusEndpoint: '/companion-plugins/site-backup/status',
|
||||
installEndpoint: '/companion-plugins/site-backup/install',
|
||||
manageUrl: 'admin.php?page=aurora-site-backup',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'admin-takeover',
|
||||
title: 'Admin Takeover',
|
||||
subtitle: 'Replace the remaining native WordPress admin screens with Aurora',
|
||||
icon: 'dashicons-shield',
|
||||
fields: [
|
||||
{
|
||||
key: 'full_admin_takeover',
|
||||
type: 'toggle',
|
||||
label: 'Full admin takeover',
|
||||
description:
|
||||
'Replace Media Upload, Add User, Profile, Appearance, Tools, and native Settings screens with Aurora equivalents. Off by default — these cover more ground than Posts/Pages/Media/Users/Comments, so it’s opt-in rather than on by default.',
|
||||
default: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
serverData: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const api = (path) => `${props.serverData.restUrl}aurora-admin/v1${path}`;
|
||||
const headers = () => ({ 'Content-Type': 'application/json', 'X-WP-Nonce': props.serverData.restNonce });
|
||||
|
||||
const themes = ref([]);
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
const busy = ref('');
|
||||
|
||||
const load = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await fetch(api('/themes'), { headers: headers() });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.message || 'Could not load themes.');
|
||||
themes.value = data;
|
||||
} catch (e) {
|
||||
error.value = e.message || 'Could not load themes.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(load);
|
||||
|
||||
const activate = async (theme) => {
|
||||
busy.value = theme.stylesheet;
|
||||
try {
|
||||
const res = await fetch(api('/themes/activate'), {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({ stylesheet: theme.stylesheet }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Could not activate theme.');
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
} finally {
|
||||
busy.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (theme) => {
|
||||
if (!confirm(`Delete "${theme.name}"? This can't be undone.`)) return;
|
||||
busy.value = theme.stylesheet;
|
||||
try {
|
||||
const res = await fetch(api(`/themes/${encodeURIComponent(theme.stylesheet)}`), {
|
||||
method: 'DELETE',
|
||||
headers: headers(),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.message || 'Could not delete theme.');
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
setTimeout(() => (error.value = ''), 3000);
|
||||
} finally {
|
||||
busy.value = '';
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="themes-page">
|
||||
<header class="themes-page__header">
|
||||
<div>
|
||||
<p class="themes-page__eyebrow">Appearance</p>
|
||||
<h1>Themes</h1>
|
||||
</div>
|
||||
<div class="themes-page__actions">
|
||||
<a v-if="serverData.addThemeUrl" class="button-primary" :href="serverData.addThemeUrl">
|
||||
Add New Theme
|
||||
</a>
|
||||
<a class="button" :href="serverData.customizeUrl">Customize</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="error" class="themes-notice themes-notice--error">{{ error }}</div>
|
||||
<p v-if="loading" class="themes-empty">Loading…</p>
|
||||
|
||||
<div v-else class="themes-grid">
|
||||
<article v-for="theme in themes" :key="theme.stylesheet" class="theme-card" :class="{ 'theme-card--active': theme.active }">
|
||||
<div class="theme-card__shot">
|
||||
<img v-if="theme.screenshot" :src="theme.screenshot" :alt="theme.name" />
|
||||
<span v-else class="dashicons dashicons-admin-appearance" />
|
||||
</div>
|
||||
<div class="theme-card__body">
|
||||
<h3>{{ theme.name }} <span>{{ theme.version }}</span></h3>
|
||||
<p>{{ theme.description }}</p>
|
||||
</div>
|
||||
<div class="theme-card__actions">
|
||||
<span v-if="theme.active" class="theme-card__badge">Active</span>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="button-primary"
|
||||
:disabled="busy === theme.stylesheet"
|
||||
@click="activate(theme)"
|
||||
>
|
||||
{{ busy === theme.stylesheet ? 'Activating…' : 'Activate' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="!theme.active"
|
||||
type="button"
|
||||
class="icon-button"
|
||||
title="Delete theme"
|
||||
:disabled="busy === theme.stylesheet"
|
||||
@click="remove(theme)"
|
||||
>
|
||||
<span class="dashicons dashicons-trash" />
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.themes-page { max-width: 1100px; margin: 0 auto; }
|
||||
.themes-page__header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 20px; }
|
||||
.themes-page__actions { display: flex; align-items: center; gap: 8px; }
|
||||
.themes-page__eyebrow {
|
||||
margin: 0 0 4px; font-size: 0.75rem; text-transform: uppercase;
|
||||
letter-spacing: 0.06em; color: var(--aurora-text-muted);
|
||||
}
|
||||
.themes-page h1 { margin: 0; font-size: 1.6rem; font-weight: 700; color: var(--aurora-text); }
|
||||
.themes-empty { color: var(--aurora-text-muted); font-size: 0.8125rem; }
|
||||
.themes-notice { padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; font-size: 0.875rem; }
|
||||
.themes-notice--error { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
|
||||
|
||||
.themes-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 18px; }
|
||||
.theme-card {
|
||||
background: var(--aurora-bg-subtle); border: 1px solid var(--aurora-border);
|
||||
border-radius: 14px; overflow: hidden; display: flex; flex-direction: column;
|
||||
}
|
||||
.theme-card--active { border-color: var(--aurora-accent); }
|
||||
.theme-card__shot {
|
||||
aspect-ratio: 4 / 3; background: var(--aurora-bg);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: var(--aurora-text-muted);
|
||||
}
|
||||
.theme-card__shot img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.theme-card__shot .dashicons { font-size: 40px; width: 40px; height: 40px; }
|
||||
.theme-card__body { padding: 14px 16px 8px; flex: 1; }
|
||||
.theme-card__body h3 { margin: 0 0 6px; font-size: 0.9375rem; color: var(--aurora-text); }
|
||||
.theme-card__body h3 span { font-weight: 400; font-size: 0.75rem; color: var(--aurora-text-muted); margin-left: 6px; }
|
||||
.theme-card__body p {
|
||||
margin: 0; font-size: 0.8125rem; color: var(--aurora-text-muted);
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||
}
|
||||
.theme-card__actions { display: flex; align-items: center; gap: 8px; padding: 12px 16px 16px; }
|
||||
.theme-card__badge {
|
||||
font-size: 0.75rem; font-weight: 600; color: var(--aurora-accent);
|
||||
background: var(--aurora-accent-soft); padding: 4px 10px; border-radius: 999px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,152 @@
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
serverData: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
email: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
password: '',
|
||||
roles: [props.serverData.defaultRole || 'subscriber'],
|
||||
});
|
||||
|
||||
const saving = ref(false);
|
||||
const error = ref('');
|
||||
const success = ref('');
|
||||
|
||||
const generatePassword = () => {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%';
|
||||
form.password = Array.from({ length: 16 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
error.value = '';
|
||||
success.value = '';
|
||||
if (!form.username || !form.email) {
|
||||
error.value = 'Username and email are required.';
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const res = await fetch(`${props.serverData.restUrl}wp/v2/users`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': props.serverData.restNonce },
|
||||
body: JSON.stringify({ ...form, password: form.password || undefined }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.message || 'Could not create the user.');
|
||||
|
||||
success.value = `User "${data.username || form.username}" created.`;
|
||||
form.username = '';
|
||||
form.email = '';
|
||||
form.first_name = '';
|
||||
form.last_name = '';
|
||||
form.password = '';
|
||||
} catch (e) {
|
||||
error.value = e.message || 'Could not create the user.';
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="add-user-page">
|
||||
<header class="add-user-page__header">
|
||||
<div>
|
||||
<p class="add-user-page__eyebrow">Accounts</p>
|
||||
<h1>Add New User</h1>
|
||||
</div>
|
||||
<a class="button" :href="serverData.usersUrl">Back to Users</a>
|
||||
</header>
|
||||
|
||||
<div v-if="success" class="add-user-notice add-user-notice--success">{{ success }}</div>
|
||||
<div v-if="error" class="add-user-notice add-user-notice--error">{{ error }}</div>
|
||||
|
||||
<form class="add-user-form" @submit.prevent="submit">
|
||||
<label class="add-user-field">
|
||||
<span>Username <em>(required)</em></span>
|
||||
<input v-model="form.username" type="text" autocomplete="off" />
|
||||
</label>
|
||||
|
||||
<label class="add-user-field">
|
||||
<span>Email <em>(required)</em></span>
|
||||
<input v-model="form.email" type="email" autocomplete="off" />
|
||||
</label>
|
||||
|
||||
<label class="add-user-field">
|
||||
<span>First Name</span>
|
||||
<input v-model="form.first_name" type="text" />
|
||||
</label>
|
||||
|
||||
<label class="add-user-field">
|
||||
<span>Last Name</span>
|
||||
<input v-model="form.last_name" type="text" />
|
||||
</label>
|
||||
|
||||
<label class="add-user-field">
|
||||
<span>Password</span>
|
||||
<div class="add-user-field__row">
|
||||
<input v-model="form.password" type="text" placeholder="Leave blank to auto-generate" />
|
||||
<button type="button" class="button" @click="generatePassword">Generate</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="add-user-field">
|
||||
<span>Role</span>
|
||||
<select v-model="form.roles[0]">
|
||||
<option v-for="role in serverData.roles" :key="role.value" :value="role.value">{{ role.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="add-user-form__actions">
|
||||
<button type="submit" class="button-primary" :disabled="saving">
|
||||
{{ saving ? 'Adding User…' : 'Add New User' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.add-user-page { max-width: 640px; margin: 0 auto; }
|
||||
.add-user-page__header {
|
||||
display: flex; align-items: flex-start; justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.add-user-page__eyebrow {
|
||||
margin: 0 0 4px; font-size: 0.75rem; text-transform: uppercase;
|
||||
letter-spacing: 0.06em; color: var(--aurora-text-muted);
|
||||
}
|
||||
.add-user-page h1 { margin: 0; font-size: 1.6rem; font-weight: 700; color: var(--aurora-text); }
|
||||
|
||||
.add-user-notice {
|
||||
padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; font-size: 0.875rem;
|
||||
}
|
||||
.add-user-notice--success { background: rgba(34, 197, 94, 0.12); color: #22c55e; }
|
||||
.add-user-notice--error { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
|
||||
|
||||
.add-user-form {
|
||||
display: flex; flex-direction: column; gap: 18px;
|
||||
background: var(--aurora-bg-subtle);
|
||||
border: 1px solid var(--aurora-border);
|
||||
border-radius: 14px;
|
||||
padding: 24px;
|
||||
}
|
||||
.add-user-field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.add-user-field span { font-size: 0.8125rem; font-weight: 600; color: var(--aurora-text); }
|
||||
.add-user-field em { font-weight: 400; font-style: normal; color: var(--aurora-text-muted); }
|
||||
.add-user-field input,
|
||||
.add-user-field select {
|
||||
padding: 9px 12px; border-radius: 8px; border: 1px solid var(--aurora-border);
|
||||
background: var(--aurora-bg); color: var(--aurora-text); font-size: 0.875rem;
|
||||
}
|
||||
.add-user-field__row { display: flex; gap: 8px; }
|
||||
.add-user-field__row input { flex: 1; }
|
||||
.add-user-form__actions { margin-top: 4px; }
|
||||
</style>
|
||||
@@ -0,0 +1,144 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
serverData: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const api = (path) => `${props.serverData.restUrl}aurora-admin/v1${path}`;
|
||||
const headers = () => ({ 'Content-Type': 'application/json', 'X-WP-Nonce': props.serverData.restNonce });
|
||||
|
||||
const sidebars = ref([]);
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
|
||||
const load = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await fetch(api('/widgets'), { headers: headers() });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.message || 'Could not load widgets.');
|
||||
sidebars.value = data.sidebars || [];
|
||||
} catch (e) {
|
||||
error.value = e.message || 'Could not load widgets.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(load);
|
||||
|
||||
const move = async (widget, fromSidebar, toSidebar, order = -1) => {
|
||||
await fetch(api('/widgets/move'), {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({ widgetId: widget.id, fromSidebar, toSidebar, order }),
|
||||
});
|
||||
await load();
|
||||
};
|
||||
|
||||
const moveUp = (widget, sidebarId) => {
|
||||
if (widget.order <= 0) return;
|
||||
move(widget, sidebarId, sidebarId, widget.order - 1);
|
||||
};
|
||||
const moveDown = (widget, sidebarId, count) => {
|
||||
if (widget.order >= count - 1) return;
|
||||
move(widget, sidebarId, sidebarId, widget.order + 1);
|
||||
};
|
||||
|
||||
const remove = async (widget) => {
|
||||
if (!confirm(`Delete "${widget.name}"? This can't be undone.`)) return;
|
||||
const res = await fetch(api(`/widgets/${encodeURIComponent(widget.id)}`), { method: 'DELETE', headers: headers() });
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
error.value = data?.message || 'Could not delete widget.';
|
||||
setTimeout(() => (error.value = ''), 3000);
|
||||
return;
|
||||
}
|
||||
await load();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="widgets-page">
|
||||
<header class="widgets-page__header">
|
||||
<div>
|
||||
<p class="widgets-page__eyebrow">Appearance</p>
|
||||
<h1>Widgets</h1>
|
||||
<p class="widgets-page__sub">
|
||||
Move widgets between your theme's sidebars, reorder them, or deactivate them. Adding a brand new
|
||||
widget type still uses WordPress's own block-based widgets screen.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="error" class="widgets-notice widgets-notice--error">{{ error }}</div>
|
||||
<p v-if="loading" class="widgets-empty">Loading…</p>
|
||||
|
||||
<div v-else class="widgets-grid">
|
||||
<section v-for="sidebar in sidebars" :key="sidebar.id" class="widgets-sidebar">
|
||||
<h2>{{ sidebar.name }}</h2>
|
||||
<p v-if="!sidebar.widgets.length" class="widgets-empty">No widgets here.</p>
|
||||
|
||||
<div v-for="widget in sidebar.widgets" :key="widget.id" class="widgets-item">
|
||||
<div class="widgets-item__info">
|
||||
<strong>{{ widget.name }}</strong>
|
||||
<span v-if="widget.title">{{ widget.title }}</span>
|
||||
</div>
|
||||
<div class="widgets-item__actions">
|
||||
<button type="button" title="Move up" @click="moveUp(widget, sidebar.id)"><span class="dashicons dashicons-arrow-up-alt2" /></button>
|
||||
<button type="button" title="Move down" @click="moveDown(widget, sidebar.id, sidebar.widgets.length)"><span class="dashicons dashicons-arrow-down-alt2" /></button>
|
||||
<select :value="sidebar.id" @change="move(widget, sidebar.id, $event.target.value)">
|
||||
<option v-for="s in sidebars" :key="s.id" :value="s.id">{{ s.name }}</option>
|
||||
</select>
|
||||
<button v-if="sidebar.id === 'wp_inactive_widgets'" type="button" title="Delete" @click="remove(widget)">
|
||||
<span class="dashicons dashicons-trash" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.widgets-page { max-width: 1100px; margin: 0 auto; }
|
||||
.widgets-page__header { margin-bottom: 20px; }
|
||||
.widgets-page__eyebrow {
|
||||
margin: 0 0 4px; font-size: 0.75rem; text-transform: uppercase;
|
||||
letter-spacing: 0.06em; color: var(--aurora-text-muted);
|
||||
}
|
||||
.widgets-page h1 { margin: 0 0 6px; font-size: 1.6rem; font-weight: 700; color: var(--aurora-text); }
|
||||
.widgets-page__sub { margin: 0; font-size: 0.8125rem; color: var(--aurora-text-muted); max-width: 640px; }
|
||||
|
||||
.widgets-notice { padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; font-size: 0.875rem; }
|
||||
.widgets-notice--error { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
|
||||
|
||||
.widgets-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px; }
|
||||
.widgets-sidebar {
|
||||
background: var(--aurora-bg-subtle); border: 1px solid var(--aurora-border);
|
||||
border-radius: 14px; padding: 18px;
|
||||
}
|
||||
.widgets-sidebar h2 { font-size: 0.9375rem; margin: 0 0 10px; color: var(--aurora-text); }
|
||||
.widgets-empty { color: var(--aurora-text-muted); font-size: 0.8125rem; }
|
||||
|
||||
.widgets-item {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 10px;
|
||||
padding: 8px 10px; border-radius: 8px; margin-bottom: 6px;
|
||||
background: var(--aurora-bg); border: 1px solid var(--aurora-border);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.widgets-item__info { display: flex; flex-direction: column; min-width: 0; }
|
||||
.widgets-item__info strong { color: var(--aurora-text); }
|
||||
.widgets-item__info span { color: var(--aurora-text-muted); font-size: 0.75rem; }
|
||||
.widgets-item__actions { display: flex; align-items: center; gap: 2px; flex-shrink: 0; }
|
||||
.widgets-item__actions button {
|
||||
border: none; background: none; color: var(--aurora-text-muted); cursor: pointer;
|
||||
width: 26px; height: 26px; border-radius: 6px; display: inline-flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.widgets-item__actions button:hover { background: var(--aurora-bg-subtle); color: var(--aurora-text); }
|
||||
.widgets-item__actions select {
|
||||
font-size: 0.75rem; padding: 4px 6px; border-radius: 6px;
|
||||
border: 1px solid var(--aurora-border); background: var(--aurora-bg); color: var(--aurora-text);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user