feat: implement dynamic component template engine with data source integration and management UI

This commit is contained in:
2026-07-13 21:24:04 +07:00
parent 849c79cc0f
commit 5513448738
23 changed files with 2588 additions and 180 deletions
@@ -201,8 +201,8 @@
<div id="collapseTwo" class="collapse" aria-labelledby="headingTwo" data-parent="#accordionSidebar">
<div class="bg-white py-2 collapse-inner rounded">
<h6 class="collapse-header">Custom Components:</h6>
<a class="collapse-item" href="#">Buttons</a>
<a class="collapse-item" href="#">Cards</a>
<a class="collapse-item" th:href="@{/manage/components}">Component Templates</a>
<a class="collapse-item" th:href="@{/manage/snippets}">HTML Snippets</a>
</div>
</div>
</li>
@@ -0,0 +1,222 @@
<!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} ? 'New Component Template' : 'Edit Component Template'">Edit Component Template</title>
<style>
#monaco-container {
width: 100%;
height: 500px;
border: 1px solid #d1d3e2;
border-radius: 0.35rem;
overflow: hidden;
}
.placeholder-tag {
display: inline-block;
padding: 2px 8px;
margin: 2px;
border-radius: 3px;
background: #e3f2fd;
border: 1px solid #90caf9;
color: #1565c0;
cursor: pointer;
font-family: monospace;
font-size: 0.85rem;
transition: all 0.2s;
}
.placeholder-tag:hover {
background: #bbdefb;
border-color: #64b5f6;
}
.placeholder-section {
margin-bottom: 0.75rem;
}
.placeholder-section h6 {
font-size: 0.75rem;
text-transform: uppercase;
color: #858796;
margin-bottom: 0.5rem;
}
</style>
</head>
<body>
<section 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} ? 'New Component Template' : 'Edit Component Template'">Component Template</h1>
<a th:href="@{/manage/components}" 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 Templates
</a>
</div>
<!-- Alerts -->
<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>
<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>
<form th:action="${isNew} ? @{/manage/components/create} : @{/manage/components/{id}(id=${template.id})}" method="post" id="templateForm">
<div class="row">
<!-- Left Column: Properties -->
<div class="col-lg-4">
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Template Properties</h6>
</div>
<div class="card-body">
<div class="form-group">
<label for="name">Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="name" name="name" th:value="${template.name}" required placeholder="e.g. CTA Buttons">
</div>
<div class="form-group">
<label for="slug">Slug <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="slug" name="slug" th:value="${template.slug}" required placeholder="e.g. cta-buttons"
pattern="[a-zA-Z0-9_-]+" title="Only letters, numbers, hyphens, and underscores">
<small class="form-text text-muted">Used in shortcode: <code>[component:<span id="slugPreview">slug</span>]</code></small>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea class="form-control" id="description" name="description" rows="3" th:text="${template.description}" placeholder="Brief description of what this template renders"></textarea>
</div>
<div class="form-group">
<div class="custom-control custom-switch">
<input type="checkbox" class="custom-control-input" id="active" name="active" th:checked="${template.active}">
<label class="custom-control-label" for="active">Active</label>
</div>
</div>
</div>
</div>
<!-- Available Placeholders -->
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Available Placeholders</h6>
</div>
<div class="card-body">
<div class="placeholder-section">
<h6>Data Source Fields</h6>
<span class="placeholder-tag" onclick="insertPlaceholder('{{name}}')">{{name}}</span>
</div>
<div class="placeholder-section">
<h6>Loop (repeats for each item)</h6>
<span class="placeholder-tag" onclick="insertPlaceholder('{{#each items}}\n\n{{/each}}')">{{#each items}}</span>
</div>
<div class="placeholder-section">
<h6>Item Fields (inside loop)</h6>
<span class="placeholder-tag" onclick="insertPlaceholder('{{label}}')">{{label}}</span>
<span class="placeholder-tag" onclick="insertPlaceholder('{{url}}')">{{url}}</span>
<span class="placeholder-tag" onclick="insertPlaceholder('{{index}}')">{{index}}</span>
</div>
<hr>
<div class="small text-muted">
<strong>Tip:</strong> Click a placeholder tag to insert it at the cursor position in the editor.
</div>
</div>
</div>
<button type="submit" class="btn btn-primary btn-block mb-4">
<i class="fas fa-save mr-1"></i>
<span th:text="${isNew} ? 'Create Template' : 'Update Template'">Save</span>
</button>
</div>
<!-- Right Column: HTML Template Editor -->
<div class="col-lg-8">
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">HTML Template</h6>
</div>
<div class="card-body p-0">
<textarea id="htmlTemplate" name="htmlTemplate" style="display:none;" th:text="${template.htmlTemplate}"></textarea>
<div id="monaco-container"></div>
</div>
</div>
<!-- Usage Example -->
<div class="card border-left-success shadow mb-4" th:unless="${isNew}">
<div class="card-body">
<div class="text-xs font-weight-bold text-success text-uppercase mb-2">Usage in Snippets</div>
<div class="mb-2">
<strong>Static (no data):</strong><br>
<code>[component:<span th:text="${template.slug}">slug</span>]</code>
</div>
<div class="mb-2">
<strong>With Menu data:</strong><br>
<code>[component:<span th:text="${template.slug}">slug</span> data-source="menu:LOCATION"]</code>
</div>
<div class="small text-muted mt-2">
Replace <code>LOCATION</code> with the menu location code (e.g. CTA, INFO, PRIMARY, FOOTER).
</div>
</div>
</div>
</div>
</div>
</form>
</section>
<section layout:fragment="scripts">
<!-- Monaco Editor -->
<script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs/loader.js"></script>
<script th:inline="javascript">
var monacoEditor = null;
require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs' } });
require(['vs/editor/editor.main'], function () {
var textArea = document.getElementById('htmlTemplate');
var initialValue = textArea.value || '';
monacoEditor = monaco.editor.create(document.getElementById('monaco-container'), {
value: initialValue,
language: 'html',
theme: 'vs-dark',
minimap: { enabled: false },
automaticLayout: true,
wordWrap: 'on',
fontSize: 14,
lineNumbers: 'on',
renderWhitespace: 'selection',
scrollBeyondLastLine: false,
tabSize: 2
});
// Sync editor content to textarea on form submit
document.getElementById('templateForm').addEventListener('submit', function () {
textArea.value = monacoEditor.getValue();
});
});
// Insert placeholder at cursor position in Monaco
function insertPlaceholder(text) {
if (monacoEditor) {
var selection = monacoEditor.getSelection();
monacoEditor.executeEdits('placeholder', [{
range: selection,
text: text,
forceMoveMarkers: true
}]);
monacoEditor.focus();
}
}
// Update slug preview
document.getElementById('slug').addEventListener('input', function () {
document.getElementById('slugPreview').textContent = this.value || 'slug';
});
// Initialize preview
document.addEventListener('DOMContentLoaded', function () {
var slugInput = document.getElementById('slug');
if (slugInput.value) {
document.getElementById('slugPreview').textContent = slugInput.value;
}
});
</script>
</section>
</body>
</html>
@@ -0,0 +1,104 @@
<!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>Component Templates</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">Component Templates</h1>
<a th:href="@{/manage/components/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 Template
</a>
</div>
<!-- Alerts -->
<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>
<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>
<!-- Info Card -->
<div class="card border-left-info shadow mb-4">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-info text-uppercase mb-1">How to use</div>
<div class="text-gray-800">
Use shortcode <code>[component:SLUG data-source="menu:LOCATION"]</code> inside any Snippet to render a Component Template with dynamic data.
</div>
</div>
<div class="col-auto">
<i class="fas fa-puzzle-piece fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
<div class="card shadow mb-4">
<div class="card-body">
<div class="table-responsive">
<table class="table table-bordered" width="100%" cellspacing="0">
<thead>
<tr>
<th>Name</th>
<th>Slug</th>
<th>Description</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr th:each="t : ${templates}">
<td th:text="${t.name}">Name</td>
<td>
<code th:text="${t.slug}">slug</code>
<div class="small text-muted mt-1">
Shortcode: <code>[component:<span th:text="${t.slug}">slug</span>]</code>
</div>
</td>
<td>
<small th:text="${t.description}" class="text-muted">Description</small>
</td>
<td>
<span class="badge badge-success" th:if="${t.active}">Active</span>
<span class="badge badge-secondary" th:unless="${t.active}">Inactive</span>
</td>
<td>
<a th:href="@{/manage/components/{id}/edit(id=${t.id})}" class="btn btn-sm btn-outline-info mr-1" title="Edit">
<i class="fas fa-edit"></i>
</a>
<form th:action="@{/manage/components/{id}/duplicate(id=${t.id})}" method="post" style="display:inline;">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<button type="submit" class="btn btn-sm btn-outline-warning mr-1" title="Duplicate">
<i class="fas fa-copy"></i>
</button>
</form>
<form th:action="@{/manage/components/{id}/delete(id=${t.id})}" method="post" style="display:inline;" onsubmit="return confirm('Are you sure you want to delete this component template?');">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete">
<i class="fas fa-trash"></i>
</button>
</form>
</td>
</tr>
<tr th:if="${#lists.isEmpty(templates)}">
<td colspan="5" class="text-center">No Component Templates found.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</body>
</html>
@@ -1,46 +0,0 @@
<div data-component-id="umass_base:block-multiple-ctas" data-background="solid" data-layout-variant="default">
<div class="container" th:if="${ctaMenu != null and not #lists.isEmpty(ctaMenu.items)}">
<div class="text-container">
<div data-component-id="umass_base:section-title" class="f--section-title">
<h2><span th:utext="${ctaMenu.name}">TÌM HIỂU CHUYÊN KHOA</span></h2>
</div>
</div>
<div class="links-container">
<div class="f--field f--button" th:each="item : ${ctaMenu.items}">
<a th:href="${item.url}" class="button-text-link button-context-light " th:aria-label="${item.label}" target="_self" data-component-id="umass_base:button">
<span class="button-text" th:text="${item.label}"> Apply </span>
</a>
</div>
</div>
</div>
<!-- Fallback if menu is empty, show default CTA block -->
<div class="container" th:unless="${ctaMenu != null and not #lists.isEmpty(ctaMenu.items)}">
<div class="text-container">
<div data-component-id="umass_base:section-title" class="f--section-title">
<h2>TÌM HIỂU<span class="highlight">CHUYÊN KHOA</span></h2>
</div>
</div>
<div class="links-container">
<div class="f--field f--button">
<a href="https://www.umass.edu/admissions/apply" class="button-text-link button-context-light " aria-label="Apply" target="_self" data-component-id="umass_base:button">
<span class="button-text"> Apply </span>
</a>
</div>
<div class="f--field f--button">
<a href="https://www.umass.edu/gateway/academics/explore-our-programs" class="button-text-link button-context-light " aria-label="Majors &amp; Minors" target="_self" data-component-id="umass_base:button">
<span class="button-text"> Majors &amp; Minors </span>
</a>
</div>
<div class="f--field f--button">
<a href="https://www.umass.edu/admissions/undergraduate-admissions/costs-aid" class="button-text-link button-context-light " aria-label="Tuition &amp; Costs" target="_self" data-component-id="umass_base:button">
<span class="button-text"> Tuition &amp; Costs </span>
</a>
</div>
<div class="f--field f--button">
<a href="https://www.umass.edu/admissions/visit" class="button-text-link button-context-light " aria-label="Take a Tour" target="_self" data-component-id="umass_base:button">
<span class="button-text"> Take a Tour </span>
</a>
</div>
</div>
</div>
</div>