- Added standard Laravel directory structure and configuration. - Included Svelte and Tailwind configuration for the admin interface. - Added core PHPUnit and testing scripts.
286 lines
9.6 KiB
Svelte
286 lines
9.6 KiB
Svelte
<script>
|
|
import { onMount, onDestroy } from 'svelte';
|
|
import axios from 'axios';
|
|
import { EditorView, basicSetup } from 'codemirror';
|
|
import { javascript } from '@codemirror/lang-javascript';
|
|
import { php } from '@codemirror/lang-php';
|
|
import { css } from '@codemirror/lang-css';
|
|
import { json } from '@codemirror/lang-json';
|
|
import { markdown } from '@codemirror/lang-markdown';
|
|
import { oneDark } from '@codemirror/theme-one-dark';
|
|
import { EditorState } from '@codemirror/state';
|
|
import FileTreeNode from './FileTreeNode.svelte';
|
|
|
|
let { adminPath, themes, activeThemeSlug } = $props();
|
|
|
|
let selectedTheme = $state('');
|
|
let fileTree = $state([]);
|
|
|
|
$effect(() => {
|
|
selectedTheme = activeThemeSlug;
|
|
});
|
|
let currentFile = $state(null);
|
|
let originalContent = $state('');
|
|
let currentContent = $state('');
|
|
let loading = $state(false);
|
|
let saving = $state(false);
|
|
let editorElement = $state(null);
|
|
let editorView = $state(null);
|
|
|
|
let showNewFileModal = $state(false);
|
|
let newFileName = $state('');
|
|
let newFileTargetDir = $state(''); // relative to theme root
|
|
|
|
onMount(async () => {
|
|
if (selectedTheme) {
|
|
await loadFileTree();
|
|
}
|
|
initEditor();
|
|
});
|
|
|
|
onDestroy(() => {
|
|
if (editorView) {
|
|
editorView.destroy();
|
|
}
|
|
});
|
|
|
|
function initEditor() {
|
|
if (!editorElement) return;
|
|
|
|
const startState = EditorState.create({
|
|
doc: '',
|
|
extensions: [
|
|
basicSetup,
|
|
oneDark,
|
|
EditorView.updateListener.of((update) => {
|
|
if (update.docChanged) {
|
|
currentContent = update.state.doc.toString();
|
|
}
|
|
})
|
|
]
|
|
});
|
|
|
|
editorView = new EditorView({
|
|
state: startState,
|
|
parent: editorElement
|
|
});
|
|
}
|
|
|
|
async function loadFileTree() {
|
|
loading = true;
|
|
try {
|
|
const response = await axios.get(`${adminPath}/themes/editor/tree`, {
|
|
params: { theme: selectedTheme }
|
|
});
|
|
fileTree = response.data;
|
|
} catch (error) {
|
|
console.error('Failed to load file tree', error);
|
|
alert('Error loading file tree');
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
async function handleThemeChange() {
|
|
currentFile = null;
|
|
setEditorContent('');
|
|
await loadFileTree();
|
|
}
|
|
|
|
async function openFile(file) {
|
|
if (currentContent !== originalContent) {
|
|
if (!confirm('You have unsaved changes. Discard them?')) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
loading = true;
|
|
try {
|
|
const response = await axios.get(`${adminPath}/themes/editor/read`, {
|
|
params: { theme: selectedTheme, path: file.path }
|
|
});
|
|
currentFile = file;
|
|
originalContent = response.data.content;
|
|
currentContent = response.data.content;
|
|
setEditorContent(response.data.content, file.name);
|
|
} catch (error) {
|
|
console.error('Failed to read file', error);
|
|
alert('Error reading file');
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
function setEditorContent(content, filename = '') {
|
|
if (!editorView) return;
|
|
|
|
let extensions = [
|
|
basicSetup,
|
|
oneDark,
|
|
EditorView.updateListener.of((update) => {
|
|
if (update.docChanged) {
|
|
currentContent = update.state.doc.toString();
|
|
}
|
|
})
|
|
];
|
|
|
|
if (filename.endsWith('.js')) extensions.push(javascript());
|
|
else if (filename.endsWith('.php') || filename.endsWith('.blade.php')) extensions.push(php());
|
|
else if (filename.endsWith('.css')) extensions.push(css());
|
|
else if (filename.endsWith('.json')) extensions.push(json());
|
|
else if (filename.endsWith('.md')) extensions.push(markdown());
|
|
|
|
editorView.setState(EditorState.create({
|
|
doc: content,
|
|
extensions: extensions
|
|
}));
|
|
}
|
|
|
|
async function saveFile() {
|
|
if (!currentFile) return;
|
|
|
|
saving = true;
|
|
try {
|
|
await axios.post(`${adminPath}/themes/editor/save`, {
|
|
theme: selectedTheme,
|
|
path: currentFile.path,
|
|
content: currentContent
|
|
});
|
|
originalContent = currentContent;
|
|
alert('File saved successfully');
|
|
} catch (error) {
|
|
console.error('Failed to save file', error);
|
|
alert(error.response?.data?.error || 'Error saving file');
|
|
} finally {
|
|
saving = false;
|
|
}
|
|
}
|
|
|
|
function cancelEdit() {
|
|
if (currentContent !== originalContent) {
|
|
if (confirm('Discard unsaved changes?')) {
|
|
currentContent = originalContent;
|
|
setEditorContent(originalContent, currentFile.name);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function createFile() {
|
|
if (!newFileName) return;
|
|
|
|
try {
|
|
await axios.post(`${adminPath}/themes/editor/create`, {
|
|
theme: selectedTheme,
|
|
path: newFileTargetDir,
|
|
filename: newFileName
|
|
});
|
|
showNewFileModal = false;
|
|
newFileName = '';
|
|
await loadFileTree();
|
|
} catch (error) {
|
|
console.error('Failed to create file', error);
|
|
alert(error.response?.data?.error || 'Error creating file');
|
|
}
|
|
}
|
|
|
|
function openNewFileModal(dirPath = '') {
|
|
newFileTargetDir = dirPath;
|
|
showNewFileModal = true;
|
|
}
|
|
|
|
</script>
|
|
|
|
<div class="ui container fluid">
|
|
<div class="ui grid">
|
|
<div class="sixteen wide column">
|
|
<div class="ui secondary menu">
|
|
<h2 class="ui header">Theme Editor</h2>
|
|
<div class="right menu">
|
|
<div class="item">
|
|
<select class="ui dropdown" bind:value={selectedTheme} onchange={handleThemeChange}>
|
|
<option value="">Select Theme</option>
|
|
{#each themes as theme}
|
|
<option value={theme.slug}>{theme.title}</option>
|
|
{/each}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="ui divider"></div>
|
|
|
|
{#if loading && !fileTree.length}
|
|
<div class="ui segment" style="height: 400px;">
|
|
<div class="ui active inverted dimmer">
|
|
<div class="ui text loader">Loading...</div>
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<div class="ui grid">
|
|
<div class="four wide column">
|
|
<div class="ui segment" style="height: 600px; overflow-y: auto;">
|
|
<div class="ui header small" style="display: flex; justify-content: space-between; align-items: center;">
|
|
Files
|
|
<button class="ui icon mini button primary" onclick={() => openNewFileModal('')} title="New File in Root">
|
|
<i class="plus icon"></i>
|
|
</button>
|
|
</div>
|
|
<div class="ui list">
|
|
<FileTreeNode {fileTree} {openFile} {openNewFileModal} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="twelve wide column">
|
|
<div class="ui segment p-0" style="height: 600px; display: flex; flex-direction: column;">
|
|
{#if currentFile}
|
|
<div class="ui secondary menu mini borderless" style="margin: 0; padding: 5px 10px; background: #f9f9f9;">
|
|
<div class="item"><strong>{currentFile.path}</strong></div>
|
|
</div>
|
|
{/if}
|
|
<div bind:this={editorElement} style="flex-grow: 1; overflow: auto;"></div>
|
|
</div>
|
|
|
|
<div class="ui segment right aligned">
|
|
<button class="ui button" onclick={cancelEdit} disabled={saving || !currentFile}>Cancel</button>
|
|
<button class="ui button primary" class:loading={saving} onclick={saveFile} disabled={saving || !currentFile}>Save</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Simple New File Modal (using basic UI instead of formal semantic modal for simplicity in Svelte) -->
|
|
{#if showNewFileModal}
|
|
<div class="ui active modal" style="top: 20%; display: block !important;">
|
|
<div class="header">Create New File</div>
|
|
<div class="content">
|
|
<div class="ui form">
|
|
<div class="field">
|
|
<label for="new-file-dir">Directory: {newFileTargetDir || '/'}</label>
|
|
<input type="hidden" id="new-file-dir">
|
|
</div>
|
|
<div class="field">
|
|
<label for="new-file-name">Filename (e.g. index.blade.php, style.css)</label>
|
|
<input type="text" id="new-file-name" bind:value={newFileName} placeholder="filename.extension">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="actions">
|
|
<button class="ui button" onclick={() => showNewFileModal = false}>Cancel</button>
|
|
<button class="ui button primary" onclick={createFile}>Create</button>
|
|
</div>
|
|
</div>
|
|
<div class="ui active dimmer" style="z-index: 999;"></div>
|
|
{/if}
|
|
|
|
<style>
|
|
:global(.cm-editor) {
|
|
height: 100%;
|
|
}
|
|
.p-0 {
|
|
padding: 0 !important;
|
|
}
|
|
</style>
|