Add real replacement pages for Pages, Media Library, and Users (v2.6.0)
Completes the "skinned vs. replaced" conversion started with Posts — every native list-table screen the user asked about now has a genuine custom Vue page behind it instead of dark-themed CSS over the native markup. All three follow the same pattern as Posts: own REST controller in admin/src/Rest/, own Vue page matching the Comments layout, native screen redirected away (direct visits too, not just the sidebar link), gated by its own Settings toggle defaulting to on. - Pages (PagesData.php + Pages.vue): same as Posts, parent page shown instead of categories. edit.php?post_type=page redirects; other post types untouched. - Media Library (MediaData.php + Media.vue): thumbnail grid with type filters (Images/Audio/Video/Documents/Unattached). Replaces the browse/library experience only — media-new.php (the actual upload screen) is untouched, same reasoning as leaving the block editor alone for Posts/Pages. - Users (UsersData.php + Users.vue): filterable by role. Deleting a user always reassigns their content to whoever performs the deletion rather than exposing WP core's reassign-or-delete-everything choice as a one-click action — deliberately simplified to avoid a surprising, highly destructive default. user-new.php untouched. Verified live: Media's grid renders real thumbnails/dimensions/file sizes with working filters and search; Users shows real accounts with role filters and correctly hides delete for the current user; both redirect correctly from their native URLs. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
56b6674527
commit
204c4365e2
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue';
|
||||
import '@/shell/shell-frame.css';
|
||||
import Media from '@/pages/media/Media.vue';
|
||||
|
||||
const mountEl = document.getElementById('aurora-admin-media-root');
|
||||
|
||||
if (mountEl) {
|
||||
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
|
||||
createApp(Media, { serverData: data }).mount(mountEl);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue';
|
||||
import '@/shell/shell-frame.css';
|
||||
import Pages from '@/pages/pages/Pages.vue';
|
||||
|
||||
const mountEl = document.getElementById('aurora-admin-pages-root');
|
||||
|
||||
if (mountEl) {
|
||||
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
|
||||
createApp(Pages, { serverData: data }).mount(mountEl);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue';
|
||||
import '@/shell/shell-frame.css';
|
||||
import Users from '@/pages/users/Users.vue';
|
||||
|
||||
const mountEl = document.getElementById('aurora-admin-users-root');
|
||||
|
||||
if (mountEl) {
|
||||
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
|
||||
createApp(Users, { serverData: data }).mount(mountEl);
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
serverData: { type: Object, required: 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 });
|
||||
const loading = ref(true);
|
||||
const busyId = ref(0);
|
||||
const error = ref('');
|
||||
const notice = ref('');
|
||||
const search = ref('');
|
||||
const type = ref('all');
|
||||
|
||||
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: {
|
||||
'Content-Type': 'application/json',
|
||||
'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 () => {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
type: type.value,
|
||||
search: search.value.trim(),
|
||||
page: String(pagination.value.page),
|
||||
perPage: String(pagination.value.perPage),
|
||||
});
|
||||
const data = await request(`/media?${params.toString()}`);
|
||||
items.value = data.items || [];
|
||||
stats.value = data.stats || stats.value;
|
||||
pagination.value = data.pagination || pagination.value;
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Could not load media';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(load);
|
||||
|
||||
let searchTimer = 0;
|
||||
watch(search, () => {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => {
|
||||
pagination.value.page = 1;
|
||||
load();
|
||||
}, 250);
|
||||
});
|
||||
|
||||
const setType = (next) => {
|
||||
type.value = next;
|
||||
pagination.value.page = 1;
|
||||
load();
|
||||
};
|
||||
|
||||
const filters = computed(() => [
|
||||
{ key: 'all', label: 'All', count: stats.value.all },
|
||||
{ key: 'image', label: 'Images', count: stats.value.image },
|
||||
{ key: 'audio', label: 'Audio', count: stats.value.audio },
|
||||
{ key: 'video', label: 'Video', count: stats.value.video },
|
||||
{ key: 'application', label: 'Documents', count: stats.value.application },
|
||||
{ key: 'unattached', label: 'Unattached', count: stats.value.unattached },
|
||||
]);
|
||||
|
||||
const deleteItem = async (item) => {
|
||||
if (!item || busyId.value) return;
|
||||
if (!window.confirm(`Permanently delete "${item.title}"? This cannot be undone.`)) return;
|
||||
busyId.value = item.id;
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
try {
|
||||
const data = await request(`/media/${item.id}`, { method: 'DELETE' });
|
||||
notice.value = data.message || 'Media item deleted.';
|
||||
await load();
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Action failed';
|
||||
} finally {
|
||||
busyId.value = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const goPage = (page) => {
|
||||
const next = Math.min(Math.max(1, page), pagination.value.totalPages || 1);
|
||||
if (next === pagination.value.page) return;
|
||||
pagination.value.page = next;
|
||||
load();
|
||||
};
|
||||
|
||||
const rowRange = computed(() => {
|
||||
if (!pagination.value.total) return '0 to 0';
|
||||
const start = (pagination.value.page - 1) * pagination.value.perPage + 1;
|
||||
const end = Math.min(pagination.value.total, pagination.value.page * pagination.value.perPage);
|
||||
return `${start} to ${end}`;
|
||||
});
|
||||
|
||||
const typeIcon = (mimeType) => {
|
||||
const group = (mimeType || '').split('/')[0];
|
||||
if (group === 'audio') return 'dashicons-format-audio';
|
||||
if (group === 'video') return 'dashicons-format-video';
|
||||
if (mimeType === 'application/pdf') return 'dashicons-pdf';
|
||||
return 'dashicons-media-document';
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="media-page">
|
||||
<header class="media-page__header">
|
||||
<div>
|
||||
<p class="media-page__eyebrow">Content</p>
|
||||
<h1>Media Library</h1>
|
||||
</div>
|
||||
<div class="media-page__actions">
|
||||
<a class="button-primary" :href="serverData.newMediaUrl">Add Media File</a>
|
||||
<button type="button" class="icon-button" title="Refresh media" :disabled="loading" @click="load">
|
||||
<span class="dashicons dashicons-update" />
|
||||
</button>
|
||||
</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>
|
||||
<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>
|
||||
<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">
|
||||
<button type="button" :disabled="pagination.page <= 1" @click="goPage(1)">
|
||||
<span class="dashicons dashicons-controls-skipback" />
|
||||
</button>
|
||||
<button type="button" :disabled="pagination.page <= 1" @click="goPage(pagination.page - 1)">
|
||||
<span class="dashicons dashicons-arrow-left-alt2" />
|
||||
</button>
|
||||
<span>Page {{ pagination.page }} of {{ pagination.totalPages }}</span>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="pagination.page >= pagination.totalPages"
|
||||
@click="goPage(pagination.page + 1)"
|
||||
>
|
||||
<span class="dashicons dashicons-arrow-right-alt2" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="pagination.page >= pagination.totalPages"
|
||||
@click="goPage(pagination.totalPages)"
|
||||
>
|
||||
<span class="dashicons dashicons-controls-skipforward" />
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.media-page {
|
||||
min-height: calc(100vh - 112px);
|
||||
padding: 0;
|
||||
color: var(--aurora-text);
|
||||
background: transparent;
|
||||
}
|
||||
.media-page__header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.media-page__eyebrow {
|
||||
margin: 0 0 7px;
|
||||
color: var(--aurora-text-muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.media-page h1 {
|
||||
margin: 0;
|
||||
color: var(--aurora-text);
|
||||
font-size: 1.45rem;
|
||||
line-height: 1.15;
|
||||
}
|
||||
.media-page__actions,
|
||||
.media-page__toolbar,
|
||||
.media-page__filters,
|
||||
.media-page__pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.media-page__actions {
|
||||
gap: 10px;
|
||||
}
|
||||
.button-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 38px;
|
||||
padding: 0 16px;
|
||||
border-radius: 8px;
|
||||
background: var(--aurora-accent);
|
||||
color: var(--aurora-accent-text);
|
||||
font-size: 0.84rem;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
.media-page__toolbar {
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.media-page__filters {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.filter-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 34px;
|
||||
border: 1px solid var(--aurora-border);
|
||||
border-radius: 999px;
|
||||
background: var(--aurora-bg-subtle);
|
||||
color: var(--aurora-text-muted);
|
||||
padding: 0 12px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
.filter-pill strong {
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--aurora-frame-bg);
|
||||
color: inherit;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.filter-pill.is-active {
|
||||
border-color: var(--aurora-text-muted);
|
||||
background: var(--aurora-frame-bg);
|
||||
color: var(--aurora-text);
|
||||
}
|
||||
.media-page__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);
|
||||
}
|
||||
.media-page__search input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
color: var(--aurora-text);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
.media-page__search input::placeholder {
|
||||
color: var(--aurora-text-muted);
|
||||
}
|
||||
.icon-button {
|
||||
width: 42px;
|
||||
min-height: 42px;
|
||||
border: 1px solid var(--aurora-border);
|
||||
border-radius: 8px;
|
||||
color: var(--aurora-text);
|
||||
background: var(--aurora-bg-subtle);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.icon-button:hover:not(:disabled) {
|
||||
border-color: var(--aurora-text-muted);
|
||||
color: var(--aurora-text);
|
||||
}
|
||||
.icon-button--inline {
|
||||
width: 34px;
|
||||
min-height: 34px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
color: #fff;
|
||||
}
|
||||
.icon-button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.media-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;
|
||||
}
|
||||
.media-grid__empty {
|
||||
padding: 60px 18px;
|
||||
border: 1px solid var(--aurora-frame-border);
|
||||
border-radius: 8px;
|
||||
background: var(--aurora-frame-bg);
|
||||
color: var(--aurora-text-muted);
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
}
|
||||
.media-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.media-card {
|
||||
border: 1px solid var(--aurora-frame-border);
|
||||
border-radius: 10px;
|
||||
background: var(--aurora-frame-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.media-card__preview {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
background: var(--aurora-bg-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.media-card__preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.media-card__icon {
|
||||
font-size: 46px;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
color: var(--aurora-text-muted);
|
||||
}
|
||||
.media-card__overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.media-card:hover .media-card__overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
.media-card__meta {
|
||||
padding: 10px 12px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
.media-card__meta strong {
|
||||
overflow: hidden;
|
||||
color: var(--aurora-text);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 750;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.media-card__meta span {
|
||||
overflow: hidden;
|
||||
color: var(--aurora-text-muted);
|
||||
font-size: 0.72rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.media-card__meta a {
|
||||
color: var(--aurora-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
.media-card__unattached {
|
||||
font-style: italic;
|
||||
}
|
||||
.media-page__footer {
|
||||
min-height: 54px;
|
||||
margin-top: 18px;
|
||||
border-top: 1px solid var(--aurora-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 26px;
|
||||
padding: 14px 4px 0;
|
||||
color: var(--aurora-text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.media-page__pager {
|
||||
gap: 8px;
|
||||
}
|
||||
.media-page__pager button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--aurora-text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.media-page__pager button:not(:disabled):hover {
|
||||
background: var(--aurora-bg-subtle);
|
||||
color: var(--aurora-text);
|
||||
}
|
||||
.media-page__pager button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.media-page__header,
|
||||
.media-page__toolbar,
|
||||
.media-page__footer {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
.media-page__search {
|
||||
width: 100%;
|
||||
}
|
||||
.media-page__footer {
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,622 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
serverData: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const pages = ref([]);
|
||||
const stats = ref({ all: 0, publish: 0, draft: 0, pending: 0, trash: 0 });
|
||||
const pagination = ref({ page: 1, perPage: 20, total: 0, totalPages: 1 });
|
||||
const loading = ref(true);
|
||||
const busyId = ref(0);
|
||||
const error = ref('');
|
||||
const notice = ref('');
|
||||
const search = ref('');
|
||||
const status = ref('all');
|
||||
|
||||
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: {
|
||||
'Content-Type': 'application/json',
|
||||
'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 () => {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
status: status.value,
|
||||
search: search.value.trim(),
|
||||
page: String(pagination.value.page),
|
||||
perPage: String(pagination.value.perPage),
|
||||
});
|
||||
const data = await request(`/pages?${params.toString()}`);
|
||||
pages.value = data.items || [];
|
||||
stats.value = data.stats || stats.value;
|
||||
pagination.value = data.pagination || pagination.value;
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Could not load pages';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(load);
|
||||
|
||||
let searchTimer = 0;
|
||||
watch(search, () => {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => {
|
||||
pagination.value.page = 1;
|
||||
load();
|
||||
}, 250);
|
||||
});
|
||||
|
||||
const setStatus = (next) => {
|
||||
status.value = next;
|
||||
pagination.value.page = 1;
|
||||
load();
|
||||
};
|
||||
|
||||
const filters = computed(() => [
|
||||
{ key: 'all', label: 'All', count: stats.value.all },
|
||||
{ key: 'publish', label: 'Published', count: stats.value.publish },
|
||||
{ key: 'draft', label: 'Draft', count: stats.value.draft },
|
||||
{ key: 'pending', label: 'Pending', count: stats.value.pending },
|
||||
{ key: 'trash', label: 'Trash', count: stats.value.trash },
|
||||
]);
|
||||
|
||||
const trashPage = async (item) => {
|
||||
if (!item || busyId.value) return;
|
||||
busyId.value = item.id;
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
try {
|
||||
const data = await request('/pages/trash', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ id: item.id }),
|
||||
});
|
||||
notice.value = data.message || 'Page moved to trash.';
|
||||
await load();
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Action failed';
|
||||
} finally {
|
||||
busyId.value = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const restorePage = async (item) => {
|
||||
if (!item || busyId.value) return;
|
||||
busyId.value = item.id;
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
try {
|
||||
const data = await request('/pages/restore', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ id: item.id }),
|
||||
});
|
||||
notice.value = data.message || 'Page restored.';
|
||||
await load();
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Action failed';
|
||||
} finally {
|
||||
busyId.value = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const deletePage = async (item) => {
|
||||
if (!item || busyId.value) return;
|
||||
if (!window.confirm(`Permanently delete "${item.title}"? This cannot be undone.`)) return;
|
||||
busyId.value = item.id;
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
try {
|
||||
const data = await request(`/pages/${item.id}`, { method: 'DELETE' });
|
||||
notice.value = data.message || 'Page deleted.';
|
||||
await load();
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Action failed';
|
||||
} finally {
|
||||
busyId.value = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const goPage = (page) => {
|
||||
const next = Math.min(Math.max(1, page), pagination.value.totalPages || 1);
|
||||
if (next === pagination.value.page) return;
|
||||
pagination.value.page = next;
|
||||
load();
|
||||
};
|
||||
|
||||
const rowRange = computed(() => {
|
||||
if (!pagination.value.total) return '0 to 0';
|
||||
const start = (pagination.value.page - 1) * pagination.value.perPage + 1;
|
||||
const end = Math.min(pagination.value.total, pagination.value.page * pagination.value.perPage);
|
||||
return `${start} to ${end}`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="pages-page">
|
||||
<header class="pages-page__header">
|
||||
<div>
|
||||
<p class="pages-page__eyebrow">Content</p>
|
||||
<h1>Pages</h1>
|
||||
</div>
|
||||
<div class="pages-page__actions">
|
||||
<a class="button-primary" :href="serverData.newPageUrl">Add Page</a>
|
||||
<button type="button" class="icon-button" title="Refresh pages" :disabled="loading" @click="load">
|
||||
<span class="dashicons dashicons-update" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="pages-page__toolbar" aria-label="Page filters and search">
|
||||
<div class="pages-page__filters">
|
||||
<button
|
||||
v-for="filter in filters"
|
||||
:key="filter.key"
|
||||
type="button"
|
||||
class="filter-pill"
|
||||
:class="{ 'is-active': status === filter.key }"
|
||||
@click="setStatus(filter.key)"
|
||||
>
|
||||
<span>{{ filter.label }}</span>
|
||||
<strong>{{ filter.count }}</strong>
|
||||
</button>
|
||||
</div>
|
||||
<label class="pages-page__search">
|
||||
<span class="dashicons dashicons-search" />
|
||||
<input v-model="search" type="search" placeholder="Search pages..." />
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<div v-if="notice" class="pages-notice pages-notice--success">{{ notice }}</div>
|
||||
<div v-if="error" class="pages-notice pages-notice--error">{{ error }}</div>
|
||||
|
||||
<section class="pages-table" aria-label="Pages">
|
||||
<div class="pages-table__head">
|
||||
<div>Title</div>
|
||||
<div>Author</div>
|
||||
<div>Parent</div>
|
||||
<div>Comments</div>
|
||||
<div>Date</div>
|
||||
<div>Actions</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="pages-table__empty">Loading pages...</div>
|
||||
<div v-else-if="!pages.length" class="pages-table__empty">No pages match your filters.</div>
|
||||
|
||||
<article v-for="item in pages" v-else :key="item.id" class="page-row">
|
||||
<div class="page-row__title" data-label="Title">
|
||||
<a :href="item.editUrl">{{ item.title }}</a>
|
||||
<span class="status-badge" :class="`status-badge--${item.status}`">{{ item.statusLabel }}</span>
|
||||
</div>
|
||||
|
||||
<div class="page-row__cell" data-label="Author">{{ item.author }}</div>
|
||||
|
||||
<div class="page-row__cell" data-label="Parent">{{ item.parentTitle || '—' }}</div>
|
||||
|
||||
<div class="page-row__cell" data-label="Comments">{{ item.commentCount }}</div>
|
||||
|
||||
<div class="page-row__cell" data-label="Date">
|
||||
<strong>{{ item.dateLabel }}</strong>
|
||||
<span>{{ item.date }}</span>
|
||||
</div>
|
||||
|
||||
<div class="page-row__actions">
|
||||
<a class="icon-button icon-button--inline" title="Edit page" :href="item.editUrl">
|
||||
<span class="dashicons dashicons-edit" />
|
||||
</a>
|
||||
<a
|
||||
v-if="item.viewUrl"
|
||||
class="icon-button icon-button--inline"
|
||||
title="View page"
|
||||
:href="item.viewUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<span class="dashicons dashicons-visibility" />
|
||||
</a>
|
||||
<button
|
||||
v-if="item.status !== 'trash'"
|
||||
type="button"
|
||||
class="icon-button icon-button--inline"
|
||||
title="Move to trash"
|
||||
:disabled="busyId === item.id"
|
||||
@click="trashPage(item)"
|
||||
>
|
||||
<span class="dashicons dashicons-trash" />
|
||||
</button>
|
||||
<template v-else>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-button icon-button--inline"
|
||||
title="Restore"
|
||||
:disabled="busyId === item.id"
|
||||
@click="restorePage(item)"
|
||||
>
|
||||
<span class="dashicons dashicons-undo" />
|
||||
</button>
|
||||
<button
|
||||
v-if="item.canDelete"
|
||||
type="button"
|
||||
class="icon-button icon-button--inline"
|
||||
title="Delete permanently"
|
||||
:disabled="busyId === item.id"
|
||||
@click="deletePage(item)"
|
||||
>
|
||||
<span class="dashicons dashicons-no-alt" />
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<footer v-if="!loading && pages.length" class="pages-table__footer">
|
||||
<div>Page Size: {{ pagination.perPage }}</div>
|
||||
<div>{{ rowRange }} of {{ pagination.total }}</div>
|
||||
<div class="pages-table__pager">
|
||||
<button type="button" :disabled="pagination.page <= 1" @click="goPage(1)">
|
||||
<span class="dashicons dashicons-controls-skipback" />
|
||||
</button>
|
||||
<button type="button" :disabled="pagination.page <= 1" @click="goPage(pagination.page - 1)">
|
||||
<span class="dashicons dashicons-arrow-left-alt2" />
|
||||
</button>
|
||||
<span>Page {{ pagination.page }} of {{ pagination.totalPages }}</span>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="pagination.page >= pagination.totalPages"
|
||||
@click="goPage(pagination.page + 1)"
|
||||
>
|
||||
<span class="dashicons dashicons-arrow-right-alt2" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="pagination.page >= pagination.totalPages"
|
||||
@click="goPage(pagination.totalPages)"
|
||||
>
|
||||
<span class="dashicons dashicons-controls-skipforward" />
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pages-page {
|
||||
min-height: calc(100vh - 112px);
|
||||
padding: 0;
|
||||
color: var(--aurora-text);
|
||||
background: transparent;
|
||||
}
|
||||
.pages-page__header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.pages-page__eyebrow {
|
||||
margin: 0 0 7px;
|
||||
color: var(--aurora-text-muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.pages-page h1 {
|
||||
margin: 0;
|
||||
color: var(--aurora-text);
|
||||
font-size: 1.45rem;
|
||||
line-height: 1.15;
|
||||
}
|
||||
.pages-page__actions,
|
||||
.pages-page__toolbar,
|
||||
.pages-page__filters,
|
||||
.page-row__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.pages-page__actions {
|
||||
gap: 10px;
|
||||
}
|
||||
.button-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 38px;
|
||||
padding: 0 16px;
|
||||
border-radius: 8px;
|
||||
background: var(--aurora-accent);
|
||||
color: var(--aurora-accent-text);
|
||||
font-size: 0.84rem;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
.pages-page__toolbar {
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.pages-page__filters {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.filter-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 34px;
|
||||
border: 1px solid var(--aurora-border);
|
||||
border-radius: 999px;
|
||||
background: var(--aurora-bg-subtle);
|
||||
color: var(--aurora-text-muted);
|
||||
padding: 0 12px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
.filter-pill strong {
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--aurora-frame-bg);
|
||||
color: inherit;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.filter-pill.is-active {
|
||||
border-color: var(--aurora-text-muted);
|
||||
background: var(--aurora-frame-bg);
|
||||
color: var(--aurora-text);
|
||||
}
|
||||
.pages-page__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);
|
||||
}
|
||||
.pages-page__search input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
color: var(--aurora-text);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
.pages-page__search input::placeholder {
|
||||
color: var(--aurora-text-muted);
|
||||
}
|
||||
.icon-button {
|
||||
width: 42px;
|
||||
min-height: 42px;
|
||||
border: 1px solid var(--aurora-border);
|
||||
border-radius: 8px;
|
||||
color: var(--aurora-text);
|
||||
background: var(--aurora-bg-subtle);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.icon-button:hover:not(:disabled) {
|
||||
border-color: var(--aurora-text-muted);
|
||||
color: var(--aurora-text);
|
||||
}
|
||||
.icon-button--inline {
|
||||
width: 30px;
|
||||
min-height: 30px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--aurora-text-muted);
|
||||
}
|
||||
.icon-button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.pages-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;
|
||||
}
|
||||
.pages-table {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--aurora-frame-border);
|
||||
border-radius: 8px;
|
||||
background: var(--aurora-frame-bg);
|
||||
}
|
||||
.pages-table__head,
|
||||
.page-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1.6fr) minmax(120px, 0.8fr) minmax(160px, 1fr) 110px 130px 132px;
|
||||
align-items: center;
|
||||
}
|
||||
.pages-table__head {
|
||||
min-height: 48px;
|
||||
border-bottom: 1px solid var(--aurora-border);
|
||||
color: var(--aurora-text);
|
||||
font-size: 0.79rem;
|
||||
font-weight: 850;
|
||||
}
|
||||
.pages-table__head > div {
|
||||
min-height: 48px;
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.page-row {
|
||||
min-height: 66px;
|
||||
border-bottom: 1px solid var(--aurora-border);
|
||||
}
|
||||
.page-row:hover {
|
||||
background: var(--aurora-bg-subtle);
|
||||
}
|
||||
.page-row__title {
|
||||
min-width: 0;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.page-row__title a {
|
||||
overflow: hidden;
|
||||
color: var(--aurora-text);
|
||||
font-weight: 800;
|
||||
font-size: 0.86rem;
|
||||
text-decoration: none;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.page-row__title a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.page-row__cell {
|
||||
min-width: 0;
|
||||
padding: 14px 16px;
|
||||
color: var(--aurora-text-muted);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.45;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.page-row__cell span {
|
||||
display: block;
|
||||
color: var(--aurora-text-muted);
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
.page-row__cell strong {
|
||||
display: block;
|
||||
color: var(--aurora-text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.status-badge {
|
||||
width: fit-content;
|
||||
border-radius: 999px;
|
||||
background: var(--aurora-bg-subtle);
|
||||
color: var(--aurora-text-muted);
|
||||
padding: 2px 9px;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 850;
|
||||
box-shadow: inset 0 0 0 1px var(--aurora-border);
|
||||
}
|
||||
.page-row__actions {
|
||||
gap: 8px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.pages-table__empty {
|
||||
padding: 38px 18px;
|
||||
color: var(--aurora-text-muted);
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
}
|
||||
.pages-table__footer {
|
||||
min-height: 54px;
|
||||
border-top: 1px solid var(--aurora-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 26px;
|
||||
padding: 0 16px;
|
||||
color: var(--aurora-text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.pages-table__pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.pages-table__pager button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--aurora-text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.pages-table__pager button:not(:disabled):hover {
|
||||
background: var(--aurora-bg-subtle);
|
||||
color: var(--aurora-text);
|
||||
}
|
||||
.pages-table__pager button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.pages-table__head,
|
||||
.page-row {
|
||||
grid-template-columns: minmax(200px, 1.6fr) minmax(100px, 0.8fr) minmax(140px, 1fr) 100px 120px 122px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.pages-table__head {
|
||||
display: none;
|
||||
}
|
||||
.page-row {
|
||||
grid-template-columns: 1fr;
|
||||
align-items: stretch;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.page-row__title,
|
||||
.page-row__cell,
|
||||
.page-row__actions {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
.page-row__cell {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
white-space: normal;
|
||||
}
|
||||
.page-row__cell::before {
|
||||
content: attr(data-label);
|
||||
color: var(--aurora-text-muted);
|
||||
font-weight: 800;
|
||||
}
|
||||
.pages-table__footer,
|
||||
.pages-page__header,
|
||||
.pages-page__toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
.pages-table__footer {
|
||||
padding: 14px 16px;
|
||||
gap: 10px;
|
||||
}
|
||||
.pages-page__search {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -167,6 +167,21 @@ export const settingsCategories = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'pages',
|
||||
title: 'Pages',
|
||||
subtitle: 'Page management and display preferences',
|
||||
icon: 'dashicons-admin-page',
|
||||
fields: [
|
||||
{
|
||||
key: 'modern_pages',
|
||||
type: 'toggle',
|
||||
label: 'Modern pages table',
|
||||
description: 'Use the Aurora pages management interface.',
|
||||
default: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'plugins',
|
||||
title: 'Plugins',
|
||||
@@ -224,6 +239,13 @@ export const settingsCategories = [
|
||||
subtitle: 'Media library and upload management settings',
|
||||
icon: 'dashicons-admin-media',
|
||||
fields: [
|
||||
{
|
||||
key: 'modern_media',
|
||||
type: 'toggle',
|
||||
label: 'Modern media library',
|
||||
description: 'Use the Aurora media library interface.',
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
key: 'enable_svg',
|
||||
type: 'toggle',
|
||||
@@ -232,6 +254,21 @@ export const settingsCategories = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'users',
|
||||
title: 'Users',
|
||||
subtitle: 'User management and administration settings',
|
||||
icon: 'dashicons-admin-users',
|
||||
fields: [
|
||||
{
|
||||
key: 'modern_users',
|
||||
type: 'toggle',
|
||||
label: 'Modern users page',
|
||||
description: 'Use the Aurora users management interface.',
|
||||
default: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'comments',
|
||||
title: 'Comments',
|
||||
|
||||
@@ -0,0 +1,569 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
serverData: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const users = ref([]);
|
||||
const stats = ref({ all: 0, byRole: {} });
|
||||
const pagination = ref({ page: 1, perPage: 20, total: 0, totalPages: 1 });
|
||||
const loading = ref(true);
|
||||
const busyId = ref(0);
|
||||
const error = ref('');
|
||||
const notice = ref('');
|
||||
const search = ref('');
|
||||
const role = ref('all');
|
||||
|
||||
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: {
|
||||
'Content-Type': 'application/json',
|
||||
'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 () => {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
role: role.value,
|
||||
search: search.value.trim(),
|
||||
page: String(pagination.value.page),
|
||||
perPage: String(pagination.value.perPage),
|
||||
});
|
||||
const data = await request(`/users?${params.toString()}`);
|
||||
users.value = data.items || [];
|
||||
stats.value = data.stats || stats.value;
|
||||
pagination.value = data.pagination || pagination.value;
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Could not load users';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(load);
|
||||
|
||||
let searchTimer = 0;
|
||||
watch(search, () => {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => {
|
||||
pagination.value.page = 1;
|
||||
load();
|
||||
}, 250);
|
||||
});
|
||||
|
||||
const setRole = (next) => {
|
||||
role.value = next;
|
||||
pagination.value.page = 1;
|
||||
load();
|
||||
};
|
||||
|
||||
const filters = computed(() => {
|
||||
const roleFilters = Object.entries(stats.value.byRole || {}).map(([key, count]) => ({
|
||||
key,
|
||||
label: key.charAt(0).toUpperCase() + key.slice(1),
|
||||
count,
|
||||
}));
|
||||
return [{ key: 'all', label: 'All', count: stats.value.all }, ...roleFilters];
|
||||
});
|
||||
|
||||
const deleteUser = async (user) => {
|
||||
if (!user || busyId.value) return;
|
||||
if (!window.confirm(`Delete "${user.displayName}"? Their content will be reassigned to you. This cannot be undone.`)) return;
|
||||
busyId.value = user.id;
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
try {
|
||||
const data = await request(`/users/${user.id}`, { method: 'DELETE' });
|
||||
notice.value = data.message || 'User deleted.';
|
||||
await load();
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Action failed';
|
||||
} finally {
|
||||
busyId.value = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const goPage = (page) => {
|
||||
const next = Math.min(Math.max(1, page), pagination.value.totalPages || 1);
|
||||
if (next === pagination.value.page) return;
|
||||
pagination.value.page = next;
|
||||
load();
|
||||
};
|
||||
|
||||
const rowRange = computed(() => {
|
||||
if (!pagination.value.total) return '0 to 0';
|
||||
const start = (pagination.value.page - 1) * pagination.value.perPage + 1;
|
||||
const end = Math.min(pagination.value.total, pagination.value.page * pagination.value.perPage);
|
||||
return `${start} to ${end}`;
|
||||
});
|
||||
|
||||
const initials = (name) =>
|
||||
(name || '?')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part.charAt(0).toUpperCase())
|
||||
.join('');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="users-page">
|
||||
<header class="users-page__header">
|
||||
<div>
|
||||
<p class="users-page__eyebrow">Accounts</p>
|
||||
<h1>Users</h1>
|
||||
</div>
|
||||
<div class="users-page__actions">
|
||||
<a class="button-primary" :href="serverData.newUserUrl">Add User</a>
|
||||
<button type="button" class="icon-button" title="Refresh users" :disabled="loading" @click="load">
|
||||
<span class="dashicons dashicons-update" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="users-page__toolbar" aria-label="User filters and search">
|
||||
<div class="users-page__filters">
|
||||
<button
|
||||
v-for="filter in filters"
|
||||
:key="filter.key"
|
||||
type="button"
|
||||
class="filter-pill"
|
||||
:class="{ 'is-active': role === filter.key }"
|
||||
@click="setRole(filter.key)"
|
||||
>
|
||||
<span>{{ filter.label }}</span>
|
||||
<strong>{{ filter.count }}</strong>
|
||||
</button>
|
||||
</div>
|
||||
<label class="users-page__search">
|
||||
<span class="dashicons dashicons-search" />
|
||||
<input v-model="search" type="search" placeholder="Search users..." />
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<div v-if="notice" class="users-notice users-notice--success">{{ notice }}</div>
|
||||
<div v-if="error" class="users-notice users-notice--error">{{ error }}</div>
|
||||
|
||||
<section class="users-table" aria-label="Users">
|
||||
<div class="users-table__head">
|
||||
<div>User</div>
|
||||
<div>Email</div>
|
||||
<div>Role</div>
|
||||
<div>Posts</div>
|
||||
<div>Registered</div>
|
||||
<div>Actions</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="users-table__empty">Loading users...</div>
|
||||
<div v-else-if="!users.length" class="users-table__empty">No users match your filters.</div>
|
||||
|
||||
<article v-for="user in users" v-else :key="user.id" class="user-row">
|
||||
<div class="user-row__user">
|
||||
<img v-if="user.avatarUrl" :src="user.avatarUrl" alt="" class="user-row__avatar" />
|
||||
<div v-else class="user-row__initials">{{ initials(user.displayName) }}</div>
|
||||
<div class="user-row__person">
|
||||
<strong>{{ user.displayName }}</strong>
|
||||
<span>{{ user.username }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="user-row__cell" data-label="Email">{{ user.email }}</div>
|
||||
|
||||
<div class="user-row__cell" data-label="Role">{{ user.roles.join(', ') || '—' }}</div>
|
||||
|
||||
<div class="user-row__cell" data-label="Posts">{{ user.postCount }}</div>
|
||||
|
||||
<div class="user-row__cell" data-label="Registered">{{ user.registeredDate }}</div>
|
||||
|
||||
<div class="user-row__actions">
|
||||
<a class="icon-button icon-button--inline" title="Edit user" :href="user.editUrl">
|
||||
<span class="dashicons dashicons-edit" />
|
||||
</a>
|
||||
<button
|
||||
v-if="user.canDelete"
|
||||
type="button"
|
||||
class="icon-button icon-button--inline"
|
||||
title="Delete user"
|
||||
:disabled="busyId === user.id"
|
||||
@click="deleteUser(user)"
|
||||
>
|
||||
<span class="dashicons dashicons-trash" />
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<footer v-if="!loading && users.length" class="users-table__footer">
|
||||
<div>Page Size: {{ pagination.perPage }}</div>
|
||||
<div>{{ rowRange }} of {{ pagination.total }}</div>
|
||||
<div class="users-table__pager">
|
||||
<button type="button" :disabled="pagination.page <= 1" @click="goPage(1)">
|
||||
<span class="dashicons dashicons-controls-skipback" />
|
||||
</button>
|
||||
<button type="button" :disabled="pagination.page <= 1" @click="goPage(pagination.page - 1)">
|
||||
<span class="dashicons dashicons-arrow-left-alt2" />
|
||||
</button>
|
||||
<span>Page {{ pagination.page }} of {{ pagination.totalPages }}</span>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="pagination.page >= pagination.totalPages"
|
||||
@click="goPage(pagination.page + 1)"
|
||||
>
|
||||
<span class="dashicons dashicons-arrow-right-alt2" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="pagination.page >= pagination.totalPages"
|
||||
@click="goPage(pagination.totalPages)"
|
||||
>
|
||||
<span class="dashicons dashicons-controls-skipforward" />
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.users-page {
|
||||
min-height: calc(100vh - 112px);
|
||||
padding: 0;
|
||||
color: var(--aurora-text);
|
||||
background: transparent;
|
||||
}
|
||||
.users-page__header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.users-page__eyebrow {
|
||||
margin: 0 0 7px;
|
||||
color: var(--aurora-text-muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.users-page h1 {
|
||||
margin: 0;
|
||||
color: var(--aurora-text);
|
||||
font-size: 1.45rem;
|
||||
line-height: 1.15;
|
||||
}
|
||||
.users-page__actions,
|
||||
.users-page__toolbar,
|
||||
.users-page__filters,
|
||||
.user-row__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.users-page__actions {
|
||||
gap: 10px;
|
||||
}
|
||||
.button-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 38px;
|
||||
padding: 0 16px;
|
||||
border-radius: 8px;
|
||||
background: var(--aurora-accent);
|
||||
color: var(--aurora-accent-text);
|
||||
font-size: 0.84rem;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
.users-page__toolbar {
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.users-page__filters {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.filter-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 34px;
|
||||
border: 1px solid var(--aurora-border);
|
||||
border-radius: 999px;
|
||||
background: var(--aurora-bg-subtle);
|
||||
color: var(--aurora-text-muted);
|
||||
padding: 0 12px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
.filter-pill strong {
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--aurora-frame-bg);
|
||||
color: inherit;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.filter-pill.is-active {
|
||||
border-color: var(--aurora-text-muted);
|
||||
background: var(--aurora-frame-bg);
|
||||
color: var(--aurora-text);
|
||||
}
|
||||
.users-page__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);
|
||||
}
|
||||
.users-page__search input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
color: var(--aurora-text);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
.users-page__search input::placeholder {
|
||||
color: var(--aurora-text-muted);
|
||||
}
|
||||
.icon-button {
|
||||
width: 42px;
|
||||
min-height: 42px;
|
||||
border: 1px solid var(--aurora-border);
|
||||
border-radius: 8px;
|
||||
color: var(--aurora-text);
|
||||
background: var(--aurora-bg-subtle);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.icon-button:hover:not(:disabled) {
|
||||
border-color: var(--aurora-text-muted);
|
||||
color: var(--aurora-text);
|
||||
}
|
||||
.icon-button--inline {
|
||||
width: 30px;
|
||||
min-height: 30px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--aurora-text-muted);
|
||||
}
|
||||
.icon-button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.users-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;
|
||||
}
|
||||
.users-table {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--aurora-frame-border);
|
||||
border-radius: 8px;
|
||||
background: var(--aurora-frame-bg);
|
||||
}
|
||||
.users-table__head,
|
||||
.user-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(200px, 1.4fr) minmax(180px, 1.2fr) minmax(110px, 0.8fr) 90px 130px 100px;
|
||||
align-items: center;
|
||||
}
|
||||
.users-table__head {
|
||||
min-height: 48px;
|
||||
border-bottom: 1px solid var(--aurora-border);
|
||||
color: var(--aurora-text);
|
||||
font-size: 0.79rem;
|
||||
font-weight: 850;
|
||||
}
|
||||
.users-table__head > div {
|
||||
min-height: 48px;
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.user-row {
|
||||
min-height: 66px;
|
||||
border-bottom: 1px solid var(--aurora-border);
|
||||
}
|
||||
.user-row:hover {
|
||||
background: var(--aurora-bg-subtle);
|
||||
}
|
||||
.user-row__user {
|
||||
display: grid;
|
||||
grid-template-columns: 36px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.user-row__avatar,
|
||||
.user-row__initials {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.user-row__avatar {
|
||||
display: block;
|
||||
}
|
||||
.user-row__initials {
|
||||
background: var(--aurora-bg-subtle);
|
||||
color: var(--aurora-text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
.user-row__person {
|
||||
min-width: 0;
|
||||
}
|
||||
.user-row__person strong {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
color: var(--aurora-text);
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.2;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.user-row__person span {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
color: var(--aurora-text-muted);
|
||||
font-size: 0.74rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.user-row__cell {
|
||||
min-width: 0;
|
||||
padding: 14px 16px;
|
||||
color: var(--aurora-text-muted);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.45;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.user-row__actions {
|
||||
gap: 8px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.users-table__empty {
|
||||
padding: 38px 18px;
|
||||
color: var(--aurora-text-muted);
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
}
|
||||
.users-table__footer {
|
||||
min-height: 54px;
|
||||
border-top: 1px solid var(--aurora-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 26px;
|
||||
padding: 0 16px;
|
||||
color: var(--aurora-text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.users-table__pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.users-table__pager button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--aurora-text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.users-table__pager button:not(:disabled):hover {
|
||||
background: var(--aurora-bg-subtle);
|
||||
color: var(--aurora-text);
|
||||
}
|
||||
.users-table__pager button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.users-table__head,
|
||||
.user-row {
|
||||
grid-template-columns: minmax(180px, 1.4fr) minmax(160px, 1.2fr) minmax(100px, 0.8fr) 80px 120px 90px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.users-table__head {
|
||||
display: none;
|
||||
}
|
||||
.user-row {
|
||||
grid-template-columns: 1fr;
|
||||
align-items: stretch;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.user-row__cell,
|
||||
.user-row__actions {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
.user-row__cell {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
white-space: normal;
|
||||
}
|
||||
.user-row__cell::before {
|
||||
content: attr(data-label);
|
||||
color: var(--aurora-text-muted);
|
||||
font-weight: 800;
|
||||
}
|
||||
.users-table__footer,
|
||||
.users-page__header,
|
||||
.users-page__toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
.users-table__footer {
|
||||
padding: 14px 16px;
|
||||
gap: 10px;
|
||||
}
|
||||
.users-page__search {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user