cms/resources/js/components/admin/ThemeEditor.svelte

286 lines
9.6 KiB
Svelte
Raw Permalink Normal View History

<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>