Add theme presets, gradient backgrounds, and macOS-style window chrome

Replaces the old light/dark toggle with 10 fixed color themes (plus
Black & Grey and White) that drive a coordinated gradient palette across
the whole admin UI, a Google Fonts picker, and a native-page dark-mode
stylesheet that now covers modals and plugin/theme card grids. Fixes
several background/gradient seams where the sidebar, toolbar, and content
frame were each painting their own independent copy of the page gradient
instead of sharing one continuous plane.

Also adds a macOS-style traffic-light title bar to every page (close,
minimize, maximize) and a persistent bottom taskbar so a minimized page
can be restored from anywhere, plus a Plugins management page.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
nixpanel
2026-07-03 11:49:15 -05:00
co-authored by Claude Sonnet 5
parent db07e21f9e
commit 4a547d2c50
26 changed files with 1816 additions and 223 deletions
+1
View File
@@ -1,5 +1,6 @@
app/node_modules/
app/dist/
dist/
*.log
.DS_Store
Thumbs.db
+101 -4
View File
@@ -163,9 +163,24 @@ html.aurora-dark.aurora-ready #wpbody-content .button-primary:hover {
/* Nav tabs -------------------------------------------------------------- */
html.aurora-dark.aurora-ready #wpbody-content .nav-tab-wrapper,
html.aurora-dark.aurora-ready #wpbody-content .wp-filter {
background: var(--aurora-card-bg);
border-color: var(--aurora-border);
}
html.aurora-dark.aurora-ready #wpbody-content .wp-filter .filter-links a {
color: var(--aurora-accent);
}
html.aurora-dark.aurora-ready #wpbody-content .wp-filter .filter-links .current {
color: var(--aurora-text);
}
html.aurora-dark.aurora-ready #wpbody-content .wp-filter .search-form input[type='search'] {
background: var(--aurora-bg-subtle);
border-color: var(--aurora-border);
color: var(--aurora-text);
}
html.aurora-dark.aurora-ready #wpbody-content .nav-tab {
background: var(--aurora-bg-subtle);
border-color: var(--aurora-border);
@@ -251,14 +266,14 @@ html.aurora-dark.aurora-ready body.update-core-php #wpbody-content .wrap > p {
html.aurora-dark.aurora-ready body.update-core-php #wpbody-content .aurora-native-card {
background: var(--aurora-card-bg);
border: 1px solid var(--aurora-card-border);
border-radius: 16px;
padding: 20px 24px;
border-radius: 20px;
padding: 20px;
margin-bottom: 16px;
}
html.aurora-dark.aurora-ready body.update-core-php #wpbody-content .aurora-native-card h2 {
font-size: 1.05rem;
font-size: 1.0625rem;
font-weight: 600;
margin: 4px 0 8px;
margin: 0 0 18px;
color: var(--aurora-text);
border: 0;
padding-top: 0;
@@ -280,3 +295,85 @@ html.aurora-dark.aurora-ready body.update-core-php #wpbody-content .aurora-nativ
html.aurora-dark.aurora-ready body.update-core-php #wpbody-content .aurora-native-card .button:hover {
background: var(--aurora-accent-soft);
}
/* ---------------------------------------------------------------------------
* Modals & popups (media library, plugin/theme install details) — these are
* Backbone/thickbox views appended to <body>, outside #wpbody-content, so
* every rule above misses them entirely. Scoped to html.aurora-dark only
* (no #wpbody-content ancestor requirement) since that's the only anchor
* available at body level.
* ------------------------------------------------------------------------ */
html.aurora-dark.aurora-ready .media-modal,
html.aurora-dark.aurora-ready .media-frame,
html.aurora-dark.aurora-ready .media-frame-title,
html.aurora-dark.aurora-ready .media-frame-router,
html.aurora-dark.aurora-ready .media-frame-content,
html.aurora-dark.aurora-ready .media-frame-toolbar,
html.aurora-dark.aurora-ready .media-sidebar,
html.aurora-dark.aurora-ready #TB_window,
html.aurora-dark.aurora-ready #TB_ajaxContent,
html.aurora-dark.aurora-ready .plugin-details-modal,
html.aurora-dark.aurora-ready .plugin-install-php .plugin-card {
background: var(--aurora-card-bg);
color: var(--aurora-text);
border-color: var(--aurora-card-border);
}
html.aurora-dark.aurora-ready .media-modal h1,
html.aurora-dark.aurora-ready .media-modal h2,
html.aurora-dark.aurora-ready .media-modal h3,
html.aurora-dark.aurora-ready .media-modal label,
html.aurora-dark.aurora-ready .media-frame-title h1,
html.aurora-dark.aurora-ready #TB_ajaxContent,
html.aurora-dark.aurora-ready #TB_ajaxContent p,
html.aurora-dark.aurora-ready #TB_ajaxContent h1,
html.aurora-dark.aurora-ready .plugin-card-top,
html.aurora-dark.aurora-ready .plugin-card .name,
html.aurora-dark.aurora-ready .plugin-card .desc {
color: var(--aurora-text);
}
html.aurora-dark.aurora-ready .media-frame-menu,
html.aurora-dark.aurora-ready .media-menu,
html.aurora-dark.aurora-ready .attachments-browser .media-toolbar,
html.aurora-dark.aurora-ready .media-sidebar,
html.aurora-dark.aurora-ready .plugin-card-bottom {
background: var(--aurora-bg-subtle);
border-color: var(--aurora-border);
}
html.aurora-dark.aurora-ready .attachments-browser .attachments,
html.aurora-dark.aurora-ready .media-frame-content {
background: var(--aurora-app-bg-solid);
}
html.aurora-dark.aurora-ready .media-modal input,
html.aurora-dark.aurora-ready .media-modal textarea,
html.aurora-dark.aurora-ready .media-modal select,
html.aurora-dark.aurora-ready #TB_ajaxContent input,
html.aurora-dark.aurora-ready #TB_ajaxContent textarea,
html.aurora-dark.aurora-ready #TB_ajaxContent select {
background: var(--aurora-bg-subtle);
border-color: var(--aurora-border);
color: var(--aurora-text);
}
html.aurora-dark.aurora-ready .media-modal-backdrop,
html.aurora-dark.aurora-ready #TB_overlay {
background: rgba(0, 0, 0, 0.7);
}
/* Plugin/theme card grids on plugins.php / plugin-install.php / themes.php */
html.aurora-dark.aurora-ready #wpbody-content .theme-browser .theme,
html.aurora-dark.aurora-ready #wpbody-content .plugin-card {
background: var(--aurora-card-bg);
border-color: var(--aurora-card-border);
color: var(--aurora-text);
}
html.aurora-dark.aurora-ready #wpbody-content .theme-browser .theme .theme-name,
html.aurora-dark.aurora-ready #wpbody-content .theme-browser .theme .theme-actions {
background: var(--aurora-card-bg);
color: var(--aurora-text);
border-color: var(--aurora-card-border);
}
+2
View File
@@ -8,6 +8,7 @@ use AuroraAdmin\Pages\SettingsPage;
use AuroraAdmin\Rest\DashboardData;
use AuroraAdmin\Analytics\Analytics;
use AuroraAdmin\Rest\AnalyticsData;
use AuroraAdmin\Rest\PluginsData;
use AuroraAdmin\Posts\ScheduledDayFilter;
use AuroraAdmin\WhiteLabel\WhiteLabel;
use AuroraAdmin\Pages\SubApps;
@@ -39,6 +40,7 @@ class AuroraAdmin
new DashboardData();
new Analytics();
new AnalyticsData();
new PluginsData();
new ScheduledDayFilter();
new WhiteLabel();
new SubApps();
+6 -1
View File
@@ -35,7 +35,12 @@ class Settings
"schema" => [
"type" => "object",
"properties" => [
"dark_mode" => ["type" => "string"],
// Two producers write this key with different types: the
// toolbar's tri-state switcher sends 'light'/'dark'/'system'
// (string), the settings page's Dark Mode toggle sends a plain
// boolean. Leaving it untyped (falls through to
// additionalProperties) lets both save instead of one
// rejecting the other with a 400.
"dashboard_card_order" => ["type" => "object"],
],
"additionalProperties" => true,
+5 -3
View File
@@ -24,6 +24,7 @@ class SubApps
"aurora-admin-roles" => ["title" => "Role Editor", "entry" => "role-editor"],
"aurora-admin-activity-log" => ["title" => "Activity Log", "entry" => "activity-log"],
"aurora-admin-database" => ["title" => "Database Explorer", "entry" => "database-explorer"],
"aurora-admin-plugins" => ["title" => "Installed Plugins", "entry" => "plugins"],
];
private static $hook_suffixes = [];
@@ -38,11 +39,12 @@ class SubApps
public static function register_menus()
{
foreach (self::PAGES as $slug => $page) {
$parent = $slug === "aurora-admin-plugins" ? "plugins.php" : "aurora-admin";
$hook = add_submenu_page(
"aurora-admin",
$parent,
__($page["title"], "aurora-admin"),
__($page["title"], "aurora-admin"),
"manage_options",
$slug === "aurora-admin-plugins" ? "activate_plugins" : "manage_options",
$slug,
[self::class, "render"]
);
@@ -53,7 +55,7 @@ class SubApps
public static function hide_from_native_submenu()
{
foreach (array_keys(self::PAGES) as $slug) {
remove_submenu_page("aurora-admin", $slug);
remove_submenu_page($slug === "aurora-admin-plugins" ? "plugins.php" : "aurora-admin", $slug);
}
}
+248
View File
@@ -0,0 +1,248 @@
<?php
namespace AuroraAdmin\Rest;
defined("ABSPATH") || exit();
/**
* REST endpoints for Aurora's custom Installed Plugins page.
*/
class PluginsData
{
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
register_rest_route("aurora-admin/v1", "/plugins", [
"methods" => "GET",
"callback" => [self::class, "list_plugins"],
"permission_callback" => function () {
return current_user_can("activate_plugins");
},
]);
register_rest_route("aurora-admin/v1", "/plugins/activate", [
"methods" => "POST",
"callback" => [self::class, "activate_plugin"],
"permission_callback" => function () {
return current_user_can("activate_plugins");
},
"args" => [
"file" => ["type" => "string", "required" => true],
],
]);
register_rest_route("aurora-admin/v1", "/plugins/deactivate", [
"methods" => "POST",
"callback" => [self::class, "deactivate_plugin"],
"permission_callback" => function () {
return current_user_can("activate_plugins");
},
"args" => [
"file" => ["type" => "string", "required" => true],
],
]);
register_rest_route("aurora-admin/v1", "/plugins/delete", [
"methods" => "DELETE",
"callback" => [self::class, "delete_plugin"],
"permission_callback" => function () {
return current_user_can("delete_plugins");
},
"args" => [
"file" => ["type" => "string", "required" => true],
],
]);
register_rest_route("aurora-admin/v1", "/plugins/update", [
"methods" => "POST",
"callback" => [self::class, "update_plugin"],
"permission_callback" => function () {
return current_user_can("update_plugins");
},
"args" => [
"file" => ["type" => "string", "required" => true],
],
]);
}
public static function list_plugins()
{
self::load_plugin_admin_functions();
self::load_update_functions();
$updates = get_site_transient("update_plugins");
if (!is_object($updates) && function_exists("wp_update_plugins")) {
wp_update_plugins();
$updates = get_site_transient("update_plugins");
}
$update_response = is_object($updates) && isset($updates->response) && is_array($updates->response)
? $updates->response
: [];
$items = [];
foreach (get_plugins() as $file => $plugin) {
$update = isset($update_response[$file]) ? $update_response[$file] : null;
$items[] = [
"file" => $file,
"slug" => self::plugin_slug($file),
"name" => isset($plugin["Name"]) ? $plugin["Name"] : self::plugin_slug($file),
"description" => isset($plugin["Description"]) ? wp_strip_all_tags($plugin["Description"]) : "",
"version" => isset($plugin["Version"]) ? $plugin["Version"] : "",
"author" => isset($plugin["Author"]) ? wp_strip_all_tags($plugin["Author"]) : "",
"pluginUri" => isset($plugin["PluginURI"]) ? esc_url_raw($plugin["PluginURI"]) : "",
"authorUri" => isset($plugin["AuthorURI"]) ? esc_url_raw($plugin["AuthorURI"]) : "",
"active" => is_plugin_active($file),
"networkActive" => is_multisite() && is_plugin_active_for_network($file),
"updateAvailable" => $update !== null,
"newVersion" => $update && isset($update->new_version) ? $update->new_version : "",
"canActivate" => current_user_can("activate_plugin", $file),
"canDelete" => current_user_can("delete_plugins") && !is_plugin_active($file),
"canUpdate" => current_user_can("update_plugins") && $update !== null,
];
}
usort($items, function ($a, $b) {
if ($a["active"] !== $b["active"]) {
return $a["active"] ? -1 : 1;
}
return strcasecmp($a["name"], $b["name"]);
});
return new \WP_REST_Response([
"items" => $items,
"stats" => self::stats($items),
"addUrl" => admin_url("plugin-install.php"),
], 200);
}
public static function activate_plugin($request)
{
self::load_plugin_admin_functions();
$file = self::sanitize_file($request->get_param("file"));
$result = activate_plugin($file);
if (is_wp_error($result)) {
return new \WP_Error("aurora_plugin_activate_failed", $result->get_error_message(), ["status" => 500]);
}
self::clean_plugin_cache();
return new \WP_REST_Response(["success" => true, "message" => __("Plugin activated.", "aurora-admin")], 200);
}
public static function deactivate_plugin($request)
{
self::load_plugin_admin_functions();
$file = self::sanitize_file($request->get_param("file"));
deactivate_plugins($file);
self::clean_plugin_cache();
return new \WP_REST_Response([
"success" => true,
"message" => __("Plugin deactivated.", "aurora-admin"),
"redirect" => $file === "aurora-admin/aurora-admin.php" ? admin_url("plugins.php") : "",
], 200);
}
public static function delete_plugin($request)
{
self::load_plugin_admin_functions();
if (!function_exists("delete_plugins")) {
require_once ABSPATH . "wp-admin/includes/plugin.php";
}
$file = self::sanitize_file($request->get_param("file"));
if (is_plugin_active($file)) {
return new \WP_Error("aurora_plugin_active_delete", __("Deactivate the plugin before deleting it.", "aurora-admin"), ["status" => 400]);
}
$result = delete_plugins([$file]);
if (is_wp_error($result)) {
return new \WP_Error("aurora_plugin_delete_failed", $result->get_error_message(), ["status" => 500]);
}
self::clean_plugin_cache();
return new \WP_REST_Response(["success" => true, "message" => __("Plugin deleted.", "aurora-admin")], 200);
}
public static function update_plugin($request)
{
self::load_plugin_admin_functions();
self::load_update_functions();
require_once ABSPATH . "wp-admin/includes/class-wp-upgrader.php";
require_once ABSPATH . "wp-admin/includes/file.php";
require_once ABSPATH . "wp-admin/includes/misc.php";
$file = self::sanitize_file($request->get_param("file"));
$skin = new \Automatic_Upgrader_Skin();
$upgrader = new \Plugin_Upgrader($skin);
$result = $upgrader->upgrade($file);
if (is_wp_error($result)) {
return new \WP_Error("aurora_plugin_update_failed", $result->get_error_message(), ["status" => 500]);
}
if (!$result) {
$error = $skin->get_errors();
$message = is_wp_error($error) && $error->has_errors()
? $error->get_error_message()
: __("Plugin update failed.", "aurora-admin");
return new \WP_Error("aurora_plugin_update_failed", $message, ["status" => 500]);
}
self::clean_plugin_cache();
return new \WP_REST_Response(["success" => true, "message" => __("Plugin updated.", "aurora-admin")], 200);
}
private static function load_plugin_admin_functions()
{
if (!function_exists("get_plugins") || !function_exists("is_plugin_active")) {
require_once ABSPATH . "wp-admin/includes/plugin.php";
}
}
private static function load_update_functions()
{
if (!function_exists("wp_update_plugins")) {
require_once ABSPATH . "wp-admin/includes/update.php";
}
}
private static function sanitize_file($file)
{
return plugin_basename(sanitize_text_field((string) $file));
}
private static function plugin_slug($file)
{
$parts = explode("/", $file);
return $parts[0];
}
private static function clean_plugin_cache()
{
if (function_exists("wp_clean_plugins_cache")) {
wp_clean_plugins_cache(true);
}
wp_cache_delete("plugins", "plugins");
}
private static function stats($items)
{
$stats = ["total" => count($items), "active" => 0, "inactive" => 0, "updates" => 0];
foreach ($items as $item) {
if ($item["active"]) {
$stats["active"]++;
} else {
$stats["inactive"]++;
}
if ($item["updateAvailable"]) {
$stats["updates"]++;
}
}
return $stats;
}
}
+4
View File
@@ -132,6 +132,10 @@ class MenuSerializer
return "";
}
if ($slug === "plugins.php") {
return admin_url("plugins.php?page=aurora-admin-plugins");
}
// Already an absolute URL.
if (preg_match('#^https?://#', $slug)) {
return $slug;
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import Plugins from '@/pages/plugins/Plugins.vue';
const mountEl = document.getElementById('aurora-admin-plugins-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(Plugins, { serverData: data }).mount(mountEl);
}
+8 -8
View File
@@ -76,15 +76,15 @@ const search = defineModel('search', { type: String, default: '' });
margin: -32px;
padding: 22px;
box-sizing: border-box;
background: var(--aurora-app-bg);
/* No background — html/body already paint the page gradient; this was
a redundant duplicate layer on top of it. */
}
/* Left — list box */
/* Left — no background/border of its own; the page gradient shows
straight through so it reads as one surface instead of a boxed panel. */
.app-list-shell__nav {
width: 340px;
flex-shrink: 0;
background: var(--aurora-frame-bg);
border: 1px solid var(--aurora-frame-border);
border-radius: 16px;
padding: 24px 20px;
box-sizing: border-box;
@@ -127,7 +127,9 @@ const search = defineModel('search', { type: String, default: '' });
background: var(--aurora-bg-subtle); color: var(--aurora-text-muted);
}
.app-list-shell__filters :deep(button.is-active) {
background: var(--aurora-text); color: var(--aurora-app-bg);
/* --aurora-app-bg can be a gradient (invalid for `color`), so this one
inverted-contrast badge uses the flat fallback token instead. */
background: var(--aurora-text); color: var(--aurora-app-bg-solid);
}
.app-list-shell__meta {
@@ -145,11 +147,9 @@ const search = defineModel('search', { type: String, default: '' });
.app-list-shell__list { display: flex; flex-direction: column; gap: 2px; }
/* Right — content box */
/* Right — no background/border of its own, same reasoning. */
.app-list-shell__content {
flex: 1; min-width: 0;
background: var(--aurora-frame-bg);
border: 1px solid var(--aurora-frame-border);
border-radius: 16px;
padding: 28px 32px;
min-height: 480px;
@@ -30,7 +30,11 @@ defineProps({
display: flex;
flex-direction: column;
height: 100%;
background: var(--aurora-card-bg);
/* Deeper than the generic --aurora-card-bg — the Dashboard is now
wrapped in its own frame (windowChrome.js's .aurora-dashboard-frame,
using --aurora-frame-bg), and that's close in tone to --aurora-card-bg
for most presets, so cards would nearly disappear into it otherwise. */
background: var(--aurora-card-bg-deep, var(--aurora-card-bg));
border: 1px solid var(--aurora-card-border);
border-radius: 20px;
padding: 20px;
+690
View File
@@ -0,0 +1,690 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
const props = defineProps({
serverData: { type: Object, required: true },
});
const plugins = ref([]);
const stats = ref({ total: 0, active: 0, inactive: 0, updates: 0 });
const addUrl = ref('');
const loading = ref(true);
const busyFile = ref('');
const error = ref('');
const notice = ref('');
const search = ref('');
const status = ref('all');
const compact = 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: {
'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 data = await request('/plugins');
plugins.value = data.items || [];
stats.value = data.stats || stats.value;
addUrl.value = data.addUrl || '';
} catch (err) {
error.value = err.message || 'Could not load plugins';
} finally {
loading.value = false;
}
};
onMounted(load);
const filtered = computed(() => {
const query = search.value.trim().toLowerCase();
return plugins.value.filter((plugin) => {
const matchesStatus =
status.value === 'all' ||
(status.value === 'active' && plugin.active) ||
(status.value === 'inactive' && !plugin.active) ||
(status.value === 'updates' && plugin.updateAvailable);
if (!matchesStatus) return false;
if (!query) return true;
return [plugin.name, plugin.description, plugin.author, plugin.file]
.join(' ')
.toLowerCase()
.includes(query);
});
});
const filters = computed(() => [
{ key: 'all', label: 'All', count: stats.value.total },
{ key: 'active', label: 'Active', count: stats.value.active },
{ key: 'inactive', label: 'Inactive', count: stats.value.inactive },
{ key: 'updates', label: 'Update Available', count: stats.value.updates },
]);
const runAction = async (action, plugin) => {
if (!plugin || busyFile.value) return;
busyFile.value = plugin.file;
error.value = '';
notice.value = '';
try {
const data = await request(`/plugins/${action}`, {
method: action === 'delete' ? 'DELETE' : 'POST',
body: JSON.stringify({ file: plugin.file }),
});
notice.value = data.message || 'Done';
if (data.redirect) {
window.location.href = data.redirect;
return;
}
await load();
} catch (err) {
error.value = err.message || 'Action failed';
} finally {
busyFile.value = '';
}
};
const openAdd = () => {
if (addUrl.value) {
window.location.href = addUrl.value;
}
};
const pluginInitial = (name) => (name || 'P').trim().charAt(0).toUpperCase();
const statusLabel = (plugin) => {
if (plugin.networkActive) return 'Network active';
return plugin.active ? 'Active' : 'Inactive';
};
const versionLine = (plugin) => {
if (plugin.updateAvailable && plugin.newVersion) {
return `${plugin.version || 'Unknown'} -> ${plugin.newVersion}`;
}
return plugin.version || 'Unknown';
};
</script>
<template>
<main class="plugins-page">
<header class="plugins-page__header">
<div>
<p class="plugins-page__eyebrow">Aurora Admin</p>
<h1>Plugins</h1>
</div>
<div class="plugins-page__actions">
<button
type="button"
class="icon-button"
:class="{ 'is-active': compact }"
title="Toggle compact view"
@click="compact = !compact"
>
<span class="dashicons dashicons-menu-alt3" />
</button>
<button type="button" class="icon-button" title="Refresh plugins" :disabled="loading" @click="load">
<span class="dashicons dashicons-update" />
</button>
<button type="button" class="primary-button" @click="openAdd">
<span class="dashicons dashicons-plus-alt2" />
Add New
</button>
</div>
</header>
<section class="plugins-page__toolbar" aria-label="Plugin filters and search">
<div class="plugins-page__filters">
<button
v-for="filter in filters"
:key="filter.key"
type="button"
class="filter-pill"
:class="{ 'is-active': status === filter.key }"
@click="status = filter.key"
>
<span>{{ filter.label }}</span>
<strong>{{ filter.count }}</strong>
</button>
</div>
<label class="plugins-page__search">
<span class="dashicons dashicons-search" />
<input v-model="search" type="search" placeholder="Search plugins..." />
</label>
</section>
<div v-if="notice" class="plugins-notice plugins-notice--success">{{ notice }}</div>
<div v-if="error" class="plugins-notice plugins-notice--error">{{ error }}</div>
<section class="plugins-table" :class="{ 'is-compact': compact }" aria-label="Installed plugins">
<div class="plugins-table__head">
<div>Plugin Info</div>
<div>Version <span class="dashicons dashicons-filter" /></div>
<div>Status <span class="dashicons dashicons-filter" /></div>
<div>Author <span class="dashicons dashicons-filter" /></div>
<div>Actions</div>
</div>
<div v-if="loading" class="plugins-table__empty">Loading plugins...</div>
<div v-else-if="!filtered.length" class="plugins-table__empty">No plugins match your filters.</div>
<article
v-for="plugin in filtered"
v-else
:key="plugin.file"
class="plugin-row"
:class="{ 'is-active': plugin.active }"
>
<div class="plugin-row__info">
<div class="plugin-row__mark">
<span>{{ pluginInitial(plugin.name) }}</span>
</div>
<div class="plugin-row__copy">
<h2>{{ plugin.name }}</h2>
<p>{{ plugin.description || 'No description provided.' }}</p>
<div class="plugin-row__links">
<a v-if="plugin.pluginUri" :href="plugin.pluginUri" target="_blank" rel="noopener noreferrer">
Plugin site
</a>
<a v-if="plugin.authorUri" :href="plugin.authorUri" target="_blank" rel="noopener noreferrer">
Author site
</a>
<span>{{ plugin.file }}</span>
</div>
</div>
</div>
<div class="plugin-row__cell" data-label="Version">
<strong>{{ versionLine(plugin) }}</strong>
<span v-if="plugin.updateAvailable">Update ready</span>
</div>
<div class="plugin-row__cell" data-label="Status">
<span class="status-badge" :class="{ 'status-badge--active': plugin.active }">
<i />
{{ statusLabel(plugin) }}
</span>
</div>
<div class="plugin-row__cell" data-label="Author">
<strong>{{ plugin.author || 'Unknown' }}</strong>
</div>
<div class="plugin-row__actions">
<button
v-if="!plugin.active"
type="button"
class="toggle-button"
title="Activate plugin"
:disabled="busyFile === plugin.file || !plugin.canActivate"
@click="runAction('activate', plugin)"
>
<span />
</button>
<button
v-else
type="button"
class="toggle-button is-on"
title="Deactivate plugin"
:disabled="busyFile === plugin.file"
@click="runAction('deactivate', plugin)"
>
<span />
</button>
<button
v-if="plugin.updateAvailable"
type="button"
class="icon-button icon-button--inline"
title="Update plugin"
:disabled="busyFile === plugin.file || !plugin.canUpdate"
@click="runAction('update', plugin)"
>
<span class="dashicons dashicons-update-alt" />
</button>
<button
type="button"
class="icon-button icon-button--inline icon-button--danger"
title="Delete plugin"
:disabled="busyFile === plugin.file || !plugin.canDelete"
@click="runAction('delete', plugin)"
>
<span class="dashicons dashicons-trash" />
</button>
<a
v-if="plugin.pluginUri"
class="icon-button icon-button--inline"
title="Open plugin site"
:href="plugin.pluginUri"
target="_blank"
rel="noopener noreferrer"
>
<span class="dashicons dashicons-ellipsis" />
</a>
</div>
</article>
</section>
</main>
</template>
<style scoped>
.plugins-page {
min-height: calc(100vh - 112px);
padding: 0;
color: var(--aurora-text);
background: transparent;
}
.plugins-page__header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 18px;
margin-bottom: 24px;
}
.plugins-page__eyebrow {
margin: 0 0 7px;
color: var(--aurora-text-muted);
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0;
text-transform: uppercase;
}
.plugins-page h1 {
margin: 0;
color: var(--aurora-text);
font-size: 1.45rem;
line-height: 1.15;
}
.plugins-page__actions,
.plugins-page__toolbar,
.plugins-page__filters,
.plugin-row__actions,
.plugin-row__links {
display: flex;
align-items: center;
}
.plugins-page__actions {
gap: 10px;
}
.plugins-page__toolbar {
justify-content: space-between;
gap: 18px;
margin-bottom: 12px;
}
.plugins-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);
}
.filter-pill.is-active strong {
background: var(--aurora-bg-subtle);
color: var(--aurora-text);
}
.plugins-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);
}
.plugins-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;
}
.plugins-page__search input::placeholder {
color: var(--aurora-text-muted);
}
.primary-button,
.icon-button {
border: 1px solid var(--aurora-border);
border-radius: 8px;
min-height: 42px;
color: var(--aurora-text);
background: var(--aurora-bg-subtle);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.primary-button {
gap: 8px;
border-color: var(--aurora-text-muted);
background: var(--aurora-frame-bg);
color: var(--aurora-text);
padding: 0 16px;
font-size: 0.84rem;
font-weight: 850;
}
.icon-button {
width: 42px;
padding: 0;
text-decoration: none;
}
.icon-button.is-active {
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:hover:not(:disabled),
.icon-button--inline:hover:not(:disabled) {
color: var(--aurora-text);
border-color: var(--aurora-text-muted);
}
.icon-button--danger:hover:not(:disabled) {
color: var(--aurora-text);
}
.primary-button:disabled,
.icon-button:disabled,
.toggle-button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.plugins-notice {
margin: 0 0 12px;
padding: 10px 12px;
border-radius: 8px;
font-size: 0.78rem;
font-weight: 750;
}
.plugins-notice--success {
background: var(--aurora-bg-subtle);
color: var(--aurora-text);
}
.plugins-notice--error {
background: var(--aurora-bg-subtle);
color: var(--aurora-text);
}
.plugins-table {
overflow: hidden;
border: 1px solid var(--aurora-frame-border);
border-radius: 8px;
background: var(--aurora-frame-bg);
box-shadow: none;
}
.plugins-table__head,
.plugin-row {
display: grid;
grid-template-columns: minmax(320px, 1fr) 140px 140px 170px 150px;
align-items: center;
}
.plugins-table__head {
min-height: 48px;
border-bottom: 1px solid var(--aurora-border);
color: var(--aurora-text);
font-size: 0.79rem;
font-weight: 850;
}
.plugins-table__head > * {
padding: 0 16px;
}
.plugins-table__head > div {
min-height: 48px;
display: flex;
align-items: center;
justify-content: space-between;
}
.plugin-row {
min-height: 78px;
border-bottom: 1px solid var(--aurora-border);
transition: background 160ms ease, border-color 160ms ease;
}
.plugin-row:last-child {
border-bottom: 0;
}
.plugin-row:hover {
background: var(--aurora-bg-subtle);
}
.plugin-row.is-active {
background: var(--aurora-bg-subtle);
}
.plugin-row__info {
display: grid;
grid-template-columns: 46px minmax(0, 1fr);
gap: 16px;
align-items: center;
min-width: 0;
padding: 16px;
}
.plugin-row__mark {
width: 46px;
height: 46px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
background: var(--aurora-bg-subtle);
color: var(--aurora-text);
font-size: 1rem;
font-weight: 900;
}
.plugin-row__copy {
min-width: 0;
}
.plugin-row__copy h2 {
margin: 0 0 5px;
color: var(--aurora-text);
font-size: 0.95rem;
line-height: 1.2;
}
.plugin-row__copy p {
margin: 0;
color: var(--aurora-text-muted);
font-size: 0.78rem;
line-height: 1.35;
}
.plugin-row__links {
gap: 10px;
flex-wrap: wrap;
margin-top: 8px;
color: var(--aurora-text-muted);
font-size: 0.7rem;
}
.plugin-row__links a {
color: var(--aurora-text);
font-weight: 800;
text-decoration: none;
}
.plugin-row__links span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.plugin-row__cell {
min-width: 0;
padding: 16px;
color: var(--aurora-text-muted);
font-size: 0.78rem;
}
.plugin-row__cell strong {
display: block;
overflow: hidden;
color: var(--aurora-text-muted);
font-size: 0.82rem;
font-weight: 850;
text-overflow: ellipsis;
white-space: nowrap;
}
.plugin-row__cell span {
display: block;
margin-top: 4px;
color: var(--aurora-text-muted);
font-size: 0.7rem;
font-weight: 800;
}
.status-badge {
width: fit-content;
border-radius: 999px;
background: var(--aurora-bg-subtle);
color: var(--aurora-text-muted);
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
font-size: 0.75rem;
font-weight: 850;
}
.status-badge i {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
opacity: 0.8;
}
.status-badge--active {
background: var(--aurora-frame-bg);
color: var(--aurora-text);
box-shadow: inset 0 0 0 1px var(--aurora-border);
}
.plugin-row__actions {
gap: 8px;
justify-content: flex-start;
padding: 16px;
}
.plugins-table.is-compact .plugin-row {
min-height: 62px;
}
.plugins-table.is-compact .plugin-row__info,
.plugins-table.is-compact .plugin-row__cell,
.plugins-table.is-compact .plugin-row__actions {
padding-top: 10px;
padding-bottom: 10px;
}
.plugins-table.is-compact .plugin-row__mark {
width: 38px;
height: 38px;
}
.plugins-table.is-compact .plugin-row__links {
display: none;
}
.toggle-button {
position: relative;
width: 34px;
height: 18px;
border: 0;
border-radius: 999px;
background: var(--aurora-bg-subtle);
cursor: pointer;
}
.toggle-button span {
position: absolute;
top: 3px;
left: 3px;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--aurora-text-muted);
transition: transform 160ms ease, background 160ms ease;
}
.toggle-button.is-on {
background: var(--aurora-text-muted);
}
.toggle-button.is-on span {
transform: translateX(16px);
background: var(--aurora-frame-bg);
}
.plugins-table__empty {
padding: 38px 18px;
color: var(--aurora-text-muted);
font-size: 0.85rem;
text-align: center;
}
@media (max-width: 1120px) {
.plugins-page {
padding: 24px 18px 34px;
}
.plugins-table__head {
display: none;
}
.plugin-row {
grid-template-columns: 1fr;
align-items: stretch;
padding: 8px 0;
}
.plugin-row__cell,
.plugin-row__actions {
padding: 8px 16px;
}
.plugin-row__cell {
display: flex;
justify-content: space-between;
gap: 16px;
}
.plugin-row__cell::before {
content: attr(data-label);
color: var(--aurora-text-muted);
font-weight: 800;
}
}
@media (max-width: 720px) {
.plugins-page__header,
.plugins-page__toolbar {
align-items: stretch;
flex-direction: column;
}
.plugins-page__actions {
justify-content: flex-start;
}
.plugins-page__search {
width: 100%;
}
}
</style>
+17 -11
View File
@@ -2,6 +2,7 @@
import { ref, reactive, computed, watch } from 'vue';
import { settingsCategories } from './settingsConfig.js';
import SettingsField from './components/SettingsField.vue';
import { applyThemePalette, applyFontFamily } from './applyLiveTheme.js';
const props = defineProps({
serverData: { type: Object, required: true },
@@ -51,10 +52,17 @@ const optionsFor = (field) => {
return field.options || [];
};
// Live dark-mode preview.
// Live preview — applied as soon as a picker changes, not just after
// Save + reload.
watch(
() => settings.dark_mode,
(v) => document.documentElement.classList.toggle('aurora-dark', !!v)
() => settings.theme_preset,
(preset) => applyThemePalette(preset || 'indigo'),
{ immediate: true }
);
watch(
() => settings.font_family,
(family) => applyFontFamily(family),
{ immediate: true }
);
// Save -------------------------------------------------------------------
@@ -189,16 +197,16 @@ const importSettings = (e) => {
margin: -32px; /* cancel the frame padding */
padding: 22px; /* inset the two boxes; the frame's right gutter adds the rest */
box-sizing: border-box;
/* Darker backdrop so the two boxes read as distinct panels. */
background: var(--aurora-app-bg);
/* 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 — its own box */
/* 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. */
.settings__nav {
width: 340px;
flex-shrink: 0;
background: var(--aurora-frame-bg);
border: 1px solid var(--aurora-frame-border);
border-radius: 16px;
padding: 24px 20px;
box-sizing: border-box;
@@ -252,11 +260,9 @@ const importSettings = (e) => {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* Right content — its own box */
/* Right content — no background/border of its own, same reasoning. */
.settings__content {
flex: 1; min-width: 0;
background: var(--aurora-frame-bg);
border: 1px solid var(--aurora-frame-border);
border-radius: 16px;
padding: 28px 32px;
}
+26
View File
@@ -0,0 +1,26 @@
/**
* Shared logic for applying the theme/font settings to the live document —
* used both for the settings page's live preview (Settings.vue) and the
* real per-page-load application (SiteShell.vue::onMounted).
*/
import { resolvePreset, buildPalette } from './themePresets.js';
export function applyThemePalette(themePresetId) {
const palette = buildPalette(resolvePreset(themePresetId));
const root = document.documentElement.style;
Object.entries(palette).forEach(([prop, value]) => root.setProperty(prop, value));
}
export function applyFontFamily(family) {
if (!family) return;
const linkId = 'aurora-google-font';
let link = document.getElementById(linkId);
if (!link) {
link = document.createElement('link');
link.id = linkId;
link.rel = 'stylesheet';
document.head.appendChild(link);
}
link.href = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}:wght@400;500;600;700&display=swap`;
document.documentElement.style.setProperty('--aurora-font-family', `'${family}', sans-serif`);
}
@@ -1,6 +1,10 @@
<script setup>
import { computed, reactive } from 'vue';
import { computed, reactive, ref } from 'vue';
import Toggle from '@/components/Toggle.vue';
import { themePresets, buildPalette } from '@/pages/settings/themePresets.js';
import { googleFonts } from '@/pages/settings/googleFonts.js';
const swatchPalette = (preset) => buildPalette(preset);
const props = defineProps({
field: { type: Object, required: true },
@@ -66,6 +70,24 @@ const pickImage = () => {
});
frame.open();
};
// Font picker (searchable Google Font dropdown) ----------------------------
const fontQuery = ref('');
const fontOpen = ref(false);
const filteredFonts = computed(() => {
const q = fontQuery.value.trim().toLowerCase();
const list = q ? googleFonts.filter((f) => f.toLowerCase().includes(q)) : googleFonts;
return list.slice(0, 40);
});
const pickFont = (name) => {
emit('update:modelValue', name);
fontQuery.value = name;
fontOpen.value = false;
};
const openFontList = () => {
fontQuery.value = props.modelValue || '';
fontOpen.value = true;
};
</script>
<template>
@@ -169,6 +191,59 @@ const pickImage = () => {
</p>
</div>
<!-- Theme picker (10 fixed color presets, whole-UI preview) -->
<div v-else-if="field.type === 'theme-picker'" class="field__theme">
<button
v-for="preset in themePresets"
:key="preset.id"
type="button"
class="field__theme-card"
:class="{ 'field__theme-card--active': (val || field.default) === preset.id }"
@click="val = preset.id"
>
<span
class="field__theme-swatch"
:style="{
background: swatchPalette(preset)['--aurora-app-bg'],
borderColor: swatchPalette(preset)['--aurora-border'],
}"
>
<span
class="field__theme-swatch-accent"
:style="{ background: swatchPalette(preset)['--aurora-accent'] }"
/>
</span>
<span class="field__theme-name">{{ preset.label }}</span>
</button>
</div>
<!-- Font picker (searchable Google Font dropdown) -->
<div v-else-if="field.type === 'font-picker'" class="field__font">
<input
type="text"
class="field__input"
placeholder="Search Google Fonts…"
:value="fontOpen ? fontQuery : (val || 'Default')"
@focus="openFontList"
@input="fontQuery = $event.target.value"
@blur="() => setTimeout(() => (fontOpen = false), 150)"
/>
<div v-if="fontOpen" class="field__font-list">
<button
v-for="name in filteredFonts"
:key="name"
type="button"
class="field__font-item"
:class="{ 'field__font-item--active': val === name }"
:style="{ fontFamily: `'${name}', sans-serif` }"
@mousedown.prevent="pickFont(name)"
>
{{ name }}
</button>
<p v-if="!filteredFonts.length" class="field__multi-empty">No fonts match.</p>
</div>
</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">
@@ -309,6 +384,49 @@ const pickImage = () => {
.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 {
display: flex; flex-direction: column; align-items: center; gap: 6px;
width: 76px; padding: 10px 6px;
border: 1px solid var(--aurora-border);
border-radius: 10px;
background: var(--aurora-bg-subtle);
cursor: pointer;
}
.field__theme-card--active { border-color: var(--aurora-accent); box-shadow: 0 0 0 1px var(--aurora-accent); }
.field__theme-swatch {
width: 36px; height: 36px; border-radius: 10px;
border: 1px solid var(--aurora-card-border);
display: flex; align-items: flex-end; justify-content: flex-end;
padding: 4px;
box-sizing: border-box;
}
.field__theme-swatch-accent {
width: 10px; height: 10px; border-radius: 50%;
}
.field__theme-name { font-size: 0.6875rem; color: var(--aurora-text-muted); }
/* Font picker */
.field__font { position: relative; width: 100%; max-width: 320px; }
.field__font-list {
position: absolute; top: calc(100% + 4px); left: 0; right: 0;
max-height: 260px; overflow-y: auto; z-index: 20;
background: var(--aurora-card-bg);
border: 1px solid var(--aurora-border);
border-radius: 10px;
padding: 6px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
}
.field__font-item {
display: block; width: 100%; text-align: left;
border: none; background: none; cursor: pointer;
padding: 8px 10px; border-radius: 7px;
font-size: 0.875rem; color: var(--aurora-text);
}
.field__font-item:hover { background: var(--aurora-bg-subtle); }
.field__font-item--active { color: var(--aurora-accent); }
@media (max-width: 782px) {
.field { grid-template-columns: 1fr; gap: 12px; }
}
+79
View File
@@ -0,0 +1,79 @@
/**
* Curated list of popular Google Font family names for the font-picker
* field. Not the full ~1700-family catalog — a representative set across
* sans, serif, display, and monospace so the picker has something useful
* to search without shipping/maintaining a giant metadata blob. Each
* family is requested from Google's CSS2 API on demand (see
* SiteShell.vue::applyFontFamily), never bundled as font files.
*/
export const googleFonts = [
'Inter',
'Roboto',
'Open Sans',
'Lato',
'Montserrat',
'Poppins',
'Nunito',
'Nunito Sans',
'Source Sans 3',
'Work Sans',
'Rubik',
'Manrope',
'DM Sans',
'Karla',
'Mulish',
'Figtree',
'Plus Jakarta Sans',
'Outfit',
'Space Grotesk',
'Sora',
'Albert Sans',
'Urbanist',
'Public Sans',
'IBM Plex Sans',
'Barlow',
'Cabin',
'Hanken Grotesk',
'Lexend',
'Jost',
'Red Hat Display',
'Playfair Display',
'Merriweather',
'Lora',
'PT Serif',
'Crimson Text',
'Source Serif 4',
'Libre Baskerville',
'Cormorant Garamond',
'EB Garamond',
'Bitter',
'Domine',
'Fraunces',
'Newsreader',
'Spectral',
'Frank Ruhl Libre',
'Josefin Sans',
'Quicksand',
'Comfortaa',
'Raleway',
'Oswald',
'Bebas Neue',
'Archivo',
'Archivo Black',
'Anton',
'Righteous',
'Fjalla One',
'Abril Fatface',
'Cormorant',
'Zilla Slab',
'IBM Plex Mono',
'JetBrains Mono',
'Space Mono',
'Roboto Mono',
'Fira Code',
'Source Code Pro',
'Courier Prime',
'Instrument Sans',
'Instrument Serif',
'Bricolage Grotesque',
];
+9 -9
View File
@@ -94,17 +94,17 @@ export const settingsCategories = [
icon: 'dashicons-admin-appearance',
fields: [
{
key: 'dark_mode',
type: 'toggle',
label: 'Dark mode',
description: 'Use the dark interface theme across the admin.',
key: 'theme_preset',
type: 'theme-picker',
label: 'Theme',
description: 'Pick a color for the whole admin interface.',
default: 'indigo',
},
{
key: 'accent_color',
type: 'color',
label: 'Accent color',
description: 'Primary accent color used throughout the interface.',
default: '#4772fa',
key: 'font_family',
type: 'font-picker',
label: 'Font family',
description: 'Google Font used across the Aurora interface.',
},
],
},
+103
View File
@@ -0,0 +1,103 @@
/**
* Fixed set of theme presets for the theme-picker field. No custom color
* picker — exactly these 10 predetermined options. Each preset is a hue;
* the main background gradient is mostly two vivid, saturated stops of
* that hue (like FleekDash's "Fresh"/"Sunrise" palette bars), only
* dipping to a near-black anchor in the last stretch — not a 50/50
* dark-to-vivid split, which reads washed out rather than "poppy".
* Cards/borders/text derive as solid, more muted shades of the primary
* hue so the gradient stays a deliberate hero background rather than
* making every surface busy.
*/
export const themePresets = [
{ id: 'indigo', label: 'Indigo', hue: 235 },
{ id: 'sky', label: 'Sky', hue: 205 },
{ id: 'teal', label: 'Teal', hue: 175 },
{ id: 'forest', label: 'Forest', hue: 140 },
{ id: 'lime', label: 'Lime', hue: 95 },
{ id: 'amber', label: 'Amber', hue: 45 },
{ id: 'orange', label: 'Orange', hue: 22 },
{ id: 'rose', label: 'Rose', hue: 345 },
{ id: 'berry', label: 'Berry', hue: 320 },
{ id: 'violet', label: 'Violet', hue: 265 },
// Two flat neutrals, no gradient — for anyone who finds the color
// themes above too much. Same values the shell originally shipped with
// before theme presets existed (zinc dark / zinc light).
{
id: 'mono',
label: 'Black & Grey',
palette: {
'--aurora-app-bg': '#09090b',
'--aurora-app-bg-solid': '#09090b',
'--aurora-bg': '#18181b',
'--aurora-card-bg': '#1f1f23',
'--aurora-card-bg-deep': '#131316',
'--aurora-bg-subtle': '#27272a',
'--aurora-text': '#f4f4f5',
'--aurora-text-muted': '#a1a1aa',
'--aurora-border': '#27272a',
'--aurora-card-border': '#2e2e33',
'--aurora-frame-bg': '#1c1c20',
'--aurora-frame-border': '#34343b',
'--aurora-accent': 'rgb(125 158 255)',
'--aurora-accent-soft': 'rgb(125 158 255 / 0.14)',
},
},
{
id: 'white',
label: 'White',
palette: {
'--aurora-app-bg': '#fafafa',
'--aurora-app-bg-solid': '#fafafa',
'--aurora-bg': '#ffffff',
'--aurora-card-bg': '#fafafa',
'--aurora-card-bg-deep': '#f0f0f2',
'--aurora-bg-subtle': '#f4f4f5',
'--aurora-text': '#18181b',
'--aurora-text-muted': '#71717a',
'--aurora-border': '#e4e4e7',
'--aurora-card-border': '#ececef',
'--aurora-frame-bg': '#ffffff',
'--aurora-frame-border': '#e0e0e3',
'--aurora-accent': 'rgb(71 114 250)',
'--aurora-accent-soft': 'rgb(71 114 250 / 0.1)',
},
},
];
const hsl = (h, s, l, a) =>
a === undefined ? `hsl(${h} ${s}% ${l}%)` : `hsl(${h} ${s}% ${l}% / ${a})`;
/** The full set of --aurora-* color tokens for a preset. */
export function buildPalette(preset) {
if (preset.palette) return preset.palette;
const { hue } = preset;
// Two vivid, bright stops of the same hue family carry most of the bar
// (0% → 55%); the near-black anchor only takes over in the final
// stretch (100%). That's the "mostly color, dark tail" look the
// FleekDash palette bars have — full saturation start to middle keeps
// it feeling poppy instead of muddy.
const hue2 = (hue + 35) % 360;
const solidAppBg = hsl(hue, 45, 16);
return {
'--aurora-app-bg': `linear-gradient(135deg, ${hsl(hue, 78, 48)} 0%, ${hsl(hue2, 78, 45)} 55%, ${hsl(hue, 32, 9)} 100%)`,
'--aurora-app-bg-solid': solidAppBg,
'--aurora-bg': hsl(hue, 40, 20),
'--aurora-card-bg': hsl(hue, 36, 24),
'--aurora-card-bg-deep': hsl(hue, 38, 14),
'--aurora-bg-subtle': hsl(hue, 32, 28),
'--aurora-text': hsl(hue, 20, 97),
'--aurora-text-muted': hsl(hue, 14, 74),
'--aurora-border': hsl(hue, 28, 30),
'--aurora-card-border': hsl(hue, 26, 33),
'--aurora-frame-bg': hsl(hue, 34, 22),
'--aurora-frame-border': hsl(hue, 26, 33),
'--aurora-accent': hsl(hue, 85, 62),
'--aurora-accent-soft': hsl(hue, 85, 62, 0.16),
};
}
/** Resolve a theme_preset id to the preset object (falls back to the first preset). */
export function resolvePreset(themePresetId) {
return themePresets.find((p) => p.id === themePresetId) || themePresets[0];
}
+93 -71
View File
@@ -1,7 +1,10 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { ref, onMounted } from 'vue';
import Sidebar from './components/Sidebar.vue';
import Toolbar from './components/Toolbar.vue';
import { applyThemePalette, applyFontFamily } from '@/pages/settings/applyLiveTheme.js';
import { initWindowChrome } from './windowChrome.js';
import { getMinimizedWindow, clearMinimizedWindow } from './taskbar.js';
const props = defineProps({
serverData: {
@@ -10,61 +13,6 @@ const props = defineProps({
},
});
// Three-state theme: 'light' | 'dark' | 'system'. Older stored values were
// just 'on'/'off' — map those forward so existing installs keep working.
const normalizeStoredTheme = (value) => {
if (value === 'on') return 'dark';
if (value === 'off') return 'light';
if (value === 'light' || value === 'dark' || value === 'system') return value;
return 'system';
};
const theme = ref(normalizeStoredTheme(props.serverData?.settings?.dark_mode));
const systemPrefersDark = ref(
window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
);
let mediaQuery = null;
const onSystemChange = (e) => (systemPrefersDark.value = e.matches);
const isDark = computed(() =>
theme.value === 'dark' || (theme.value === 'system' && systemPrefersDark.value)
);
const applyTheme = () => {
document.documentElement.classList.toggle('aurora-dark', isDark.value);
};
const persistTheme = async () => {
try {
await fetch(`${props.serverData.restUrl}wp/v2/settings`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': props.serverData.restNonce,
},
// Partial payload: the option's sanitize callback merges this into
// the stored settings, so we never write back the (secret-stripped)
// settings copy this page received.
body: JSON.stringify({
aurora_admin_settings: {
dark_mode: theme.value,
},
}),
});
} catch {
// Non-critical: theme still applied for this session.
}
};
const setTheme = (next) => {
theme.value = next;
applyTheme();
persistTheme();
};
watch(isDark, applyTheme);
// Minified (icon-only) menu — a per-browser preference kept in
// localStorage; the html class drives all layout via --aurora-sidebar-w.
const collapsed = ref(localStorage.getItem('aurora-menu-collapsed') === '1');
@@ -77,24 +25,52 @@ const toggleMenu = () => {
localStorage.setItem('aurora-menu-collapsed', collapsed.value ? '1' : '0');
};
// The block editor (Gutenberg) renders its own toolbar (undo/redo,
// title, save/publish) directly under Aurora's fixed toolbar — stacking
// both left a sliver of page gradient visible as a seam between them.
// Skip Aurora's toolbar there and let the editor's own bar sit flush
// against the top; shell-frame.css reads the aurora-no-toolbar class to
// also close up the top inset it reserves for the (now-absent) toolbar.
const isBlockEditor = document.body.classList.contains('block-editor-page');
// The one minimized window, if any (taskbar.js persists it in
// sessionStorage across the page load minimizing triggers). Rendered as a
// taskbar item at the bottom of every page since SiteShell mounts on all
// of them — clicking it navigates back and clears the entry.
const minimizedWindow = ref(null);
const restoreWindow = () => {
if (!minimizedWindow.value) return;
const url = minimizedWindow.value.url;
clearMinimizedWindow();
window.location.href = url;
};
onMounted(() => {
applyTheme();
minimizedWindow.value = getMinimizedWindow();
// Reserve bottom space for the taskbar (shell-frame.css) so page content
// stops above it instead of running underneath.
document.documentElement.classList.toggle('aurora-has-taskbar', !!minimizedWindow.value);
const settings = props.serverData?.settings || {};
// Every theme preset is its own self-contained dark-toned palette now —
// no separate light/dark mode, so this class is always on. It's kept
// (rather than renamed) because shell-frame.css and native-dark.css both
// still gate their rules on it.
document.documentElement.classList.add('aurora-dark');
document.documentElement.classList.toggle('aurora-no-toolbar', isBlockEditor);
applyThemePalette(settings.theme_preset || 'indigo');
applyCollapsed();
if (window.matchMedia) {
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', onSystemChange);
applyFontFamily(settings.font_family);
// Skipped on the block editor — #wpbody-content is bypassed by the
// editor's own fixed skeleton there (see isBlockEditor above), so a
// bar injected into it would just be invisible clutter.
if (!isBlockEditor) {
initWindowChrome(props.serverData?.adminUrl);
}
// Signal the shell is live: reveals the framed content and commits to
// keeping native chrome hidden (the PHP failsafe stops watching once
// aurora-ready is present).
document.documentElement.classList.add('aurora-ready');
});
onUnmounted(() => {
if (mediaQuery) {
mediaQuery.removeEventListener('change', onSystemChange);
}
});
</script>
<template>
@@ -105,23 +81,69 @@ onUnmounted(() => {
:current-url="serverData.currentUrl"
:logo="serverData.settings?.logo || ''"
:dark-logo="serverData.settings?.dark_logo || ''"
:is-dark="isDark"
:collapsed="collapsed"
:server-data="serverData"
:theme="theme"
@set-theme="setTheme"
/>
<Toolbar
v-if="!isBlockEditor"
:server-data="serverData"
@toggle-menu="toggleMenu"
/>
<div v-if="minimizedWindow" class="aurora-taskbar">
<button type="button" class="aurora-taskbar__item" @click="restoreWindow">
<span class="aurora-taskbar__dot" />
{{ minimizedWindow.title }}
</button>
</div>
</div>
</template>
<style scoped>
/* Sidebar and Toolbar are both position:fixed, so this wrapper has no
layout role — it just groups them under one mounted root. */
/* Sidebar, Toolbar and the taskbar are all position:fixed, so this
wrapper has no layout role — it just groups them under one mounted
root. */
.site-shell {
display: contents;
}
/* Full-width docked bar (real taskbar, edge to edge) — page content
reserves space above it via html.aurora-has-taskbar in shell-frame.css
so nothing renders underneath. The item itself stays pill-shaped. */
.aurora-taskbar {
position: fixed;
left: calc(var(--aurora-sidebar-w) + var(--aurora-sidebar-gap) * 2);
right: 0;
bottom: 0;
height: var(--aurora-taskbar-h);
display: flex;
align-items: center;
padding: 0 16px;
background: var(--aurora-frame-bg);
border-top: 1px solid var(--aurora-frame-border);
box-sizing: border-box;
z-index: 200001;
transition: left 0.2s ease;
}
.aurora-taskbar__item {
display: flex;
align-items: center;
gap: 8px;
background: var(--aurora-card-bg);
border: 1px solid var(--aurora-card-border);
border-radius: 999px;
padding: 8px 18px 8px 10px;
color: var(--aurora-text);
font-size: 0.8125rem;
cursor: pointer;
}
.aurora-taskbar__item:hover {
border-color: var(--aurora-accent);
}
.aurora-taskbar__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #febc2e;
flex-shrink: 0;
}
</style>
+16 -21
View File
@@ -19,10 +19,6 @@ const props = defineProps({
type: String,
default: '',
},
isDark: {
type: Boolean,
default: false,
},
collapsed: {
type: Boolean,
default: false,
@@ -35,19 +31,13 @@ const props = defineProps({
type: Object,
required: true,
},
theme: {
type: String,
default: 'system',
},
});
const emit = defineEmits(['set-theme']);
// Custom logo from Settings > General. Dark mode prefers the dark variant
// and falls back to the standard logo; no logo set = "Aurora" wordmark.
const activeLogo = computed(() =>
props.isDark ? props.darkLogo || props.logo : props.logo
);
// Custom logo from Settings > General. Every theme preset is a dark-toned
// palette now (no separate light mode), so the dark-variant logo is always
// preferred when set; falls back to the standard logo, or the "Aurora"
// wordmark if neither is set.
const activeLogo = computed(() => props.darkLogo || props.logo);
// Normalize any admin URL down to the part after /wp-admin/ so we can
// compare the current page against menu targets regardless of host/scheme.
@@ -209,8 +199,6 @@ const isAuroraActive = (item) => normalize(item.url) === current.value;
<UserMenu
:server-data="serverData"
:collapsed="collapsed"
:theme="theme"
@set-theme="(t) => emit('set-theme', t)"
/>
</nav>
</template>
@@ -218,11 +206,18 @@ const isAuroraActive = (item) => normalize(item.url) === current.value;
<style scoped>
.sidebar {
position: fixed;
top: 0;
left: 0;
top: var(--aurora-sidebar-gap);
left: var(--aurora-sidebar-gap);
bottom: var(--aurora-sidebar-gap);
width: var(--aurora-sidebar-w);
height: 100vh;
background: var(--aurora-app-bg);
height: auto;
/* Same surface treatment as the dashboard cards (AuroraCard.vue) —
solid card background + rounded corners, floating over the page
gradient with a gap on every side now that it's inset instead of
flush against the viewport edges. */
background: var(--aurora-card-bg);
border: 1px solid var(--aurora-card-border);
border-radius: 20px;
transition: width 0.2s ease;
z-index: 99999;
box-sizing: border-box;
+3 -2
View File
@@ -129,10 +129,11 @@ onUnmounted(() => document.removeEventListener('keydown', onKeydown));
.toolbar {
position: fixed;
top: 0;
left: var(--aurora-sidebar-w);
left: calc(var(--aurora-sidebar-w) + var(--aurora-sidebar-gap) * 2);
right: 0;
height: var(--aurora-toolbar-h);
background: var(--aurora-app-bg);
/* No background — see Sidebar.vue; the fixed html/body gradient shows
through so the whole shell reads as one continuous background. */
display: flex;
align-items: center;
justify-content: space-between;
-88
View File
@@ -4,11 +4,8 @@ import { ref, computed, onMounted, onUnmounted } from 'vue';
const props = defineProps({
serverData: { type: Object, required: true },
collapsed: { type: Boolean, default: false },
theme: { type: String, default: 'system' }, // 'light' | 'dark' | 'system'
});
const emit = defineEmits(['set-theme']);
const open = ref(false);
const root = ref(null);
@@ -53,36 +50,6 @@ onUnmounted(() => document.removeEventListener('click', onDocClick));
Sign Out
</a>
</div>
<div class="user-menu__theme">
<span class="user-menu__theme-label">Theme</span>
<div class="user-menu__segmented">
<button
type="button"
:class="{ 'is-active': theme === 'light' }"
title="Light"
@click="emit('set-theme', 'light')"
>
<span class="user-menu__swatch user-menu__swatch--light" />
</button>
<button
type="button"
:class="{ 'is-active': theme === 'dark' }"
title="Dark"
@click="emit('set-theme', 'dark')"
>
<span class="user-menu__swatch user-menu__swatch--dark" />
</button>
<button
type="button"
:class="{ 'is-active': theme === 'system' }"
title="Match system"
@click="emit('set-theme', 'system')"
>
<span class="user-menu__swatch user-menu__swatch--system" />
</button>
</div>
</div>
</div>
<button
@@ -224,59 +191,4 @@ onUnmounted(() => document.removeEventListener('click', onDocClick));
color: #ef4444;
}
.user-menu__theme {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
}
.user-menu__theme-label {
font-size: 0.8125rem;
color: var(--aurora-text-muted);
}
.user-menu__segmented {
display: flex;
gap: 2px;
background: var(--aurora-bg-subtle);
border-radius: 8px;
padding: 2px;
}
.user-menu__segmented button {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
background: none;
border-radius: 6px;
cursor: pointer;
padding: 3px;
}
.user-menu__segmented button.is-active {
background: var(--aurora-bg);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
}
.user-menu__swatch {
display: block;
width: 100%;
height: 100%;
border-radius: 4px;
border: 1px solid rgba(127, 127, 127, 0.35);
box-sizing: border-box;
}
.user-menu__swatch--light {
background: #ffffff;
}
.user-menu__swatch--dark {
background: #18181b;
}
.user-menu__swatch--system {
background: linear-gradient(135deg, #ffffff 0%, #ffffff 50%, #18181b 50%, #18181b 100%);
}
.user-menu__segmented button.is-active .user-menu__swatch {
border-color: var(--aurora-accent);
box-shadow: 0 0 0 1px var(--aurora-accent);
}
</style>
+149 -3
View File
@@ -14,6 +14,13 @@
:root {
--aurora-sidebar-w: 264px;
--aurora-toolbar-h: 56px;
/* SiteShell.vue's taskbar, shown only while a window is minimized
(html.aurora-has-taskbar toggles the bottom space reserved for it). */
--aurora-taskbar-h: 56px;
/* Space between the sidebar card and the browser's left/top/bottom
edges (Sidebar.vue) — the toolbar and #wpcontent offsets below add
the same amount so nothing overlaps the floating sidebar. */
--aurora-sidebar-gap: 16px;
/* Breathing room between page content and the right browser edge. */
--aurora-gutter-r: 44px;
@@ -22,8 +29,15 @@
/* Light theme */
--aurora-app-bg: #fafafa; /* zinc-50 */
/* Flat fallback for the handful of consumers that need a solid color
(--aurora-app-bg itself may be a gradient once a theme preset applies). */
--aurora-app-bg-solid: #fafafa;
--aurora-bg: #ffffff; /* frame */
--aurora-card-bg: #fafafa; /* zinc-50 */
/* Dashboard cards specifically (AuroraCard.vue) — a step further from
--aurora-frame-bg than --aurora-card-bg so they read as distinct
surfaces against the Dashboard's own frame, not a near-identical tone. */
--aurora-card-bg-deep: #f0f0f2;
--aurora-bg-subtle: #f4f4f5; /* zinc-100 */
--aurora-text: #18181b; /* zinc-900 */
--aurora-text-muted: #71717a; /* zinc-500 */
@@ -34,6 +48,10 @@
--aurora-accent: rgb(71 114 250);
--aurora-accent-soft: rgb(71 114 250 / 0.1);
--aurora-accent-text: #ffffff;
/* Overridden by the font-picker setting (see SiteShell.vue::applyFontFamily);
this is the fallback when no Google Font has been chosen. */
--aurora-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
/* Minified menu: the toolbar's collapse button toggles this class; every
@@ -46,8 +64,10 @@ html.aurora-collapsed {
html.aurora-dark {
/* Dark theme — the immersive near-black look of the original */
--aurora-app-bg: #09090b; /* zinc-950 */
--aurora-app-bg-solid: #09090b;
--aurora-bg: #18181b; /* zinc-900 frame */
--aurora-card-bg: #1f1f23; /* slightly elevated */
--aurora-card-bg-deep: #131316;
--aurora-bg-subtle: #27272a; /* zinc-800 */
--aurora-text: #f4f4f5; /* zinc-100 */
--aurora-text-muted: #a1a1aa; /* zinc-400 */
@@ -64,6 +84,14 @@ html.aurora-dark {
html.aurora-ready,
html.aurora-ready body {
background: var(--aurora-app-bg);
/* Fixed so the gradient stays pinned to the viewport instead of
scrolling with document height — the sidebar/toolbar are also
position:fixed and paint no background of their own (see
Sidebar.vue/Toolbar.vue), so this one gradient plane has to line up
with them at every scroll position to read as a single unified
background instead of a seam between "sidebar" and "page". */
background-attachment: fixed;
font-family: var(--aurora-font-family);
}
/* !important here defeats WordPress's own responsive #wpcontent margin
@@ -73,12 +101,31 @@ html.aurora-ready body {
rather than on #wpbody, so content rendered above #wpbody — like the
takeover dashboard — also clears the fixed toolbar. */
html.aurora-ready #wpcontent {
margin-left: var(--aurora-sidebar-w) !important;
margin-left: calc(var(--aurora-sidebar-w) + var(--aurora-sidebar-gap) * 2) !important;
padding-left: 0 !important;
padding-top: var(--aurora-toolbar-h);
transition: margin-left 0.2s ease;
}
/* The block editor (Gutenberg) doesn't live inside #wpcontent's normal
box model — it renders its own skeleton as position:fixed, inset:0,
completely independent of the margin-left above. Left unchecked it
spans the full viewport and renders underneath the (higher z-index)
sidebar and toolbar. Inset it the same amount #wpcontent gets. */
html.aurora-ready .interface-interface-skeleton {
left: calc(var(--aurora-sidebar-w) + var(--aurora-sidebar-gap) * 2) !important;
top: var(--aurora-toolbar-h) !important;
transition: left 0.2s ease;
}
/* Aurora's own toolbar is skipped on the block editor (SiteShell.vue) —
its own toolbar sits flush against the top instead, so no toolbar
height needs to be reserved here; just the same top gap the sidebar
gets, so the two line up. */
html.aurora-ready.aurora-no-toolbar .interface-interface-skeleton {
top: var(--aurora-sidebar-gap) !important;
}
html.aurora-ready #wpbody-content {
background: var(--aurora-frame-bg);
color: var(--aurora-text);
@@ -89,12 +136,111 @@ html.aurora-ready #wpbody-content {
padding: 32px;
min-height: calc(100vh - var(--aurora-toolbar-h) - 40px);
box-sizing: border-box;
/* WP core sets #wpbody-content { float: left; width: 100% }, which (with
box-sizing left alone) ignores the margin above and overflows past the
right edge by margin-left + margin-right. Force it back to shrink-to-fit
so the margin is actually respected. */
float: none;
width: auto;
}
/* SiteShell.vue's taskbar docks along the bottom edge only while a window
is minimized — push content up by its height so pages stop above it
instead of rendering underneath. */
html.aurora-has-taskbar #wpbody-content {
margin-bottom: calc(24px + var(--aurora-taskbar-h));
}
html.aurora-has-taskbar .aurora-dashboard-frame {
margin-bottom: calc(24px + var(--aurora-taskbar-h));
}
/* macOS-style title bar (windowChrome.js). Most pages get it injected as
the first child of #wpbody-content — negative margins cancel the 32px
padding above so it spans edge-to-edge like a real window title bar. */
.aurora-winbar {
display: flex;
align-items: center;
gap: 8px;
}
#wpbody-content > .aurora-winbar {
margin: -32px -32px 20px -32px;
padding: 14px 16px;
border-bottom: 1px solid var(--aurora-frame-border);
border-top-left-radius: var(--aurora-radius-lg);
border-top-right-radius: var(--aurora-radius-lg);
}
.aurora-winbar__dot {
width: 12px;
height: 12px;
border-radius: 50%;
border: none;
padding: 0;
cursor: pointer;
}
.aurora-winbar__dot:hover {
filter: brightness(1.15);
}
.aurora-winbar__dot--close {
background: #ff5f57;
}
.aurora-winbar__dot--minimize {
background: #febc2e;
}
.aurora-winbar__dot--maximize {
background: #28c840;
}
/* The Dashboard bypasses #wpbody-content (DashboardTakeover.php renders
it into a separate sibling — see windowChrome.js), so it never got the
card frame every other page has. windowChrome.js wraps the bar + the
dashboard's mount point in this div to give it the same treatment. */
.aurora-dashboard-frame {
background: var(--aurora-frame-bg);
border: 1px solid var(--aurora-frame-border);
border-radius: var(--aurora-radius-lg);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
margin: 16px var(--aurora-gutter-r) 24px 24px;
box-sizing: border-box;
}
.aurora-dashboard-frame > .aurora-winbar {
padding: 14px 20px;
border-bottom: 1px solid var(--aurora-frame-border);
border-top-left-radius: var(--aurora-radius-lg);
border-top-right-radius: var(--aurora-radius-lg);
}
/* Yellow dot: on the Dashboard itself there's nowhere further "home" to
send it, so minimize just collapses the frame down to the title bar in
place — everywhere else, minimize navigates to the dashboard instead
(see windowChrome.js) and SiteShell.vue's taskbar offers a way back. */
html.aurora-minimized #aurora-admin-dashboard-root {
display: none !important;
}
/* Green dot: hide Aurora's own chrome (sidebar/toolbar) and let the
content reclaim the full browser window, like restoring a real OS
window to fullscreen. */
html.aurora-maximized .sidebar,
html.aurora-maximized .toolbar {
display: none !important;
}
html.aurora-ready.aurora-maximized #wpcontent {
margin-left: 0 !important;
}
html.aurora-ready.aurora-maximized .interface-interface-skeleton {
left: 0 !important;
}
html.aurora-maximized .aurora-taskbar {
left: 0 !important;
}
/* The settings page renders its own two boxed panels, so drop the outer
content frame there — leaving just those panels on the dark backdrop. */
content frame there — leaving just those panels on the dark backdrop.
No background here: #wpbody-content is inset from the sidebar/gutter,
not the full viewport, so painting var(--aurora-app-bg) on it computes
its own independent (and visibly mismatched) gradient instance instead
of sharing the one fixed gradient plane painted on html/body. */
html.aurora-ready body.toplevel_page_aurora-admin #wpbody-content {
background: var(--aurora-app-bg);
border: none;
border-radius: 0;
box-shadow: none;
+39
View File
@@ -0,0 +1,39 @@
/**
* Tracks the one "minimized" window across page loads. Aurora is a normal
* multi-page WP admin (each nav is a full page load), so a real taskbar
* needs to survive navigation — sessionStorage (tab-scoped, clears when the
* tab closes) is enough for that. Only one item is tracked at a time: this
* is "minimize sends you home with a way back", not a full window manager.
*/
const STORAGE_KEY = 'aurora-minimized-window';
export function getMinimizedWindow() {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
export function setMinimizedWindow(title, url) {
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ title, url }));
} catch {
/* sessionStorage unavailable (e.g. private mode) — minimize just won't persist */
}
}
export function clearMinimizedWindow() {
try {
sessionStorage.removeItem(STORAGE_KEY);
} catch {
/* noop */
}
}
/** Best-effort readable label for the current page, used as the taskbar item's title. */
export function currentPageTitle() {
const raw = document.title || 'Window';
return raw.split('‹')[0].trim() || 'Window';
}
+81
View File
@@ -0,0 +1,81 @@
/**
* Adds a macOS-style traffic-light title bar to the top of the page content.
* Most pages render inside #wpbody-content (native WP pages and all of
* Aurora's own Vue pages, since each mounts its root div there). The
* Dashboard is the one exception — DashboardTakeover.php renders it into a
* separate #aurora-admin-dashboard-root sibling, above #wpbody (which it
* hides), to fully replace the native dashboard, so it gets wrapped in its
* own frame to match the card treatment #wpbody-content already has. The
* block editor bypasses both (see SiteShell.vue isBlockEditor), so it's
* skipped there.
*
* Red closes (navigates back to the Aurora dashboard). Yellow minimizes —
* on any page other than the dashboard, that means "save where I am and
* send me home", with SiteShell.vue's taskbar offering a way back; on the
* dashboard itself (already home) it just collapses in place instead,
* since there's nowhere further back to send it. Green hides Aurora's own
* sidebar/toolbar so the content fills the browser window. All of this is
* just classes on <html> plus one sessionStorage entry (taskbar.js) — no
* server-side state.
*/
import { setMinimizedWindow, currentPageTitle } from './taskbar.js';
export function initWindowChrome(adminUrl) {
if (document.querySelector('.aurora-winbar')) {
return;
}
const bar = document.createElement('div');
bar.className = 'aurora-winbar';
const makeDot = (variant, label, onClick) => {
const dot = document.createElement('button');
dot.type = 'button';
dot.className = 'aurora-winbar__dot aurora-winbar__dot--' + variant;
dot.setAttribute('aria-label', label);
dot.title = label;
dot.addEventListener('click', onClick);
return dot;
};
const root = document.documentElement;
const dashboardRoot = document.getElementById('aurora-admin-dashboard-root');
const isDashboard = !!dashboardRoot;
bar.appendChild(
makeDot('close', 'Close', () => {
window.location.href = adminUrl || '/wp-admin/';
})
);
bar.appendChild(
makeDot('minimize', 'Minimize', () => {
if (isDashboard) {
root.classList.toggle('aurora-minimized');
root.classList.remove('aurora-maximized');
} else {
setMinimizedWindow(currentPageTitle(), window.location.href);
window.location.href = adminUrl || '/wp-admin/';
}
})
);
bar.appendChild(
makeDot('maximize', 'Maximize', () => {
root.classList.toggle('aurora-maximized');
root.classList.remove('aurora-minimized');
})
);
if (dashboardRoot) {
const frame = document.createElement('div');
frame.className = 'aurora-dashboard-frame';
dashboardRoot.parentNode.insertBefore(frame, dashboardRoot);
frame.appendChild(bar);
frame.appendChild(dashboardRoot);
return;
}
const wpbodyContent = document.getElementById('wpbody-content');
if (wpbodyContent) {
wpbodyContent.insertBefore(bar, wpbodyContent.firstChild);
}
}
+1
View File
@@ -28,6 +28,7 @@ export default defineConfig({
'role-editor': fileURLToPath(new URL('./src/entries/role-editor.js', import.meta.url)),
'activity-log': fileURLToPath(new URL('./src/entries/activity-log.js', import.meta.url)),
'database-explorer': fileURLToPath(new URL('./src/entries/database-explorer.js', import.meta.url)),
plugins: fileURLToPath(new URL('./src/entries/plugins.js', import.meta.url)),
},
output: {
entryFileNames: '[name].[hash].js',
+1
View File
@@ -28,6 +28,7 @@ require AURORA_ADMIN_PATH . "admin/src/Pages/SettingsPage.php";
require AURORA_ADMIN_PATH . "admin/src/Rest/DashboardData.php";
require AURORA_ADMIN_PATH . "admin/src/Analytics/Analytics.php";
require AURORA_ADMIN_PATH . "admin/src/Rest/AnalyticsData.php";
require AURORA_ADMIN_PATH . "admin/src/Rest/PluginsData.php";
require AURORA_ADMIN_PATH . "admin/src/Posts/ScheduledDayFilter.php";
require AURORA_ADMIN_PATH . "admin/src/WhiteLabel/WhiteLabel.php";
require AURORA_ADMIN_PATH . "admin/src/Pages/SubApps.php";