hoàn thành block editor của Pages

This commit is contained in:
2026-06-25 16:38:03 +07:00
parent 5c644545b8
commit 6fe098bc5b
32 changed files with 2036 additions and 8 deletions
@@ -86,6 +86,7 @@ jhipster:
# Token is valid 24 hours
token-validity-in-seconds: 86400
token-validity-in-seconds-for-remember-me: 2592000
base64-secret: NGE0ZDlkMmI4YTMzMTNjOGFiZWZiMGMwNmVkNTcxNDZhZmVmMThmMDViNGMzNWI3MGVjY2YyZWJmOTMzOGI2NTIzMDdhMDVlNzU0YWJkYjcxOGM3Y2IwODcyZmMyZjg1MDU5ZjViMDIzMDJmMjc3ZmYxMWMyZDdiZWNhMmVjNGE=
mail: # specific JHipster mail property, for standard properties see MailProperties
base-url: http://127.0.0.1:8080
logging:
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<changeSet id="20260625100000-1" author="sisvietnam">
<createTable tableName="sis_page">
<column name="id" type="bigint">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="title" type="varchar(255)">
<constraints nullable="false"/>
</column>
<column name="slug" type="varchar(255)">
<constraints nullable="false" unique="true" uniqueConstraintName="ux_sis_page_slug"/>
</column>
<column name="content" type="${clobType}"/>
<column name="meta_description" type="varchar(500)"/>
<column name="status" type="varchar(20)" defaultValue="DRAFT">
<constraints nullable="false"/>
</column>
<column name="display_order" type="integer" defaultValueNumeric="0"/>
<column name="created_by" type="varchar(50)">
<constraints nullable="false"/>
</column>
<column name="created_date" type="timestamp"/>
<column name="last_modified_by" type="varchar(50)"/>
<column name="last_modified_date" type="timestamp"/>
</createTable>
</changeSet>
</databaseChangeLog>
@@ -19,6 +19,7 @@
<property name="timeType" value="time" dbms="oracle"/>
<include file="config/liquibase/changelog/00000000000000_initial_schema.xml" relativeToChangelogFile="false"/>
<include file="config/liquibase/changelog/20260625100000_add_page_entity.xml" relativeToChangelogFile="false"/>
<!-- jhipster-needle-liquibase-add-changelog - JHipster will add liquibase changelogs here -->
<!-- jhipster-needle-liquibase-add-constraints-changelog - JHipster will add liquibase constraints changelogs here -->
<!-- jhipster-needle-liquibase-add-incremental-changelog - JHipster will add incremental liquibase changelogs here -->
@@ -1,4 +1,40 @@
/* Custom CSS - This file has the highest priority and will override other styles */
/* ==========================================================================
Design System Tokens (Project-wide CSS Variables)
========================================================================== */
:root {
/* Primary brand colors */
--color-primary: #007bff;
--color-primary-hover: #0056b3;
--color-primary-light: #f0f9ff;
/* Secondary & Accent colors */
--color-secondary: #6c757d;
--color-success: #28a745;
--color-warning: #ffc107;
--color-danger: #dc3545;
/* Neutrals */
--color-background: #ffffff;
--color-surface: #fafafa;
--color-border: #dee2e6;
/* Text colors */
--color-text-main: #333333;
--color-text-muted: #6c757d;
--color-text-light: #ffffff;
/* Typography */
--font-family-base: 'Open Sans', 'Inter', sans-serif;
--font-family-heading: 'Lora', 'Georgia', serif;
/* Spacing & Radii */
--spacing-md: 20px;
--radius-md: 4px;
--radius-lg: 8px;
}
[data-component-id="umass_base:tophat"] {
background-color: transparent !important;
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,180 @@
/**
* SIS Vietnam - Block Editor Configuration
*
* Built on Editor.js (https://editorjs.io)
*
* PLUGIN ARCHITECTURE:
* =====================
* This editor uses a plugin registry pattern. The built-in block types
* (Header, List, Quote, etc.) are registered by default.
*
* To add a CUSTOM BLOCK TYPE in the future:
*
* 1. Create a new JS file in /js/manage/editor-plugins/
* Example: /js/manage/editor-plugins/my-custom-block.js
*
* 2. In that file, register your tool BEFORE the editor initializes:
*
* window.SISEditorPlugins = window.SISEditorPlugins || {};
* window.SISEditorPlugins['myBlock'] = {
* class: MyBlockClass, // Your Editor.js Tool class
* inlineToolbar: true, // optional
* config: { ... } // optional tool-specific config
* };
*
* 3. Load the script in your template BEFORE editor-config.js:
* <script src="/js/manage/editor-plugins/my-custom-block.js"></script>
*
* 4. The editor will automatically pick up all registered plugins.
*/
// Global plugin registry — external plugins register here
window.SISEditorPlugins = window.SISEditorPlugins || {};
/**
* Initialize the SIS Block Editor on a given holder element.
*
* @param {string} holderId - The DOM element ID for the editor container
* @param {string} hiddenInputId - The DOM element ID for the hidden input storing JSON
* @param {object|null} initialData - Pre-existing Editor.js JSON data to load
* @returns {EditorJS} The editor instance
*/
function initSISEditor(holderId, hiddenInputId, initialData) {
'use strict';
// === Built-in Tools ===
var builtInTools = {
header: {
class: Header,
inlineToolbar: true,
config: {
placeholder: 'Enter a heading...',
levels: [2, 3, 4],
defaultLevel: 2
}
},
list: {
class: NestedList,
inlineToolbar: true,
config: {
defaultStyle: 'unordered'
}
},
quote: {
class: Quote,
inlineToolbar: true,
config: {
quotePlaceholder: 'Enter a quote...',
captionPlaceholder: 'Quote author'
}
},
delimiter: {
class: Delimiter
},
table: {
class: Table,
inlineToolbar: true,
config: {
rows: 2,
cols: 3
}
},
code: {
class: CodeTool
},
warning: {
class: Warning,
inlineToolbar: true,
config: {
titlePlaceholder: 'Title',
messagePlaceholder: 'Message'
}
},
marker: {
class: Marker
},
inlineCode: {
class: InlineCode
},
underline: {
class: Underline
},
image: {
class: ImageTool,
config: {
endpoints: {
byFile: '/api/manage/media/upload',
},
field: 'file',
types: 'image/*'
}
},
attaches: {
class: AttachesTool,
config: {
endpoint: '/api/manage/media/upload',
field: 'file'
}
}
};
// === Merge built-in tools with any registered plugins ===
var allTools = Object.assign({}, builtInTools, window.SISEditorPlugins);
// === Parse initial data ===
var editorData = null;
if (initialData && typeof initialData === 'string') {
try {
editorData = JSON.parse(initialData);
} catch (e) {
console.warn('[SIS Editor] Could not parse initial data as JSON, starting empty.', e);
editorData = null;
}
} else if (initialData && typeof initialData === 'object') {
editorData = initialData;
}
// === Create the Editor ===
var editor = new EditorJS({
holder: holderId,
tools: allTools,
data: editorData || undefined,
placeholder: 'Click here to start writing your page content...',
autofocus: false,
onReady: function() {
console.log('[SIS Editor] Ready. Tools loaded:', Object.keys(allTools));
},
onChange: function(api, event) {
// Auto-save to hidden input on every change
api.saver.save().then(function(outputData) {
var hiddenInput = document.getElementById(hiddenInputId);
if (hiddenInput) {
hiddenInput.value = JSON.stringify(outputData);
}
});
}
});
// === Form submission handler ===
// Ensure the latest content is saved before form submit
var form = document.querySelector('form');
if (form) {
var submitHandler = function(event) {
event.preventDefault();
editor.save().then(function(outputData) {
var hiddenInput = document.getElementById(hiddenInputId);
if (hiddenInput) {
hiddenInput.value = JSON.stringify(outputData);
}
// Now submit the form
form.removeEventListener('submit', submitHandler);
form.submit();
}).catch(function(error) {
console.error('[SIS Editor] Save failed:', error);
});
};
form.addEventListener('submit', submitHandler);
}
return editor;
}
@@ -0,0 +1,69 @@
/**
* SAMPLE PLUGIN — How to create a custom Editor.js block for SIS Vietnam
*
* This file demonstrates the plugin pattern. Copy this file and modify it
* to create your own custom block types.
*
* STEPS:
* 1. Copy this file and rename it (e.g., "my-video-block.js")
* 2. Create your Tool class following the Editor.js API
* 3. Register it in window.SISEditorPlugins
* 4. Load it in your template <script> tag BEFORE editor-config.js
*
* DOCUMENTATION: https://editorjs.io/creating-a-block-tool/
*/
// === Example: A simple "Alert Box" block ===
/*
(function() {
'use strict';
// Define your block tool class
class AlertBox {
static get toolbox() {
return {
title: 'Alert Box',
icon: '<svg width="17" height="15" viewBox="0 0 336 276"><path d="M291 36l-15-26a17 17 0 0 0-30 0L15 277h306L291 36z"/></svg>'
};
}
constructor({ data }) {
this.data = data || {};
}
render() {
var wrapper = document.createElement('div');
wrapper.style.padding = '12px';
wrapper.style.border = '2px solid #f0ad4e';
wrapper.style.borderRadius = '4px';
wrapper.style.backgroundColor = '#fcf8e3';
wrapper.contentEditable = true;
wrapper.innerHTML = this.data.text || '';
wrapper.addEventListener('input', function() {
this.data.text = wrapper.innerHTML;
}.bind(this));
this.wrapper = wrapper;
return wrapper;
}
save(blockContent) {
return {
text: blockContent.innerHTML
};
}
}
// Register the plugin — this is the KEY step
window.SISEditorPlugins = window.SISEditorPlugins || {};
window.SISEditorPlugins['alertBox'] = {
class: AlertBox,
inlineToolbar: true
};
})();
*/
// This file is intentionally commented out.
// Uncomment the code above to enable the Alert Box block,
// or use it as a template for your own custom blocks.
@@ -0,0 +1,138 @@
/**
* Custom Editor.js Block Tool for embedding predefined HTML snippets.
*/
class HtmlSnippetTool {
static get toolbox() {
return {
title: 'HTML Snippet',
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"></polyline><polyline points="8 6 2 12 8 18"></polyline></svg>'
};
}
constructor({ data, api }) {
this.data = {
id: data.id || ''
};
this.api = api;
this.wrapper = undefined;
}
render() {
this.wrapper = document.createElement('div');
this.wrapper.classList.add('ce-snippet-wrapper');
this.wrapper.style.border = '1px solid #ddd';
this.wrapper.style.padding = '15px';
this.wrapper.style.borderRadius = '5px';
this.wrapper.style.background = '#fafafa';
if (this.data.id) {
this._showPreview(this.data.id);
} else {
this._showInput();
}
return this.wrapper;
}
_showInput() {
this.wrapper.innerHTML = '';
const title = document.createElement('h4');
title.style.marginTop = '0';
title.style.marginBottom = '10px';
title.innerText = 'Insert Predefined HTML Snippet';
const inputContainer = document.createElement('div');
inputContainer.style.display = 'flex';
inputContainer.style.gap = '10px';
const input = document.createElement('input');
input.classList.add('ce-input');
input.placeholder = 'Enter Snippet ID (e.g., test_banner)';
input.value = this.data.id;
const loadBtn = document.createElement('button');
loadBtn.innerText = 'Load Preview';
loadBtn.style.padding = '5px 15px';
loadBtn.style.cursor = 'pointer';
loadBtn.addEventListener('click', () => {
if (input.value.trim()) {
this._showPreview(input.value.trim());
}
});
inputContainer.appendChild(input);
inputContainer.appendChild(loadBtn);
this.wrapper.appendChild(title);
this.wrapper.appendChild(inputContainer);
}
_showPreview(snippetId) {
this.wrapper.innerHTML = '<div style="color: #666;">Loading preview...</div>';
fetch('/api/manage/snippets/' + encodeURIComponent(snippetId))
.then(response => {
if (!response.ok) throw new Error('Snippet not found');
return response.text();
})
.then(html => {
this.data.id = snippetId;
this.wrapper.innerHTML = '';
const header = document.createElement('div');
header.style.display = 'flex';
header.style.justifyContent = 'space-between';
header.style.alignItems = 'center';
header.style.marginBottom = '10px';
header.style.borderBottom = '1px solid #ddd';
header.style.paddingBottom = '5px';
const title = document.createElement('strong');
title.innerText = 'Snippet: ' + snippetId;
const editBtn = document.createElement('button');
editBtn.innerText = 'Edit ID';
editBtn.style.fontSize = '12px';
editBtn.style.cursor = 'pointer';
editBtn.addEventListener('click', () => {
this._showInput();
});
header.appendChild(title);
header.appendChild(editBtn);
const previewArea = document.createElement('div');
previewArea.innerHTML = html;
// Prevent interactions inside preview from submitting forms or acting up
previewArea.style.pointerEvents = 'none';
this.wrapper.appendChild(header);
this.wrapper.appendChild(previewArea);
})
.catch(error => {
this.wrapper.innerHTML = '<div style="color: red;">Error: ' + error.message + '</div>';
const backBtn = document.createElement('button');
backBtn.innerText = 'Try Again';
backBtn.style.marginTop = '10px';
backBtn.style.cursor = 'pointer';
backBtn.addEventListener('click', () => this._showInput());
this.wrapper.appendChild(backBtn);
});
}
save(blockContent) {
return {
id: this.data.id
};
}
}
// Register the plugin globally
window.SISEditorPlugins = window.SISEditorPlugins || {};
window.SISEditorPlugins['snippet'] = {
class: HtmlSnippetTool
};
@@ -1,4 +1,4 @@
<header id="l--main-header" th:fragment="header">
<header id="l--main-header" th:fragment="header">
<h1 class="visually-hidden">The University of Massachusetts Amherst</h1>
<style>
@@ -1354,7 +1354,7 @@
</div>
<script defer src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/lazysizes/5.3.2/lazysizes.min.js"></script>
<script defer th:src="@{/js/lazysizes.min.js}"></script>
<script defer th:src="@{/js/custom-umass.js}"></script>
<style>
@@ -16,6 +16,9 @@
<!-- Custom styles for this template-->
<link href="https://cdnjs.cloudflare.com/ajax/libs/startbootstrap-sb-admin-2/4.1.4/css/sb-admin-2.min.css" rel="stylesheet">
<!-- Project-wide CSS Variables & Custom Styles -->
<link rel="stylesheet" th:href="@{/css/custom.css}">
</head>
<body id="page-top">
@@ -47,6 +50,30 @@
<!-- Divider -->
<hr class="sidebar-divider">
<!-- Heading -->
<div class="sidebar-heading">
Content
</div>
<!-- Nav Item - Pages Collapse Menu -->
<li class="nav-item">
<a class="nav-link collapsed" href="#" data-toggle="collapse" data-target="#collapsePages"
aria-expanded="true" aria-controls="collapsePages">
<i class="fas fa-fw fa-file-alt"></i>
<span>Pages</span>
</a>
<div id="collapsePages" class="collapse" aria-labelledby="headingPages" data-parent="#accordionSidebar">
<div class="bg-white py-2 collapse-inner rounded">
<h6 class="collapse-header">Page Management:</h6>
<a class="collapse-item" th:href="@{/manage/pages}">All Pages</a>
<a class="collapse-item" th:href="@{/manage/pages/new}">Add New</a>
</div>
</div>
</li>
<!-- Divider -->
<hr class="sidebar-divider">
<!-- Heading -->
<div class="sidebar-heading">
Interface
@@ -184,6 +211,9 @@
<!-- Custom scripts for all pages-->
<script src="https://cdnjs.cloudflare.com/ajax/libs/startbootstrap-sb-admin-2/4.1.4/js/sb-admin-2.min.js"></script>
<!-- Page-specific scripts injected by child templates -->
<section layout:fragment="scripts"></section>
</body>
</html>
@@ -0,0 +1,307 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{fragments/manage-layout}">
<head>
<title th:text="${isNew} ? 'Add New Page' : 'Edit Page'">Page Form</title>
<style>
/* Editor.js container styling */
#editorjs {
border: 1px solid #d1d3e2;
border-radius: 0.35rem;
padding: 16px 12px;
min-height: 350px;
background: #fff;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
}
#editorjs:focus-within {
border-color: #bac8f3;
box-shadow: 0 0 0 0.2rem rgba(78, 115, 223, 0.25);
}
/* Block tool styling overrides */
.ce-block__content {
max-width: 100% !important;
}
.ce-toolbar__content {
max-width: 100% !important;
}
/* Make the editor look clean inside the card */
.codex-editor__redactor {
padding-bottom: 80px !important;
}
/* Plugin badge info */
.editor-plugin-info {
font-size: 0.75rem;
color: #858796;
}
.editor-plugin-info .badge {
font-weight: 400;
font-size: 0.7rem;
}
/* Full Screen Editor Mode */
.editor-fullscreen {
position: fixed !important;
top: 0;
left: 0;
width: 100vw !important;
height: 100vh !important;
z-index: 9999 !important;
margin: 0 !important;
border-radius: 0 !important;
border: none !important;
overflow-y: auto !important;
padding: 40px !important;
}
</style>
</head>
<body>
<div layout:fragment="content">
<!-- Page Heading -->
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 mb-0 text-gray-800" th:text="${isNew} ? 'Add New Page' : 'Edit Page'">Page Form</h1>
<a th:href="@{/manage/pages}" class="d-none d-sm-inline-block btn btn-sm btn-secondary shadow-sm">
<i class="fas fa-arrow-left fa-sm text-white-50"></i> Back to All Pages
</a>
</div>
<!-- Form Card -->
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary"
th:text="${isNew} ? 'Create a New Page' : 'Update Page Details'">Form</h6>
</div>
<div class="card-body">
<form id="pageForm" th:action="${isNew} ? @{/manage/pages} : @{/manage/pages/{id}(id=${page.id})}"
th:object="${page}" method="post">
<!-- Validation errors summary -->
<div th:if="${#fields.hasErrors('*')}" class="alert alert-danger">
<ul class="mb-0">
<li th:each="err : ${#fields.errors('*')}" th:text="${err}"></li>
</ul>
</div>
<!-- Title -->
<div class="form-group">
<label for="pageTitle" class="font-weight-bold">Title <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="pageTitle" th:field="*{title}"
th:classappend="${#fields.hasErrors('title')} ? 'is-invalid' : ''"
placeholder="Enter page title (e.g. About Us, Contact)" required>
<div class="invalid-feedback" th:if="${#fields.hasErrors('title')}" th:errors="*{title}"></div>
</div>
<!-- Slug -->
<div class="form-group">
<label for="pageSlug" class="font-weight-bold">Slug (URL)</label>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text">/page/</span>
</div>
<input type="text" class="form-control" id="pageSlug" th:field="*{slug}"
th:classappend="${#fields.hasErrors('slug')} ? 'is-invalid' : ''"
placeholder="auto-generated-from-title">
<div class="invalid-feedback" th:if="${#fields.hasErrors('slug')}" th:errors="*{slug}">
</div>
</div>
<small class="form-text text-muted">Leave blank to auto-generate from the title. Use lowercase
letters, numbers, and hyphens only.</small>
</div>
<div class="row">
<!-- Status -->
<div class="col-md-6">
<div class="form-group">
<label for="pageStatus" class="font-weight-bold">Status <span
class="text-danger">*</span></label>
<select class="form-control" id="pageStatus" th:field="*{status}">
<option th:each="s : ${statuses}" th:value="${s}" th:text="${s}"></option>
</select>
</div>
</div>
<!-- Display Order -->
<div class="col-md-6">
<div class="form-group">
<label for="pageDisplayOrder" class="font-weight-bold">Display Order</label>
<input type="number" class="form-control" id="pageDisplayOrder"
th:field="*{displayOrder}" placeholder="0" min="0">
<small class="form-text text-muted">Lower numbers appear first.</small>
</div>
</div>
</div>
<!-- Meta Description -->
<div class="form-group">
<label for="pageMetaDescription" class="font-weight-bold">Meta Description (SEO)</label>
<textarea class="form-control" id="pageMetaDescription" th:field="*{metaDescription}" rows="2"
maxlength="500"
placeholder="Brief description for search engines (max 500 characters)"></textarea>
<small class="form-text text-muted">This appears in Google search results below the page
title.</small>
</div>
<!-- Block Editor Content -->
<div class="form-group">
<div class="d-flex justify-content-between align-items-center mb-2">
<label class="font-weight-bold mb-0">
<i class="fas fa-cubes"></i> Page Content (Block Editor)
</label>
<button type="button" class="btn btn-sm btn-outline-primary" id="toggleFullscreenBtn">
<i class="fas fa-expand"></i> Full Screen Mode
</button>
</div>
<div class="editor-plugin-info mb-2">
Active blocks:
<span class="badge badge-light">Heading</span>
<span class="badge badge-light">List</span>
<span class="badge badge-light">Quote</span>
<span class="badge badge-light">Table</span>
<span class="badge badge-light">Code</span>
<span class="badge badge-light">Delimiter</span>
<span class="badge badge-light">Warning</span>
<span id="pluginBadges"></span>
</div>
<!-- The Editor.js container -->
<div id="editorjs"></div>
<!-- Hidden input to store the JSON content for form submission -->
<input type="hidden" id="editorContent" name="content" th:value="*{content}">
<small class="form-text text-muted mt-2">
<i class="fas fa-info-circle"></i>
Click the <strong>+</strong> button or press <kbd>Tab</kbd> to add new blocks.
Use the block menu (&#9776;) to change block types or reorder.
</small>
</div>
<!-- Submit Buttons -->
<hr>
<div class="d-flex justify-content-between">
<a th:href="@{/manage/pages}" class="btn btn-secondary">
<i class="fas fa-times"></i> Cancel
</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i>
<span th:text="${isNew} ? 'Create Page' : 'Update Page'">Save</span>
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Editor.js Scripts (injected via layout:fragment="scripts") -->
<section layout:fragment="scripts">
<!-- Editor.js Core -->
<script src="https://cdn.jsdelivr.net/npm/@editorjs/editorjs@2.30.8/dist/editorjs.umd.js"></script>
<!-- Built-in Block Tools -->
<script src="https://cdn.jsdelivr.net/npm/@editorjs/header@2.8.8/dist/header.umd.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@editorjs/nested-list@1.4.3/dist/nested-list.umd.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@editorjs/quote@2.7.4/dist/quote.umd.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@editorjs/delimiter@1.4.2/dist/delimiter.umd.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@editorjs/table@2.4.2/dist/table.umd.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@editorjs/code@2.9.3/dist/code.umd.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@editorjs/warning@1.4.1/dist/warning.umd.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@editorjs/marker@1.4.0/dist/marker.umd.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@editorjs/inline-code@1.5.1/dist/inline-code.umd.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@editorjs/underline@1.1.0/dist/bundle.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@editorjs/image@2.9.0/dist/image.umd.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@editorjs/attaches@1.3.0/dist/bundle.js"></script>
<!--
============================================================
FUTURE PLUGINS: Load your custom block plugin scripts HERE.
They must be loaded BEFORE editor-config.js so they can
register into window.SISEditorPlugins.
Example:
<script th:src="@{/js/manage/editor-plugins/my-video-block.js}"></script>
<script th:src="@{/js/manage/editor-plugins/my-gallery-block.js}"></script>
============================================================
-->
<!-- Custom SIS Editor Plugins -->
<script th:src="@{/js/manage/editor-plugins/html-snippet.js}"></script>
<!-- Init Editor -->
<script th:src="@{/js/manage/editor-config.js}"></script>
<!-- Initialize the editor with existing content (if editing) -->
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function () {
var existingContent = document.getElementById('editorContent').value;
var initialData = null;
if (existingContent && existingContent.trim() !== '') {
try {
initialData = JSON.parse(existingContent);
} catch (e) {
console.warn('[SIS Editor] Existing content is not valid JSON, starting fresh.');
}
}
window.sisEditor = initSISEditor('editorjs', 'editorContent', initialData);
// Show badges for any injected plugins
var pluginBadges = document.getElementById('pluginBadges');
var pluginKeys = Object.keys(window.SISEditorPlugins || {});
if (pluginBadges && pluginKeys.length > 0) {
pluginKeys.forEach(function (key) {
var badge = document.createElement('span');
badge.className = 'badge badge-info ml-1';
badge.textContent = key + ' (plugin)';
pluginBadges.appendChild(badge);
});
}
// Warn user before leaving page if they have unsaved changes
var isFormSubmitted = false;
document.getElementById('pageForm').addEventListener('submit', function () {
isFormSubmitted = true;
});
window.addEventListener('beforeunload', function (e) {
if (!isFormSubmitted) {
var confirmationMessage = 'You may have unsaved changes. Are you sure you want to leave?';
e.returnValue = confirmationMessage;
return confirmationMessage;
}
});
// Full Screen Mode Toggle
var editorContainer = document.getElementById('editorjs');
var fsBtn = document.getElementById('toggleFullscreenBtn');
var fsIcon = fsBtn.querySelector('i');
fsBtn.addEventListener('click', function () {
editorContainer.classList.toggle('editor-fullscreen');
if (editorContainer.classList.contains('editor-fullscreen')) {
fsIcon.classList.remove('fa-expand');
fsIcon.classList.add('fa-compress');
fsBtn.style.position = 'fixed';
fsBtn.style.top = '10px';
fsBtn.style.right = '20px';
fsBtn.style.zIndex = '10000';
} else {
fsIcon.classList.remove('fa-compress');
fsIcon.classList.add('fa-expand');
fsBtn.style.position = 'static';
}
});
});
</script>
</section>
</body>
</html>
@@ -0,0 +1,95 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{fragments/manage-layout}">
<head>
<title>All Pages</title>
</head>
<body>
<div layout:fragment="content">
<!-- Page Heading -->
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 mb-0 text-gray-800">All Pages</h1>
<a th:href="@{/manage/pages/new}" class="d-none d-sm-inline-block btn btn-sm btn-primary shadow-sm">
<i class="fas fa-plus fa-sm text-white-50"></i> Add New Page
</a>
</div>
<!-- Success Message -->
<div th:if="${successMessage}" class="alert alert-success alert-dismissible fade show" role="alert">
<span th:text="${successMessage}"></span>
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<!-- Error Message -->
<div th:if="${errorMessage}" class="alert alert-danger alert-dismissible fade show" role="alert">
<span th:text="${errorMessage}"></span>
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<!-- Pages Table -->
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Manage Static Pages</h6>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-bordered" id="pagesTable" width="100%" cellspacing="0">
<thead>
<tr>
<th width="5%">#</th>
<th width="25%">Title</th>
<th width="20%">Slug</th>
<th width="10%">Status</th>
<th width="8%">Order</th>
<th width="17%">Last Modified</th>
<th width="15%">Actions</th>
</tr>
</thead>
<tbody>
<tr th:each="page, iterStat : ${pages}">
<td th:text="${iterStat.count}"></td>
<td th:text="${page.title}"></td>
<td>
<code th:text="${page.slug}"></code>
</td>
<td>
<span class="badge"
th:classappend="${page.status.name() == 'PUBLISHED'} ? 'badge-success' : (${page.status.name() == 'DRAFT'} ? 'badge-warning' : 'badge-secondary')"
th:text="${page.status}">
</span>
</td>
<td th:text="${page.displayOrder}"></td>
<td th:text="${page.lastModifiedDate != null} ? ${#temporals.format(page.lastModifiedDate, 'yyyy-MM-dd HH:mm')} : '-'"></td>
<td>
<a th:href="@{/manage/pages/{id}/edit(id=${page.id})}" class="btn btn-sm btn-info" title="Edit">
<i class="fas fa-edit"></i> Edit
</a>
<form th:action="@{/manage/pages/{id}/delete(id=${page.id})}" method="post" style="display:inline;"
onsubmit="return confirm('Are you sure you want to delete this page?');">
<button type="submit" class="btn btn-sm btn-danger" title="Delete">
<i class="fas fa-trash"></i> Delete
</button>
</form>
</td>
</tr>
<tr th:if="${#lists.isEmpty(pages)}">
<td colspan="7" class="text-center text-muted py-4">
<i class="fas fa-file-alt fa-2x mb-2 d-block"></i>
No pages found. Click "Add New Page" to create one.
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</body>
</html>
@@ -0,0 +1,110 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{fragments/layout}">
<head>
<title th:text="${page.title}">Page Title</title>
<!-- Add Meta Description for SEO -->
<meta name="description" th:if="${page.metaDescription != null}" th:content="${page.metaDescription}" />
</head>
<body>
<!--
Because the user wants absolute freedom to design the page using snippets,
we will render the blocks directly into the content fragment with NO constraining containers.
If they want a container, they can use a standard snippet.
-->
<div layout:fragment="content">
<!-- Render Editor.js Blocks -->
<th:block th:each="block : ${blocks}">
<!-- 1. HTML Snippet Block (Custom) -->
<th:block th:if="${block.type == 'snippet'}">
<div th:insert="~{'snippets/' + ${block.data.id}}"></div>
</th:block>
<!-- 2. Header Block -->
<th:block th:if="${block.type == 'header'}">
<!-- Editor.js header block has level (1-6) and text -->
<div class="container my-3">
<th:block th:switch="${block.data.level}">
<h1 th:case="1" th:utext="${block.data.text}"></h1>
<h2 th:case="2" th:utext="${block.data.text}"></h2>
<h3 th:case="3" th:utext="${block.data.text}"></h3>
<h4 th:case="4" th:utext="${block.data.text}"></h4>
<h5 th:case="5" th:utext="${block.data.text}"></h5>
<h6 th:case="6" th:utext="${block.data.text}"></h6>
<h2 th:case="*" th:utext="${block.data.text}"></h2>
</th:block>
</div>
</th:block>
<!-- 3. Paragraph Block -->
<th:block th:if="${block.type == 'paragraph'}">
<div class="container">
<p th:utext="${block.data.text}"></p>
</div>
</th:block>
<!-- 4. List Block -->
<th:block th:if="${block.type == 'list'}">
<div class="container">
<ul th:if="${block.data.style == 'unordered'}">
<li th:each="item : ${block.data.items}" th:utext="${item}"></li>
</ul>
<ol th:if="${block.data.style == 'ordered'}">
<li th:each="item : ${block.data.items}" th:utext="${item}"></li>
</ol>
</div>
</th:block>
<!-- 5. Image Block -->
<th:block th:if="${block.type == 'image'}">
<div class="container text-center my-4">
<img th:src="${block.data.file.url}" class="img-fluid" th:alt="${block.data.caption}" />
<p class="text-muted small mt-1" th:if="${block.data.caption}" th:utext="${block.data.caption}"></p>
</div>
</th:block>
<!-- 6. Quote Block -->
<th:block th:if="${block.type == 'quote'}">
<div class="container my-4">
<blockquote class="blockquote">
<p class="mb-0" th:utext="${block.data.text}"></p>
<footer class="blockquote-footer" th:if="${block.data.caption}" th:utext="${block.data.caption}"></footer>
</blockquote>
</div>
</th:block>
<!-- 7. Delimiter Block -->
<th:block th:if="${block.type == 'delimiter'}">
<div class="container text-center my-4">
<span style="font-size: 24px; letter-spacing: 10px; color: #ccc;">***</span>
</div>
</th:block>
<!-- 8. Table Block -->
<th:block th:if="${block.type == 'table'}">
<div class="container my-4">
<table class="table table-bordered">
<tbody>
<tr th:each="row, rowStat : ${block.data.content}">
<!-- If withHeadings is true, make first row <th> -->
<th:block th:if="${block.data.withHeadings == true and rowStat.index == 0}">
<th th:each="cell : ${row}" th:utext="${cell}"></th>
</th:block>
<th:block th:unless="${block.data.withHeadings == true and rowStat.index == 0}">
<td th:each="cell : ${row}" th:utext="${cell}"></td>
</th:block>
</tr>
</tbody>
</table>
</div>
</th:block>
</th:block>
</div>
</body>
</html>
@@ -0,0 +1,5 @@
<div style="background-color: var(--color-primary-light); border-left: 4px solid var(--color-primary); padding: var(--spacing-md); margin: var(--spacing-md) 0; border-radius: var(--radius-md); font-family: var(--font-family-base);">
<h2 style="margin-top: 0; color: var(--color-primary); font-family: var(--font-family-heading);">This is a predefined Snippet!</h2>
<p style="margin-bottom: 0; color: var(--color-text-main);">You have successfully loaded the <strong>test_banner</strong> HTML snippet via the Editor.js custom block.</p>
<button style="margin-top: 15px; background: var(--color-primary); color: var(--color-text-light); border: none; padding: 10px 20px; border-radius: var(--radius-md); cursor: pointer;">Action Button</button>
</div>