Add File Manager sub-app (Pro feature) (v2.7.0)
New browse/edit interface for the WordPress install's own files, built using the installed "WP File Manager" plugin (elFinder-based) as a functional reference rather than embedding it. Own REST controller (FileManagerData.php) + Vue page, same pattern as every other sub-app. Security is the whole point here (highest-risk surface in the plugin — arbitrary filesystem read/write): - Root is ABSPATH, manage_options-gated throughout. - Every client path is resolved via realpath() and checked for root containment against the *resolved* path, not string-matched against the raw input — catches symlink tricks and encoded traversal that a naive "../" check would miss. - Uploads/new files/renames reject a blocklist of server-executable extensions (.php, .cgi, .sh, etc.) — the single most important control, since uploading a webshell is the classic exploit path for this class of feature. - Deliberately not built: zip/unzip (zip-slip vulnerability class), chmod/permissions editing, cross-directory move/copy. Gated as a paid feature (riskier than Database Explorer, which is already paid) — a judgment call flagged to the user, not something they specified. Also fixed a real bug found while wiring this up: SubApps.php's page-level lock doesn't stop someone with manage_options from calling the REST endpoints directly, bypassing the license check entirely. File Manager's REST permission callbacks now also check License::is_feature_available(). The same gap exists in Database Explorer, Role Editor, and Activity Log's REST controllers and is flagged as a follow-up task, not fixed here (out of scope for what was asked, affects three unrelated features). Verified live via direct REST calls: path traversal blocked (404), blocked extensions rejected (400), root deletion blocked (400), and a full create/write/read/rename/delete round trip succeeded cleanly. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
204c4365e2
commit
15d6060ae3
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aurora-admin-app",
|
||||
"version": "2.6.0",
|
||||
"version": "2.7.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue';
|
||||
import '@/shell/shell-frame.css';
|
||||
import FileManager from '@/pages/file-manager/FileManager.vue';
|
||||
|
||||
const mountEl = document.getElementById('aurora-admin-file-manager-root');
|
||||
|
||||
if (mountEl) {
|
||||
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
|
||||
createApp(FileManager, { serverData: data }).mount(mountEl);
|
||||
}
|
||||
@@ -0,0 +1,649 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
serverData: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const path = ref('');
|
||||
const breadcrumb = ref([]);
|
||||
const items = ref([]);
|
||||
const loading = ref(true);
|
||||
const busy = ref(false);
|
||||
const error = ref('');
|
||||
const notice = ref('');
|
||||
const search = ref('');
|
||||
const fileInput = ref(null);
|
||||
|
||||
// Text editor state — a single file open at a time.
|
||||
const editing = ref(null); // { path, name, content, isWritable }
|
||||
const editorContent = ref('');
|
||||
const savingEdit = ref(false);
|
||||
|
||||
const apiBase = computed(() => `${props.serverData.restUrl}aurora-admin/v1`);
|
||||
|
||||
const request = async (path, options = {}) => {
|
||||
const response = await fetch(`${apiBase.value}${path}`, {
|
||||
credentials: 'same-origin',
|
||||
...options,
|
||||
headers: {
|
||||
'X-WP-Nonce': props.serverData.restNonce,
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || data.error || 'Request failed');
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
const load = async (targetPath = path.value) => {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const params = new URLSearchParams({ path: targetPath });
|
||||
const data = await request(`/file-manager/list?${params.toString()}`);
|
||||
path.value = data.path || '';
|
||||
breadcrumb.value = data.breadcrumb || [];
|
||||
items.value = data.items || [];
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Could not load this folder';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => load(''));
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
const q = search.value.trim().toLowerCase();
|
||||
if (!q) return items.value;
|
||||
return items.value.filter((item) => item.name.toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
const openFolder = (item) => {
|
||||
if (item.type !== 'dir') return;
|
||||
load(item.path);
|
||||
};
|
||||
|
||||
const goToCrumb = (crumbPath) => load(crumbPath);
|
||||
const goToRoot = () => load('');
|
||||
|
||||
const newFolder = async () => {
|
||||
const name = window.prompt('New folder name:');
|
||||
if (!name) return;
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
try {
|
||||
const data = await request('/file-manager/folder', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: path.value, name }),
|
||||
});
|
||||
notice.value = data.message || 'Folder created.';
|
||||
await load();
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Could not create folder';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const newFile = async () => {
|
||||
const name = window.prompt('New file name:');
|
||||
if (!name) return;
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
try {
|
||||
const data = await request('/file-manager/file', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: path.value, name }),
|
||||
});
|
||||
notice.value = data.message || 'File created.';
|
||||
await load();
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Could not create file';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const triggerUpload = () => fileInput.value?.click();
|
||||
|
||||
const onFileSelected = async (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
if (!file) return;
|
||||
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('path', path.value);
|
||||
formData.append('file', file);
|
||||
const data = await request('/file-manager/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
notice.value = data.message || 'File uploaded.';
|
||||
await load();
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Upload failed';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const renameItem = async (item) => {
|
||||
const newName = window.prompt('Rename to:', item.name);
|
||||
if (!newName || newName === item.name) return;
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
try {
|
||||
const data = await request('/file-manager/rename', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: item.path, newName }),
|
||||
});
|
||||
notice.value = data.message || 'Renamed.';
|
||||
await load();
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Rename failed';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteItem = async (item) => {
|
||||
const warning =
|
||||
item.type === 'dir'
|
||||
? `Permanently delete the folder "${item.name}" and everything inside it? This cannot be undone.`
|
||||
: `Permanently delete "${item.name}"? This cannot be undone.`;
|
||||
if (!window.confirm(warning)) return;
|
||||
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
try {
|
||||
const data = await request('/file-manager/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: item.path }),
|
||||
});
|
||||
notice.value = data.message || 'Deleted.';
|
||||
await load();
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Delete failed';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const downloadUrl = (item) => {
|
||||
const params = new URLSearchParams({
|
||||
path: item.path,
|
||||
_wpnonce: props.serverData.restNonce,
|
||||
});
|
||||
return `${apiBase.value}/file-manager/download?${params.toString()}`;
|
||||
};
|
||||
|
||||
const openEditor = async (item) => {
|
||||
error.value = '';
|
||||
try {
|
||||
const params = new URLSearchParams({ path: item.path });
|
||||
const data = await request(`/file-manager/read?${params.toString()}`);
|
||||
editing.value = { path: item.path, name: item.name, isWritable: data.isWritable };
|
||||
editorContent.value = data.content;
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Could not open this file';
|
||||
}
|
||||
};
|
||||
|
||||
const closeEditor = () => {
|
||||
editing.value = null;
|
||||
editorContent.value = '';
|
||||
};
|
||||
|
||||
const saveEditor = async () => {
|
||||
if (!editing.value) return;
|
||||
savingEdit.value = true;
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
try {
|
||||
const data = await request('/file-manager/write', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: editing.value.path, content: editorContent.value }),
|
||||
});
|
||||
notice.value = data.message || 'Saved.';
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Save failed';
|
||||
} finally {
|
||||
savingEdit.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const icon = (item) => {
|
||||
if (item.type === 'dir') return 'dashicons-portfolio';
|
||||
const ext = item.extension;
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'ico', 'bmp'].includes(ext)) return 'dashicons-format-image';
|
||||
if (['zip', 'gz', 'tar', 'rar', '7z'].includes(ext)) return 'dashicons-media-archive';
|
||||
if (['mp3', 'wav', 'ogg'].includes(ext)) return 'dashicons-format-audio';
|
||||
if (['mp4', 'mov', 'avi', 'webm'].includes(ext)) return 'dashicons-format-video';
|
||||
if (['php', 'js', 'css', 'json', 'html', 'htm', 'xml', 'sql', 'yml', 'yaml'].includes(ext)) return 'dashicons-media-code';
|
||||
return 'dashicons-media-default';
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="fm-page">
|
||||
<header class="fm-page__header">
|
||||
<div>
|
||||
<p class="fm-page__eyebrow">Tools</p>
|
||||
<h1>File Manager</h1>
|
||||
</div>
|
||||
<div class="fm-page__actions">
|
||||
<button type="button" class="icon-button" title="New folder" :disabled="busy" @click="newFolder">
|
||||
<span class="dashicons dashicons-plus-alt2" /> Folder
|
||||
</button>
|
||||
<button type="button" class="icon-button" title="New file" :disabled="busy" @click="newFile">
|
||||
<span class="dashicons dashicons-plus-alt2" /> File
|
||||
</button>
|
||||
<button type="button" class="icon-button" title="Upload file" :disabled="busy" @click="triggerUpload">
|
||||
<span class="dashicons dashicons-upload" /> Upload
|
||||
</button>
|
||||
<input ref="fileInput" type="file" hidden @change="onFileSelected" />
|
||||
<button type="button" class="icon-button" title="Refresh" :disabled="loading" @click="load()">
|
||||
<span class="dashicons dashicons-update" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="fm-breadcrumb" aria-label="Folder path">
|
||||
<button type="button" class="fm-breadcrumb__item" @click="goToRoot">WordPress root</button>
|
||||
<template v-for="crumb in breadcrumb" :key="crumb.path">
|
||||
<span class="fm-breadcrumb__sep">/</span>
|
||||
<button type="button" class="fm-breadcrumb__item" @click="goToCrumb(crumb.path)">{{ crumb.label }}</button>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<section class="fm-toolbar">
|
||||
<label class="fm-search">
|
||||
<span class="dashicons dashicons-search" />
|
||||
<input v-model="search" type="search" placeholder="Filter this folder..." />
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<div v-if="notice" class="fm-notice fm-notice--success">{{ notice }}</div>
|
||||
<div v-if="error" class="fm-notice fm-notice--error">{{ error }}</div>
|
||||
|
||||
<section class="fm-table" aria-label="Files and folders">
|
||||
<div class="fm-table__head">
|
||||
<div>Name</div>
|
||||
<div>Size</div>
|
||||
<div>Modified</div>
|
||||
<div>Actions</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="fm-table__empty">Loading...</div>
|
||||
<div v-else-if="!filteredItems.length" class="fm-table__empty">This folder is empty.</div>
|
||||
|
||||
<article v-for="item in filteredItems" v-else :key="item.name" class="fm-row">
|
||||
<div
|
||||
class="fm-row__name"
|
||||
:class="{ 'fm-row__name--clickable': item.type === 'dir' }"
|
||||
@click="openFolder(item)"
|
||||
>
|
||||
<span class="dashicons" :class="icon(item)" />
|
||||
<span>{{ item.name }}</span>
|
||||
</div>
|
||||
<div class="fm-row__cell">{{ item.sizeLabel || '—' }}</div>
|
||||
<div class="fm-row__cell">{{ item.modified }}</div>
|
||||
<div class="fm-row__actions">
|
||||
<button
|
||||
v-if="item.isEditable"
|
||||
type="button"
|
||||
class="icon-button icon-button--inline"
|
||||
title="Edit"
|
||||
@click="openEditor(item)"
|
||||
>
|
||||
<span class="dashicons dashicons-edit" />
|
||||
</button>
|
||||
<a
|
||||
v-if="item.type === 'file'"
|
||||
class="icon-button icon-button--inline"
|
||||
title="Download"
|
||||
:href="downloadUrl(item)"
|
||||
>
|
||||
<span class="dashicons dashicons-download" />
|
||||
</a>
|
||||
<button type="button" class="icon-button icon-button--inline" title="Rename" @click="renameItem(item)">
|
||||
<span class="dashicons dashicons-tag" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-button icon-button--inline"
|
||||
title="Delete"
|
||||
:disabled="!item.isWritable"
|
||||
@click="deleteItem(item)"
|
||||
>
|
||||
<span class="dashicons dashicons-trash" />
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<!-- Text editor overlay -->
|
||||
<div v-if="editing" class="fm-editor-backdrop" @click.self="closeEditor">
|
||||
<div class="fm-editor">
|
||||
<header class="fm-editor__header">
|
||||
<strong>{{ editing.name }}</strong>
|
||||
<span v-if="!editing.isWritable" class="fm-editor__readonly">Read-only (not writable on disk)</span>
|
||||
<div class="fm-editor__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="button-primary"
|
||||
:disabled="!editing.isWritable || savingEdit"
|
||||
@click="saveEditor"
|
||||
>
|
||||
{{ savingEdit ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
<button type="button" class="icon-button" @click="closeEditor">
|
||||
<span class="dashicons dashicons-no-alt" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<textarea
|
||||
v-model="editorContent"
|
||||
class="fm-editor__textarea"
|
||||
spellcheck="false"
|
||||
:readonly="!editing.isWritable"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fm-page {
|
||||
min-height: calc(100vh - 112px);
|
||||
padding: 0;
|
||||
color: var(--aurora-text);
|
||||
background: transparent;
|
||||
}
|
||||
.fm-page__header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.fm-page__eyebrow {
|
||||
margin: 0 0 7px;
|
||||
color: var(--aurora-text-muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.fm-page h1 {
|
||||
margin: 0;
|
||||
color: var(--aurora-text);
|
||||
font-size: 1.45rem;
|
||||
line-height: 1.15;
|
||||
}
|
||||
.fm-page__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.icon-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 38px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--aurora-border);
|
||||
border-radius: 8px;
|
||||
color: var(--aurora-text);
|
||||
background: var(--aurora-bg-subtle);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.icon-button:hover:not(:disabled) {
|
||||
border-color: var(--aurora-text-muted);
|
||||
}
|
||||
.icon-button--inline {
|
||||
width: 30px;
|
||||
min-height: 30px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--aurora-text-muted);
|
||||
justify-content: center;
|
||||
}
|
||||
.icon-button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.button-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 34px;
|
||||
padding: 0 14px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: var(--aurora-accent);
|
||||
color: var(--aurora-accent-text);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.button-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.fm-breadcrumb {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 14px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.fm-breadcrumb__item {
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--aurora-text-muted);
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.fm-breadcrumb__item:hover {
|
||||
color: var(--aurora-text);
|
||||
background: var(--aurora-bg-subtle);
|
||||
}
|
||||
.fm-breadcrumb__sep {
|
||||
color: var(--aurora-text-muted);
|
||||
}
|
||||
.fm-toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.fm-search {
|
||||
width: min(320px, 100%);
|
||||
min-height: 38px;
|
||||
border: 1px solid var(--aurora-border);
|
||||
border-radius: 8px;
|
||||
background: var(--aurora-bg-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 12px;
|
||||
color: var(--aurora-text-muted);
|
||||
}
|
||||
.fm-search input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
color: var(--aurora-text);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
.fm-notice {
|
||||
margin: 0 0 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--aurora-bg-subtle);
|
||||
color: var(--aurora-text);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
.fm-table {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--aurora-frame-border);
|
||||
border-radius: 8px;
|
||||
background: var(--aurora-frame-bg);
|
||||
}
|
||||
.fm-table__head,
|
||||
.fm-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(200px, 1.8fr) 120px 180px 130px;
|
||||
align-items: center;
|
||||
}
|
||||
.fm-table__head {
|
||||
min-height: 44px;
|
||||
border-bottom: 1px solid var(--aurora-border);
|
||||
color: var(--aurora-text);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 850;
|
||||
}
|
||||
.fm-table__head > div {
|
||||
padding: 0 16px;
|
||||
}
|
||||
.fm-row {
|
||||
min-height: 48px;
|
||||
border-bottom: 1px solid var(--aurora-border);
|
||||
}
|
||||
.fm-row:hover {
|
||||
background: var(--aurora-bg-subtle);
|
||||
}
|
||||
.fm-row__name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
padding: 10px 16px;
|
||||
color: var(--aurora-text);
|
||||
font-size: 0.84rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
.fm-row__name span:last-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.fm-row__name .dashicons {
|
||||
color: var(--aurora-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.fm-row__name--clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.fm-row__name--clickable:hover {
|
||||
color: var(--aurora-accent);
|
||||
}
|
||||
.fm-row__cell {
|
||||
min-width: 0;
|
||||
padding: 10px 16px;
|
||||
color: var(--aurora-text-muted);
|
||||
font-size: 0.78rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.fm-row__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
.fm-table__empty {
|
||||
padding: 38px 18px;
|
||||
color: var(--aurora-text-muted);
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
}
|
||||
.fm-editor-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100000;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 30px;
|
||||
}
|
||||
.fm-editor {
|
||||
width: min(900px, 100%);
|
||||
height: min(640px, 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--aurora-frame-border);
|
||||
border-radius: 10px;
|
||||
background: var(--aurora-frame-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.fm-editor__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--aurora-border);
|
||||
color: var(--aurora-text);
|
||||
}
|
||||
.fm-editor__readonly {
|
||||
font-size: 0.75rem;
|
||||
color: var(--aurora-text-muted);
|
||||
}
|
||||
.fm-editor__actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.fm-editor__textarea {
|
||||
flex: 1;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
resize: none;
|
||||
padding: 16px;
|
||||
background: var(--aurora-bg-subtle);
|
||||
color: var(--aurora-text);
|
||||
font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.fm-table__head {
|
||||
display: none;
|
||||
}
|
||||
.fm-row {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 6px 0;
|
||||
}
|
||||
.fm-row__cell,
|
||||
.fm-row__actions {
|
||||
padding: 4px 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -34,6 +34,7 @@ export default defineConfig({
|
||||
pages: fileURLToPath(new URL('./src/entries/pages.js', import.meta.url)),
|
||||
media: fileURLToPath(new URL('./src/entries/media.js', import.meta.url)),
|
||||
users: fileURLToPath(new URL('./src/entries/users.js', import.meta.url)),
|
||||
'file-manager': fileURLToPath(new URL('./src/entries/file-manager.js', import.meta.url)),
|
||||
'locked-feature': fileURLToPath(new URL('./src/entries/locked-feature.js', import.meta.url)),
|
||||
},
|
||||
output: {
|
||||
|
||||
Reference in New Issue
Block a user