feat: enhance page block system with custom styling support and add TinyMCE rich editor integration

This commit is contained in:
2026-08-03 16:00:20 +07:00
parent 0f4a7dd5b5
commit 48a2cd02f1
107 changed files with 10896 additions and 822 deletions
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
(function(){"use strict";try{if(typeof document<"u"){var e=document.createElement("style");e.appendChild(document.createTextNode(".ce-code__textarea{min-height:200px;font-family:Menlo,Monaco,Consolas,Courier New,monospace;color:#41314e;line-height:1.6em;font-size:12px;background:#f8f7fa;border:1px solid #f1f1f4;box-shadow:none;white-space:pre;word-wrap:normal;overflow-x:auto;resize:vertical}")),document.head.appendChild(e)}}catch(o){console.error("vite-plugin-css-injected-by-js",o)}})();
(function(i,o){typeof exports=="object"&&typeof module<"u"?module.exports=o():typeof define=="function"&&define.amd?define(o):(i=typeof globalThis<"u"?globalThis:i||self,i.CodeTool=o())})(this,function(){"use strict";function i(h,t){let s="";for(;s!==`
`&&t>0;)t=t-1,s=h.substr(t,1);return s===`
`&&(t+=1),t}const o='<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 8L5 12L9 16"/><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 8L19 12L15 16"/></svg>';/**
* CodeTool for Editor.js
* @version 2.0.0
* @license MIT
*/class l{static get isReadOnlySupported(){return!0}static get enableLineBreaks(){return!0}constructor({data:t,config:e,api:s,readOnly:r}){this.api=s,this.readOnly=r,this.placeholder=this.api.i18n.t(e.placeholder||l.DEFAULT_PLACEHOLDER),this.CSS={baseClass:this.api.styles.block,input:this.api.styles.input,wrapper:"ce-code",textarea:"ce-code__textarea"},this.nodes={holder:null,textarea:null},this.data={code:t.code??""},this.nodes.holder=this.drawView()}render(){return this.nodes.holder}save(t){return{code:t.querySelector("textarea").value}}onPaste(t){const e=t.detail;if("data"in e){const s=e.data;this.data={code:s||""}}}get data(){return this._data}set data(t){this._data=t,this.nodes.textarea&&(this.nodes.textarea.value=t.code)}static get toolbox(){return{icon:o,title:"Code"}}static get DEFAULT_PLACEHOLDER(){return"Enter a code"}static get pasteConfig(){return{tags:["pre"]}}static get sanitize(){return{code:!0}}tabHandler(t){t.stopPropagation(),t.preventDefault();const e=t.target,s=t.shiftKey,r=e.selectionStart,a=e.value,n=" ";let d;if(!s)d=r+n.length,e.value=a.substring(0,r)+n+a.substring(r);else{const c=i(a,r);if(a.substr(c,n.length)!==n)return;e.value=a.substring(0,c)+a.substring(c+n.length),d=r-n.length}e.setSelectionRange(d,d)}drawView(){const t=document.createElement("div"),e=document.createElement("textarea");return t.classList.add(this.CSS.baseClass,this.CSS.wrapper),e.classList.add(this.CSS.textarea,this.CSS.input),e.value=this.data.code,e.placeholder=this.placeholder,this.readOnly&&(e.disabled=!0),t.appendChild(e),e.addEventListener("keydown",s=>{switch(s.code){case"Tab":this.tabHandler(s);break}}),this.nodes.textarea=e,t}}return l});
@@ -0,0 +1,560 @@
/* cyrillic-ext */
@font-face {
font-family: 'Nunito';
font-style: italic;
font-weight: 200;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXX3I6Li01BKofIMNaORs71cA.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Nunito';
font-style: italic;
font-weight: 200;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXX3I6Li01BKofIMNaHRs71cA.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* vietnamese */
@font-face {
font-family: 'Nunito';
font-style: italic;
font-weight: 200;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXX3I6Li01BKofIMNaMRs71cA.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Nunito';
font-style: italic;
font-weight: 200;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXX3I6Li01BKofIMNaNRs71cA.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Nunito';
font-style: italic;
font-weight: 200;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXX3I6Li01BKofIMNaDRs4.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Nunito';
font-style: italic;
font-weight: 300;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXX3I6Li01BKofIMNaORs71cA.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Nunito';
font-style: italic;
font-weight: 300;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXX3I6Li01BKofIMNaHRs71cA.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* vietnamese */
@font-face {
font-family: 'Nunito';
font-style: italic;
font-weight: 300;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXX3I6Li01BKofIMNaMRs71cA.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Nunito';
font-style: italic;
font-weight: 300;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXX3I6Li01BKofIMNaNRs71cA.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Nunito';
font-style: italic;
font-weight: 300;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXX3I6Li01BKofIMNaDRs4.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Nunito';
font-style: italic;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXX3I6Li01BKofIMNaORs71cA.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Nunito';
font-style: italic;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXX3I6Li01BKofIMNaHRs71cA.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* vietnamese */
@font-face {
font-family: 'Nunito';
font-style: italic;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXX3I6Li01BKofIMNaMRs71cA.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Nunito';
font-style: italic;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXX3I6Li01BKofIMNaNRs71cA.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Nunito';
font-style: italic;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXX3I6Li01BKofIMNaDRs4.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Nunito';
font-style: italic;
font-weight: 600;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXX3I6Li01BKofIMNaORs71cA.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Nunito';
font-style: italic;
font-weight: 600;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXX3I6Li01BKofIMNaHRs71cA.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* vietnamese */
@font-face {
font-family: 'Nunito';
font-style: italic;
font-weight: 600;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXX3I6Li01BKofIMNaMRs71cA.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Nunito';
font-style: italic;
font-weight: 600;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXX3I6Li01BKofIMNaNRs71cA.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Nunito';
font-style: italic;
font-weight: 600;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXX3I6Li01BKofIMNaDRs4.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Nunito';
font-style: italic;
font-weight: 700;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXX3I6Li01BKofIMNaORs71cA.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Nunito';
font-style: italic;
font-weight: 700;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXX3I6Li01BKofIMNaHRs71cA.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* vietnamese */
@font-face {
font-family: 'Nunito';
font-style: italic;
font-weight: 700;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXX3I6Li01BKofIMNaMRs71cA.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Nunito';
font-style: italic;
font-weight: 700;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXX3I6Li01BKofIMNaNRs71cA.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Nunito';
font-style: italic;
font-weight: 700;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXX3I6Li01BKofIMNaDRs4.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Nunito';
font-style: italic;
font-weight: 800;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXX3I6Li01BKofIMNaORs71cA.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Nunito';
font-style: italic;
font-weight: 800;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXX3I6Li01BKofIMNaHRs71cA.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* vietnamese */
@font-face {
font-family: 'Nunito';
font-style: italic;
font-weight: 800;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXX3I6Li01BKofIMNaMRs71cA.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Nunito';
font-style: italic;
font-weight: 800;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXX3I6Li01BKofIMNaNRs71cA.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Nunito';
font-style: italic;
font-weight: 800;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXX3I6Li01BKofIMNaDRs4.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Nunito';
font-style: italic;
font-weight: 900;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXX3I6Li01BKofIMNaORs71cA.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Nunito';
font-style: italic;
font-weight: 900;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXX3I6Li01BKofIMNaHRs71cA.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* vietnamese */
@font-face {
font-family: 'Nunito';
font-style: italic;
font-weight: 900;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXX3I6Li01BKofIMNaMRs71cA.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Nunito';
font-style: italic;
font-weight: 900;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXX3I6Li01BKofIMNaNRs71cA.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Nunito';
font-style: italic;
font-weight: 900;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXX3I6Li01BKofIMNaDRs4.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Nunito';
font-style: normal;
font-weight: 200;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXV3I6Li01BKofIOOaBXso.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Nunito';
font-style: normal;
font-weight: 200;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXV3I6Li01BKofIMeaBXso.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* vietnamese */
@font-face {
font-family: 'Nunito';
font-style: normal;
font-weight: 200;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXV3I6Li01BKofIOuaBXso.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Nunito';
font-style: normal;
font-weight: 200;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXV3I6Li01BKofIO-aBXso.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Nunito';
font-style: normal;
font-weight: 200;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXV3I6Li01BKofINeaB.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Nunito';
font-style: normal;
font-weight: 300;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXV3I6Li01BKofIOOaBXso.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Nunito';
font-style: normal;
font-weight: 300;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXV3I6Li01BKofIMeaBXso.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* vietnamese */
@font-face {
font-family: 'Nunito';
font-style: normal;
font-weight: 300;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXV3I6Li01BKofIOuaBXso.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Nunito';
font-style: normal;
font-weight: 300;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXV3I6Li01BKofIO-aBXso.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Nunito';
font-style: normal;
font-weight: 300;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXV3I6Li01BKofINeaB.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Nunito';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXV3I6Li01BKofIOOaBXso.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Nunito';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXV3I6Li01BKofIMeaBXso.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* vietnamese */
@font-face {
font-family: 'Nunito';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXV3I6Li01BKofIOuaBXso.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Nunito';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXV3I6Li01BKofIO-aBXso.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Nunito';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXV3I6Li01BKofINeaB.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Nunito';
font-style: normal;
font-weight: 600;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXV3I6Li01BKofIOOaBXso.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Nunito';
font-style: normal;
font-weight: 600;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXV3I6Li01BKofIMeaBXso.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* vietnamese */
@font-face {
font-family: 'Nunito';
font-style: normal;
font-weight: 600;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXV3I6Li01BKofIOuaBXso.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Nunito';
font-style: normal;
font-weight: 600;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXV3I6Li01BKofIO-aBXso.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Nunito';
font-style: normal;
font-weight: 600;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXV3I6Li01BKofINeaB.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Nunito';
font-style: normal;
font-weight: 700;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXV3I6Li01BKofIOOaBXso.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Nunito';
font-style: normal;
font-weight: 700;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXV3I6Li01BKofIMeaBXso.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* vietnamese */
@font-face {
font-family: 'Nunito';
font-style: normal;
font-weight: 700;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXV3I6Li01BKofIOuaBXso.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Nunito';
font-style: normal;
font-weight: 700;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXV3I6Li01BKofIO-aBXso.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Nunito';
font-style: normal;
font-weight: 700;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXV3I6Li01BKofINeaB.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Nunito';
font-style: normal;
font-weight: 800;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXV3I6Li01BKofIOOaBXso.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Nunito';
font-style: normal;
font-weight: 800;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXV3I6Li01BKofIMeaBXso.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* vietnamese */
@font-face {
font-family: 'Nunito';
font-style: normal;
font-weight: 800;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXV3I6Li01BKofIOuaBXso.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Nunito';
font-style: normal;
font-weight: 800;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXV3I6Li01BKofIO-aBXso.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Nunito';
font-style: normal;
font-weight: 800;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXV3I6Li01BKofINeaB.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Nunito';
font-style: normal;
font-weight: 900;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXV3I6Li01BKofIOOaBXso.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Nunito';
font-style: normal;
font-weight: 900;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXV3I6Li01BKofIMeaBXso.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* vietnamese */
@font-face {
font-family: 'Nunito';
font-style: normal;
font-weight: 900;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXV3I6Li01BKofIOuaBXso.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Nunito';
font-style: normal;
font-weight: 900;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXV3I6Li01BKofIO-aBXso.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Nunito';
font-style: normal;
font-weight: 900;
src: url(https://fonts.gstatic.com/s/nunito/v32/XRXV3I6Li01BKofINeaB.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@@ -0,0 +1,9 @@
(function(){"use strict";try{if(typeof document<"u"){var e=document.createElement("style");e.appendChild(document.createTextNode('.ce-delimiter{line-height:1.6em;width:100%;text-align:center}.ce-delimiter:before{display:inline-block;content:"***";font-size:30px;line-height:65px;height:30px;letter-spacing:.2em}')),document.head.appendChild(e)}}catch(t){console.error("vite-plugin-css-injected-by-js",t)}})();
(function(t,i){typeof exports=="object"&&typeof module<"u"?module.exports=i():typeof define=="function"&&define.amd?define(i):(t=typeof globalThis<"u"?globalThis:t||self,t.Delimiter=i())})(this,function(){"use strict";const t='<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><line x1="6" x2="10" y1="12" y2="12" stroke="currentColor" stroke-linecap="round" stroke-width="2"/><line x1="14" x2="18" y1="12" y2="12" stroke="currentColor" stroke-linecap="round" stroke-width="2"/></svg>';/**
* Delimiter Block for the Editor.js.
*
* @author CodeX (team@ifmo.su)
* @copyright CodeX 2018
* @license The MIT License (MIT)
* @version 2.0.0
*/class i{static get isReadOnlySupported(){return!0}static get contentless(){return!0}constructor({data:e,config:s,api:r}){this.api=r,this._CSS={block:this.api.styles.block,wrapper:"ce-delimiter"},this._element=this.drawView(),this.data=e}drawView(){let e=document.createElement("div");return e.classList.add(this._CSS.wrapper,this._CSS.block),e}render(){return this._element}save(e){return{}}static get toolbox(){return{icon:t,title:"Delimiter"}}static get pasteConfig(){return{tags:["HR"]}}onPaste(e){this.data={}}}return i});
@@ -0,0 +1,777 @@
/**
* 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 || {};
/**
* Global Media Library Modal Handler
* Callable from any Editor.js plugin via window.openSISMediaModal(callback)
*/
window.openSISMediaModal = function (callback) {
if (window.SISMediaPicker && typeof window.SISMediaPicker.open === 'function') {
window.SISMediaPicker.open(callback);
} else {
window.sisMediaSelectCallback = callback;
var modalEl = document.getElementById('sisMediaLibraryModal');
if (modalEl && typeof $ !== 'undefined' && $.fn && $.fn.modal) {
$(modalEl).modal('show');
}
}
};
/**
* Dynamic Monaco Editor Script Loader Helper
*/
window.loadMonacoEditor = function () {
if (window.monaco) return Promise.resolve(window.monaco);
if (window._monacoLoadingPromise) return window._monacoLoadingPromise;
window._monacoLoadingPromise = new Promise((resolve, reject) => {
if (typeof require !== 'undefined' && require.config) {
require(['vs/editor/editor.main'], function () {
resolve(window.monaco);
});
return;
}
const script = document.createElement('script');
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.45.0/min/vs/loader.min.js';
script.onload = () => {
window.require.config({
paths: { vs: 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.45.0/min/vs' }
});
window.require(['vs/editor/editor.main'], () => {
resolve(window.monaco);
});
};
script.onerror = (err) => reject(err);
document.head.appendChild(script);
});
return window._monacoLoadingPromise;
};
class SISRawHtmlTool {
static get toolbox() {
return {
title: 'Raw HTML',
icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"></polyline><polyline points="8 6 2 12 8 18"></polyline></svg>'
};
}
constructor({ data, api, readOnly }) {
this.api = api;
this.readOnly = readOnly;
this.data = {
html: (data && data.html) ? data.html : '',
stretched: !!(data && data.stretched)
};
this.textarea = null;
this.editorInstance = null;
}
render() {
const container = document.createElement('div');
container.style.width = '100%';
container.className = 'ce-raw-html-wrapper mb-3';
const stretchWrapper = document.createElement('div');
stretchWrapper.className = 'custom-control custom-switch mb-2';
this.stretchCheck = document.createElement('input');
this.stretchCheck.type = 'checkbox';
this.stretchCheck.className = 'custom-control-input';
this.stretchCheck.id = 'raw_stretch_' + Math.random().toString(36).substring(7);
this.stretchCheck.checked = !!(this.data && this.data.stretched);
const stretchLabel = document.createElement('label');
stretchLabel.className = 'custom-control-label small font-weight-bold text-secondary';
stretchLabel.htmlFor = this.stretchCheck.id;
stretchLabel.innerHTML = '<i class="fas fa-arrows-alt-h"></i> Stretch block to Full Screen Width (Independent Breakout)';
this.stretchCheck.addEventListener('change', () => {
this.data.stretched = this.stretchCheck.checked;
});
stretchWrapper.appendChild(this.stretchCheck);
stretchWrapper.appendChild(stretchLabel);
container.appendChild(stretchWrapper);
// Header status bar
const headerBar = document.createElement('div');
headerBar.className = 'bg-dark text-white p-2 rounded-top d-flex justify-content-between align-items-center small font-weight-bold';
headerBar.innerHTML = '<span><i class="fab fa-html5 text-danger"></i> Raw HTML Code (Monaco IDE Editor)</span><span class="badge badge-info font-weight-normal">HTML5</span>';
container.appendChild(headerBar);
// Monaco Container
const editorContainer = document.createElement('div');
editorContainer.className = 'monaco-editor-box border border-dark rounded-bottom';
editorContainer.style.height = '350px';
editorContainer.style.width = '100%';
container.appendChild(editorContainer);
// Textarea fallback (hidden when Monaco is active)
this.textarea = document.createElement('textarea');
this.textarea.className = 'form-control d-none';
this.textarea.value = this.data.html || '';
container.appendChild(this.textarea);
if (this.readOnly) {
this.stretchCheck.disabled = true;
this.textarea.disabled = true;
}
// Initialize Monaco Editor
window.loadMonacoEditor().then((monaco) => {
if (!editorContainer || !document.body.contains(editorContainer)) return;
this.editorInstance = monaco.editor.create(editorContainer, {
value: this.data.html || '',
language: 'html',
theme: 'vs-dark',
automaticLayout: true,
minimap: { enabled: false },
fontSize: 13,
lineNumbers: 'on',
tabSize: 2,
scrollBeyondLastLine: false,
readOnly: !!this.readOnly
});
this.editorInstance.onDidChangeModelContent(() => {
this.data.html = this.editorInstance.getValue();
if (this.textarea) this.textarea.value = this.data.html;
});
}).catch((err) => {
console.warn('[SIS Raw HTML Tool] Monaco Editor CDN fallback to Textarea:', err);
this.textarea.classList.remove('d-none');
this.textarea.style.minHeight = '300px';
this.textarea.style.backgroundColor = '#1e1e24';
this.textarea.style.color = '#66d9ef';
this.textarea.style.fontFamily = 'monospace';
editorContainer.style.display = 'none';
this.textarea.addEventListener('input', () => {
this.data.html = this.textarea.value;
});
});
return container;
}
save() {
const htmlVal = this.editorInstance ? this.editorInstance.getValue() : (this.textarea ? this.textarea.value : (this.data.html || ''));
return {
html: htmlVal,
stretched: this.stretchCheck ? this.stretchCheck.checked : !!(this.data && this.data.stretched)
};
}
}
class SISAccordionTool {
static get toolbox() {
return {
title: 'Accordion / Collapse',
icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>'
};
}
constructor({ data, api, readOnly }) {
this.api = api;
this.readOnly = readOnly;
this.data = {
title: (data && data.title) ? data.title : '',
content: (data && data.content) ? data.content : '',
isOpen: !!(data && data.isOpen),
stretched: !!(data && data.stretched)
};
this.titleInput = null;
this.contentInput = null;
}
render() {
const container = document.createElement('div');
container.style.border = '1px solid #d1d3e2';
container.style.borderRadius = '6px';
container.style.padding = '12px';
container.style.background = '#ffffff';
container.style.marginBottom = '10px';
const headerLabel = document.createElement('label');
headerLabel.className = 'font-weight-bold text-primary small mb-1';
headerLabel.innerHTML = '<i class="fas fa-chevron-circle-down"></i> Accordion Title / Summary';
this.titleInput = document.createElement('input');
this.titleInput.type = 'text';
this.titleInput.className = 'form-control mb-2';
this.titleInput.placeholder = 'Enter accordion header/title...';
this.titleInput.value = this.data.title;
const bodyLabel = document.createElement('label');
bodyLabel.className = 'font-weight-bold text-secondary small mb-1';
bodyLabel.innerHTML = '<i class="fas fa-align-left"></i> Accordion Content / Details';
this.contentInput = document.createElement('textarea');
this.contentInput.className = 'form-control';
this.contentInput.style.minHeight = '100px';
this.contentInput.placeholder = 'Enter body content (HTML or text)...';
this.contentInput.value = this.data.content;
if (this.readOnly) {
this.titleInput.disabled = true;
this.contentInput.disabled = true;
}
this.titleInput.addEventListener('input', () => {
this.data.title = this.titleInput.value;
});
this.contentInput.addEventListener('input', () => {
this.data.content = this.contentInput.value;
});
container.appendChild(headerLabel);
container.appendChild(this.titleInput);
container.appendChild(bodyLabel);
container.appendChild(this.contentInput);
// Open by default toggle
const openWrapper = document.createElement('div');
openWrapper.className = 'custom-control custom-switch mt-2';
this.openCheck = document.createElement('input');
this.openCheck.type = 'checkbox';
this.openCheck.className = 'custom-control-input';
this.openCheck.id = 'acc_open_' + Math.random().toString(36).substring(7);
this.openCheck.checked = !!(this.data && this.data.isOpen);
const openLabel = document.createElement('label');
openLabel.className = 'custom-control-label small font-weight-bold text-primary';
openLabel.htmlFor = this.openCheck.id;
openLabel.innerHTML = '<i class="fas fa-folder-open"></i> Expand / Uncollapse by default after loading page';
this.openCheck.addEventListener('change', () => {
this.data.isOpen = this.openCheck.checked;
});
openWrapper.appendChild(this.openCheck);
openWrapper.appendChild(openLabel);
container.appendChild(openWrapper);
// Stretch toggle
const stretchWrapper = document.createElement('div');
stretchWrapper.className = 'custom-control custom-switch mt-1';
this.stretchCheck = document.createElement('input');
this.stretchCheck.type = 'checkbox';
this.stretchCheck.className = 'custom-control-input';
this.stretchCheck.id = 'acc_stretch_' + Math.random().toString(36).substring(7);
this.stretchCheck.checked = !!(this.data && this.data.stretched);
const stretchLabel = document.createElement('label');
stretchLabel.className = 'custom-control-label small font-weight-bold text-secondary';
stretchLabel.htmlFor = this.stretchCheck.id;
stretchLabel.innerHTML = '<i class="fas fa-arrows-alt-h"></i> Stretch block to Full Screen Width (Independent Breakout)';
this.stretchCheck.addEventListener('change', () => {
this.data.stretched = this.stretchCheck.checked;
});
stretchWrapper.appendChild(this.stretchCheck);
stretchWrapper.appendChild(stretchLabel);
container.appendChild(stretchWrapper);
return container;
}
save() {
return {
title: this.titleInput ? this.titleInput.value : this.data.title,
content: this.contentInput ? this.contentInput.value : this.data.content,
isOpen: this.openCheck ? this.openCheck.checked : !!(this.data && this.data.isOpen),
stretched: this.stretchCheck ? this.stretchCheck.checked : !!(this.data && this.data.stretched)
};
}
}
class SISYouTubeTool {
static get toolbox() {
return {
title: 'YouTube Video',
icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="red"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>'
};
}
constructor({ data, api, readOnly }) {
this.api = api;
this.readOnly = readOnly;
this.data = {
url: (data && data.url) ? data.url : '',
thumbnailUrl: (data && data.thumbnailUrl) ? data.thumbnailUrl : '',
caption: (data && data.caption) ? data.caption : '',
stretched: !!(data && data.stretched)
};
this.urlInput = null;
this.thumbnailInput = null;
this.captionInput = null;
this.previewContainer = null;
this.iframe = null;
this.isIframeActive = false;
}
extractVideoId(input) {
if (!input) return '';
var regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/;
var match = input.match(regExp);
if (match && match[2].length === 11) {
return match[2];
}
if (input.length === 11) {
return input;
}
return '';
}
getEmbedUrl(videoId) {
return videoId ? 'https://www.youtube.com/embed/' + videoId : '';
}
getThumbnailUrl(videoId) {
return videoId ? 'https://img.youtube.com/vi/' + videoId + '/hqdefault.jpg' : '';
}
render() {
const container = document.createElement('div');
container.style.border = '1px solid #e3e6f0';
container.style.borderRadius = '8px';
container.style.padding = '14px';
container.style.background = '#fff';
container.style.marginBottom = '12px';
const label = document.createElement('label');
label.className = 'font-weight-bold text-danger small mb-1';
label.innerHTML = '<i class="fab fa-youtube"></i> YouTube Video Link / URL / ID';
this.urlInput = document.createElement('input');
this.urlInput.type = 'text';
this.urlInput.className = 'form-control mb-2';
this.urlInput.placeholder = 'Paste YouTube URL (e.g. https://www.youtube.com/watch?v=sDE4ZZdNOOY)...';
this.urlInput.value = this.data.url;
// Custom Thumbnail Field
const thumbLabel = document.createElement('label');
thumbLabel.className = 'font-weight-bold text-secondary small mb-1';
thumbLabel.innerHTML = '<i class="fas fa-image"></i> Custom Thumbnail Image URL (Optional - Overrides YouTube Cover)';
this.thumbnailInput = document.createElement('input');
this.thumbnailInput.type = 'text';
this.thumbnailInput.className = 'form-control form-control-sm mb-2';
this.thumbnailInput.placeholder = 'Custom Thumbnail Image URL (leave blank to auto-use YouTube cover)...';
this.thumbnailInput.value = this.data.thumbnailUrl;
// Thumbnail Preview Container
this.previewContainer = document.createElement('div');
this.previewContainer.className = 'yt-thumbnail-preview';
this.previewContainer.style.position = 'relative';
this.previewContainer.style.paddingBottom = '56.25%';
this.previewContainer.style.height = '0';
this.previewContainer.style.overflow = 'hidden';
this.previewContainer.style.background = '#111 center/cover no-repeat';
this.previewContainer.style.borderRadius = '6px';
this.previewContainer.style.marginBottom = '8px';
this.previewContainer.style.cursor = 'pointer';
this.previewContainer.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)';
// Play Button Overlay
const playBtn = document.createElement('div');
playBtn.className = 'yt-play-button-overlay';
playBtn.style.position = 'absolute';
playBtn.style.top = '50%';
playBtn.style.left = '50%';
playBtn.style.transform = 'translate(-50%, -50%)';
playBtn.style.transition = 'transform 0.2s ease';
playBtn.innerHTML = '<svg width="68" height="48" viewBox="0 0 68 48"><path d="M66.52 7.74c-.78-2.93-2.49-5.41-5.42-6.19C55.79.13 34 0 34 0S12.21.13 6.9 1.55c-2.93.78-4.63 3.26-5.42 6.19C.06 13.05 0 24 0 24s.06 10.95 1.48 16.26c.78 2.93 2.49 5.41 5.42 6.19C12.21 47.87 34 48 34 48s21.79-.13 27.1-1.55c2.93-.78 4.64-3.26 5.42-6.19C67.94 34.95 68 24 68 24s-.06-10.95-1.48-16.26z" fill="#ff0000"/><path d="M45 24L27 14v20z" fill="#ffffff"/></svg>';
this.previewContainer.appendChild(playBtn);
// Update Thumbnail Image
const updatePreview = () => {
const customThumb = this.thumbnailInput.value.trim();
const videoId = this.extractVideoId(this.urlInput.value);
const thumbUrl = customThumb || this.getThumbnailUrl(videoId);
if (thumbUrl) {
this.previewContainer.style.backgroundImage = 'url("' + thumbUrl + '")';
playBtn.style.display = 'block';
} else {
this.previewContainer.style.backgroundImage = 'none';
this.previewContainer.style.backgroundColor = '#222';
}
};
// Click thumbnail to play live video
this.previewContainer.addEventListener('click', () => {
const videoId = this.extractVideoId(this.urlInput.value);
if (videoId && !this.isIframeActive) {
this.isIframeActive = true;
this.previewContainer.innerHTML = '';
const iframe = document.createElement('iframe');
iframe.style.position = 'absolute';
iframe.style.top = '0';
iframe.style.left = '0';
iframe.style.width = '100%';
iframe.style.height = '100%';
iframe.style.border = '0';
iframe.setAttribute('allow', 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share');
iframe.setAttribute('allowfullscreen', 'true');
iframe.src = this.getEmbedUrl(videoId) + '?autoplay=1';
this.previewContainer.appendChild(iframe);
}
});
updatePreview();
this.captionInput = document.createElement('input');
this.captionInput.type = 'text';
this.captionInput.className = 'form-control form-control-sm mb-2';
this.captionInput.placeholder = 'Video caption (optional)...';
this.captionInput.value = this.data.caption;
if (this.readOnly) {
this.urlInput.disabled = true;
this.thumbnailInput.disabled = true;
this.captionInput.disabled = true;
}
this.urlInput.addEventListener('input', () => {
const videoId = this.extractVideoId(this.urlInput.value);
this.data.url = this.getEmbedUrl(videoId);
this.isIframeActive = false;
this.previewContainer.innerHTML = '';
this.previewContainer.appendChild(playBtn);
updatePreview();
});
this.thumbnailInput.addEventListener('input', () => {
this.data.thumbnailUrl = this.thumbnailInput.value.trim();
updatePreview();
});
this.captionInput.addEventListener('input', () => {
this.data.caption = this.captionInput.value;
});
container.appendChild(label);
container.appendChild(this.urlInput);
container.appendChild(thumbLabel);
container.appendChild(this.thumbnailInput);
container.appendChild(this.previewContainer);
container.appendChild(this.captionInput);
const stretchWrapper = document.createElement('div');
stretchWrapper.className = 'custom-control custom-switch mt-2';
this.stretchCheck = document.createElement('input');
this.stretchCheck.type = 'checkbox';
this.stretchCheck.className = 'custom-control-input';
this.stretchCheck.id = 'yt_stretch_' + Math.random().toString(36).substring(7);
this.stretchCheck.checked = !!(this.data && this.data.stretched);
const stretchLabel = document.createElement('label');
stretchLabel.className = 'custom-control-label small font-weight-bold text-secondary';
stretchLabel.htmlFor = this.stretchCheck.id;
stretchLabel.innerHTML = '<i class="fas fa-arrows-alt-h"></i> Stretch block to Full Screen Width (Independent Breakout)';
this.stretchCheck.addEventListener('change', () => {
this.data.stretched = this.stretchCheck.checked;
});
stretchWrapper.appendChild(this.stretchCheck);
stretchWrapper.appendChild(stretchLabel);
container.appendChild(stretchWrapper);
return container;
}
save() {
const videoId = this.extractVideoId(this.urlInput ? this.urlInput.value : this.data.url);
return {
url: this.getEmbedUrl(videoId) || this.data.url,
thumbnailUrl: this.thumbnailInput ? this.thumbnailInput.value.trim() : (this.data.thumbnailUrl || ''),
caption: this.captionInput ? this.captionInput.value : this.data.caption,
stretched: this.stretchCheck ? this.stretchCheck.checked : !!(this.data && this.data.stretched)
};
}
}
// SISHeroBannerTool extracted to /js/manage/editor-plugins/hero-banner.js
/**
* 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
* @param {boolean} [skipSubmitHandler=false] - If true, prevents automatic form submission handling
* @returns {EditorJS} The editor instance
*/
function initSISEditor(holderId, hiddenInputId, initialData, skipSubmitHandler = false) {
if (typeof EditorJS === 'undefined') {
console.error('[SIS Editor] Editor.js library is not loaded!');
return null;
}
// === Register Built-in Tools (Defensive Check for Globals) ===
var builtInTools = {};
if (typeof Header !== 'undefined') {
builtInTools.header = {
class: Header,
inlineToolbar: ['link'],
tunes: ['textStyling'],
config: {
placeholder: 'Header text...',
levels: [1, 2, 3, 4, 5, 6],
defaultLevel: 2
}
};
}
builtInTools.paragraph = {
tunes: ['textStyling']
};
if (typeof NestedList !== 'undefined') {
builtInTools.list = {
class: NestedList,
inlineToolbar: true,
tunes: ['textStyling'],
config: { defaultStyle: 'unordered' }
};
} else if (typeof List !== 'undefined') {
builtInTools.list = {
class: List,
inlineToolbar: true,
tunes: ['textStyling'],
config: { defaultStyle: 'unordered' }
};
}
if (typeof Quote !== 'undefined') {
builtInTools.quote = {
class: Quote,
inlineToolbar: true,
tunes: ['textStyling'],
config: {
quotePlaceholder: 'Enter a quote',
captionPlaceholder: 'Quote\'s author'
}
};
}
if (typeof Delimiter !== 'undefined') builtInTools.delimiter = { class: Delimiter };
if (typeof Table !== 'undefined') {
builtInTools.table = {
class: Table,
inlineToolbar: true,
tunes: ['textStyling'],
config: { rows: 2, cols: 3 }
};
}
if (typeof CodeTool !== 'undefined') builtInTools.code = { class: CodeTool };
if (typeof Warning !== 'undefined') builtInTools.warning = { class: Warning, inlineToolbar: true, config: { titlePlaceholder: 'Title', messagePlaceholder: 'Message' } };
if (typeof Marker !== 'undefined') builtInTools.marker = { class: Marker };
if (typeof InlineCode !== 'undefined') builtInTools.inlineCode = { class: InlineCode };
if (typeof Underline !== 'undefined') builtInTools.underline = { class: Underline };
if (typeof ImageTool !== 'undefined') {
builtInTools.image = {
class: ImageTool,
config: {
endpoints: {
byFile: '/api/manage/media/upload',
byUrl: '/api/manage/media/fetchUrl'
},
uploader: {
uploadByUrl(url) {
return Promise.resolve({
success: 1,
file: { url: url }
});
},
selectFile() {
return new Promise((resolve, reject) => {
if (window.SISMediaPicker) {
SISMediaPicker.open((url, mediaObj, config) => {
if (url) {
resolve({
success: 1,
file: {
url: url,
style: config ? config.style : '',
cssClass: config ? config.cssClass : '',
alt: config ? config.alt : '',
customAttributes: config ? config.customAttributes : '',
aspectRatio: config ? config.aspectRatio : ''
}
});
} else {
reject('No image selected');
}
});
} else {
reject('SISMediaPicker not loaded');
}
});
}
},
field: 'file',
types: 'image/*'
}
};
window.SISEditorPlugins.image = builtInTools.image;
}
if (typeof AttachesTool !== 'undefined') {
builtInTools.attaches = {
class: AttachesTool,
config: { endpoint: '/api/manage/media/upload', field: 'file' }
};
}
// Custom SIS Tools (Always present)
builtInTools.raw = { class: SISRawHtmlTool };
builtInTools.accordion = { class: SISAccordionTool };
builtInTools.youtube = { class: SISYouTubeTool };
if (typeof SISHeroBannerTool !== 'undefined') {
builtInTools.hero = { class: SISHeroBannerTool };
}
if (typeof SISStickyNavTool !== 'undefined') {
builtInTools.stickyNav = { class: SISStickyNavTool };
}
if (typeof TextStylingTune !== 'undefined') {
builtInTools.textStyling = { class: TextStylingTune };
}
// === Merge built-in tools with any registered plugins ===
var allTools = Object.assign({}, builtInTools, window.SISEditorPlugins);
// Apply the textStyling tune to all block tools dynamically
for (var toolName in allTools) {
if (toolName !== 'textStyling') {
if (!allTools[toolName].tunes) {
allTools[toolName].tunes = ['textStyling'];
} else if (!allTools[toolName].tunes.includes('textStyling')) {
allTools[toolName].tunes.push('textStyling');
}
}
}
// === 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 hiddenInput = document.getElementById(hiddenInputId);
var form = hiddenInput ? hiddenInput.closest('form') : document.querySelector('form');
if (form && !skipSubmitHandler) {
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;
}
/**
* Setup IDE Code Editor styling and Tab indentation for custom CSS and JS textareas
*/
function setupCodeTextareas() {
var codeSelectors = '#customCss, #customJs, #postCustomCss, #postCustomJs, textarea[name="customCss"], textarea[name="customJs"]';
document.querySelectorAll(codeSelectors).forEach(function(textarea) {
if (!textarea || textarea.dataset.codeEditorInit) return;
textarea.dataset.codeEditorInit = 'true';
// Enable Tab key indentation (inserts 2 spaces)
textarea.addEventListener('keydown', function(e) {
if (e.key === 'Tab') {
e.preventDefault();
var start = this.selectionStart;
var end = this.selectionEnd;
this.value = this.value.substring(0, start) + ' ' + this.value.substring(end);
this.selectionStart = this.selectionEnd = start + 2;
}
});
});
}
// Auto-run code textareas setup on page load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', setupCodeTextareas);
} else {
setupCodeTextareas();
}
@@ -0,0 +1,903 @@
/**
* Flex block tool for Editor.js.
* Allows users to create highly custom Flexbox layouts with alignment, direction, gaps, and custom column items.
* Supports instantiating other registered plugins inside columns dynamically.
*/
class FlexTool {
static get toolbox() {
return {
title: 'Flex Layout',
icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"></rect><line x1="9" y1="3" x2="9" y2="21"></line><line x1="15" y1="3" x2="15" y2="21"></line></svg>'
};
}
constructor({ data, api, readOnly }) {
this.api = api;
this.readOnly = readOnly;
this.data = {
cols: parseInt(data.cols) || 2,
direction: data.direction || 'row',
justify: data.justify || 'start',
align: data.align || 'stretch',
gap: data.gap || '3', // standard Bootstrap gap level
globalClass: data.globalClass || '',
globalId: data.globalId || '',
globalAttributes: data.globalAttributes || data.attributes || ''
};
this.activeInstances = {};
// Load items content dynamically
for (let i = 1; i <= 12; i++) {
this.data[`type${i}`] = data[`type${i}`] || 'html';
this.data[`col${i}`] = data[`col${i}`] || '';
this.data[`imgUrl${i}`] = data[`imgUrl${i}`] || '';
this.data[`ytUrl${i}`] = data[`ytUrl${i}`] || '';
this.data[`accTitle${i}`] = data[`accTitle${i}`] || '';
this.data[`accContent${i}`] = data[`accContent${i}`] || '';
this.data[`class${i}`] = data[`class${i}`] || '';
this.data[`id${i}`] = data[`id${i}`] || '';
this.data[`attr${i}`] = data[`attr${i}`] || data[`attributes${i}`] || '';
this.data[`width${i}`] = parseInt(data[`width${i}`]) || 0; // 0 means auto
this.data[`caption${i}`] = data[`caption${i}`] || '';
}
this.wrapper = undefined;
}
render() {
this.wrapper = document.createElement('div');
this.wrapper.className = 'p-3 bg-light border rounded mb-3 ce-flex-tool-wrapper';
this.wrapper.style.fontFamily = 'inherit';
const title = document.createElement('div');
title.className = 'font-weight-bold text-primary small mb-3';
title.innerHTML = '<i class="fas fa-boxes"></i> Custom Flexbox Layout Builder';
this.wrapper.appendChild(title);
// Container Flex Settings Row
const settingsRow = document.createElement('div');
settingsRow.className = 'form-row mb-3 pb-3 border-bottom';
// 1. Direction
const dirCol = document.createElement('div');
dirCol.className = 'col-md-3 col-sm-6 mb-2';
dirCol.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Direction</label>';
const dirSelect = document.createElement('select');
dirSelect.className = 'custom-select custom-select-sm';
const dirOpts = [
{ val: 'row', lbl: 'Row' },
{ val: 'row-reverse', lbl: 'Row Reverse' },
{ val: 'column', lbl: 'Column' },
{ val: 'column-reverse', lbl: 'Column Reverse' }
];
dirOpts.forEach(o => {
const opt = document.createElement('option');
opt.value = o.val;
opt.innerText = o.lbl;
opt.selected = this.data.direction === o.val;
dirSelect.appendChild(opt);
});
dirSelect.addEventListener('change', (e) => this.data.direction = e.target.value);
dirCol.appendChild(dirSelect);
settingsRow.appendChild(dirCol);
// 2. Justify Content
const justifyCol = document.createElement('div');
justifyCol.className = 'col-md-3 col-sm-6 mb-2';
justifyCol.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Justify Content</label>';
const justifySelect = document.createElement('select');
justifySelect.className = 'custom-select custom-select-sm';
const justifyOpts = [
{ val: 'start', lbl: 'Start' },
{ val: 'center', lbl: 'Center' },
{ val: 'end', lbl: 'End' },
{ val: 'between', lbl: 'Space Between' },
{ val: 'around', lbl: 'Space Around' },
{ val: 'evenly', lbl: 'Space Evenly' }
];
justifyOpts.forEach(o => {
const opt = document.createElement('option');
opt.value = o.val;
opt.innerText = o.lbl;
opt.selected = this.data.justify === o.val;
justifySelect.appendChild(opt);
});
justifySelect.addEventListener('change', (e) => this.data.justify = e.target.value);
justifyCol.appendChild(justifySelect);
settingsRow.appendChild(justifyCol);
// 3. Align Items
const alignCol = document.createElement('div');
alignCol.className = 'col-md-3 col-sm-6 mb-2';
alignCol.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Align Items</label>';
const alignSelect = document.createElement('select');
alignSelect.className = 'custom-select custom-select-sm';
const alignOpts = [
{ val: 'stretch', lbl: 'Stretch' },
{ val: 'start', lbl: 'Start' },
{ val: 'center', lbl: 'Center' },
{ val: 'end', lbl: 'End' }
];
alignOpts.forEach(o => {
const opt = document.createElement('option');
opt.value = o.val;
opt.innerText = o.lbl;
opt.selected = this.data.align === o.val;
alignSelect.appendChild(opt);
});
alignSelect.addEventListener('change', (e) => this.data.align = e.target.value);
alignCol.appendChild(alignSelect);
settingsRow.appendChild(alignCol);
// 4. Gap Level
const gapCol = document.createElement('div');
gapCol.className = 'col-md-3 col-sm-6 mb-2';
gapCol.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Gap Level</label>';
const gapSelect = document.createElement('select');
gapSelect.className = 'custom-select custom-select-sm';
const gapOpts = [
{ val: '0', lbl: 'None' },
{ val: '1', lbl: 'Extra Small' },
{ val: '2', lbl: 'Small' },
{ val: '3', lbl: 'Medium' },
{ val: '4', lbl: 'Large' },
{ val: '5', lbl: 'Extra Large' }
];
gapOpts.forEach(o => {
const opt = document.createElement('option');
opt.value = o.val;
opt.innerText = o.lbl;
opt.selected = this.data.gap === o.val;
gapSelect.appendChild(opt);
});
gapSelect.addEventListener('change', (e) => this.data.gap = e.target.value);
gapCol.appendChild(gapSelect);
settingsRow.appendChild(gapCol);
this.wrapper.appendChild(settingsRow);
// Background Settings Row
const bgSettingsRow = document.createElement('div');
bgSettingsRow.className = 'form-row mb-3 pb-2 border-bottom';
// 1. Flex Items (Cols)
const colDiv = document.createElement('div');
colDiv.className = 'col-md-2 mb-2';
colDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Flex Items</label>';
const input = document.createElement('input');
input.type = 'number';
input.className = 'form-control form-control-sm';
input.min = '1';
input.max = '12';
input.value = this.data.cols;
if (this.readOnly) input.disabled = true;
input.addEventListener('input', (e) => {
let val = parseInt(e.target.value) || 2;
if (val < 1) val = 1;
if (val > 12) val = 12;
this.data.cols = val;
this._renderColumnInputs();
});
colDiv.appendChild(input);
bgSettingsRow.appendChild(colDiv);
// 2. Global CSS Class
const classDiv = document.createElement('div');
classDiv.className = 'col-md-3 mb-2';
classDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Flex CSS Class(es)</label>';
const classInput = document.createElement('input');
classInput.type = 'text';
classInput.className = 'form-control form-control-sm';
classInput.placeholder = 'e.g. my-flex-row';
classInput.value = this.data.globalClass || '';
if (this.readOnly) classInput.disabled = true;
classInput.addEventListener('input', (e) => this.data.globalClass = e.target.value.trim());
this.globalClassInput = classInput;
classDiv.appendChild(classInput);
bgSettingsRow.appendChild(classDiv);
// 3. Global HTML ID
const idDiv = document.createElement('div');
idDiv.className = 'col-md-3 mb-2';
idDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Flex HTML ID</label>';
const idInput = document.createElement('input');
idInput.type = 'text';
idInput.className = 'form-control form-control-sm';
idInput.placeholder = 'e.g. flex-section-1';
idInput.value = this.data.globalId || '';
if (this.readOnly) idInput.disabled = true;
idInput.addEventListener('input', (e) => this.data.globalId = e.target.value.trim());
this.globalIdInput = idInput;
idDiv.appendChild(idInput);
bgSettingsRow.appendChild(idDiv);
// 4. Global Custom Attributes
const attrDiv = document.createElement('div');
attrDiv.className = 'col-md-4 mb-2';
attrDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1"><i class="fas fa-sliders-h"></i> Custom Attributes</label>';
const attrInput = document.createElement('input');
attrInput.type = 'text';
attrInput.className = 'form-control form-control-sm';
attrInput.placeholder = 'e.g. data-aos="fade-up" style="background:#fff"';
attrInput.value = this.data.globalAttributes || '';
if (this.readOnly) attrInput.disabled = true;
attrInput.addEventListener('input', (e) => this.data.globalAttributes = e.target.value.trim());
this.globalAttrInput = attrInput;
attrDiv.appendChild(attrInput);
bgSettingsRow.appendChild(attrDiv);
this.wrapper.appendChild(bgSettingsRow);
// Columns inputs container
this.inputsContainer = document.createElement('div');
this.wrapper.appendChild(this.inputsContainer);
this._renderColumnInputs();
return this.wrapper;
}
_renderColumnInputs() {
this.inputsContainer.innerHTML = '';
const row = document.createElement('div');
row.className = 'row';
const count = this.data.cols;
const colWidthClass = count === 1 ? 'col-12' : (count === 2 ? 'col-md-6' : (count === 3 ? 'col-md-4' : (count === 4 ? 'col-md-3' : 'col-md')));
for (let i = 1; i <= count; i++) {
const colDiv = document.createElement('div');
colDiv.className = `${colWidthClass} mb-3 p-2 border rounded bg-white`;
colDiv.style.boxShadow = '0 1px 3px rgba(0,0,0,0.05)';
const headerRow = document.createElement('div');
headerRow.className = 'd-flex justify-content-between align-items-center mb-2 pb-1 border-bottom';
const label = document.createElement('label');
label.className = 'small font-weight-bold text-dark mb-0';
label.innerText = `Item ${i}`;
headerRow.appendChild(label);
const controlsWrapper = document.createElement('div');
controlsWrapper.className = 'd-flex align-items-center gap-1';
// Width Selector
const widthSelect = document.createElement('select');
widthSelect.className = 'custom-select custom-select-sm mr-1';
widthSelect.style.width = '75px';
if (this.readOnly) widthSelect.disabled = true;
const widthOpts = [
{ val: 0, lbl: 'Auto' },
{ val: 1, lbl: '1/12' },
{ val: 2, lbl: '2/12' },
{ val: 3, lbl: '3/12' },
{ val: 4, lbl: '4/12' },
{ val: 5, lbl: '5/12' },
{ val: 6, lbl: '6/12' },
{ val: 7, lbl: '7/12' },
{ val: 8, lbl: '8/12' },
{ val: 9, lbl: '9/12' },
{ val: 10, lbl: '10/12' },
{ val: 11, lbl: '11/12' },
{ val: 12, lbl: '12/12' }
];
widthOpts.forEach(o => {
const opt = document.createElement('option');
opt.value = o.val;
opt.innerText = o.lbl;
opt.selected = this.data[`width${i}`] === o.val;
widthSelect.appendChild(opt);
});
widthSelect.addEventListener('change', (e) => {
this.data[`width${i}`] = parseInt(e.target.value) || 0;
});
controlsWrapper.appendChild(widthSelect);
// Column Type Select (Built-ins + Registered plugins dynamically)
const typeSelect = document.createElement('select');
typeSelect.className = 'custom-select custom-select-sm';
typeSelect.style.width = '100px';
if (this.readOnly) typeSelect.disabled = true;
const types = [
{ value: 'html', label: 'HTML/Text' },
{ value: 'image', label: 'Image' },
{ value: 'youtube', label: 'YouTube' },
{ value: 'accordion', label: 'Accordion' }
];
if (window.SISEditorPlugins) {
Object.keys(window.SISEditorPlugins).forEach(key => {
// Allow nesting any registered plugins (no exclusions)
if (!types.some(t => t.value === key)) {
types.push({ value: key, label: `[Plugin] ${key}` });
}
});
}
types.forEach(t => {
const opt = document.createElement('option');
opt.value = t.value;
opt.innerText = t.label;
opt.selected = this.data[`type${i}`] === t.value;
typeSelect.appendChild(opt);
});
const contentDiv = document.createElement('div');
typeSelect.addEventListener('change', (e) => {
this.data[`type${i}`] = e.target.value;
this._renderTypeSpecificInput(i, contentDiv);
});
controlsWrapper.appendChild(typeSelect);
headerRow.appendChild(controlsWrapper);
colDiv.appendChild(headerRow);
colDiv.appendChild(contentDiv);
// --- Sub-Plugin (Sub-Content) Section ---
const subWrapper = document.createElement('div');
if (this.data[`subType${i}`] !== 'none') {
subWrapper.className = 'mt-3 pt-2 border-top';
}
const subHeader = document.createElement('div');
subHeader.className = 'd-flex justify-content-between align-items-center mb-2';
const subLabel = document.createElement('label');
subLabel.className = 'small font-weight-bold text-muted mb-0';
subLabel.style.fontSize = '10px';
subLabel.innerText = 'SUB-CONTENT / INJECT PLUGIN';
subHeader.appendChild(subLabel);
const subTypeSelect = document.createElement('select');
subTypeSelect.className = 'custom-select custom-select-sm';
subTypeSelect.style.width = '130px';
subTypeSelect.style.fontSize = '10px';
if (this.readOnly) subTypeSelect.disabled = true;
const subTypes = [
{ value: 'none', label: 'None' },
{ value: 'caption', label: 'Text Caption' },
{ value: 'html', label: 'HTML/Text' },
{ value: 'image', label: 'Image' },
{ value: 'youtube', label: 'YouTube' },
{ value: 'accordion', label: 'Accordion' }
];
if (window.SISEditorPlugins) {
Object.keys(window.SISEditorPlugins).forEach(key => {
if (!subTypes.some(t => t.value === key)) {
subTypes.push({ value: key, label: `[Plugin] ${key}` });
}
});
}
subTypes.forEach(t => {
const opt = document.createElement('option');
opt.value = t.value;
opt.innerText = t.label;
opt.selected = this.data[`subType${i}`] === t.value;
subTypeSelect.appendChild(opt);
});
subHeader.appendChild(subTypeSelect);
subWrapper.appendChild(subHeader);
const subContentDiv = document.createElement('div');
subWrapper.appendChild(subContentDiv);
colDiv.appendChild(subWrapper);
const renderSubInputs = () => {
subContentDiv.innerHTML = '';
const subType = this.data[`subType${i}`];
if (subType === 'none') {
subWrapper.className = '';
} else {
subWrapper.className = 'mt-3 pt-2 border-top';
}
if (subType === 'none') {
// Empty
} else if (subType === 'caption') {
const captionGroup = document.createElement('div');
captionGroup.className = 'form-group mb-2';
const captionInput = document.createElement('input');
captionInput.type = 'text';
captionInput.className = 'form-control form-control-sm';
captionInput.style.fontSize = '11px';
captionInput.placeholder = 'Optional column caption / text...';
captionInput.value = this.data[`caption${i}`] || '';
if (this.readOnly) captionInput.disabled = true;
captionInput.addEventListener('input', (e) => {
this.data[`caption${i}`] = e.target.value;
});
captionGroup.appendChild(captionInput);
subContentDiv.appendChild(captionGroup);
} else {
this._renderTypeSpecificInput(`sub_${i}`, subContentDiv, subType);
}
};
subTypeSelect.addEventListener('change', (e) => {
this.data[`subType${i}`] = e.target.value;
renderSubInputs();
});
row.appendChild(colDiv);
this._renderTypeSpecificInput(i, contentDiv);
renderSubInputs();
}
this.inputsContainer.appendChild(row);
}
_renderTypeSpecificInput(i, container, explicitType) {
container.innerHTML = '';
// Remove tracking of previous instance
if (this.activeInstances[i]) {
delete this.activeInstances[i];
}
const typeContainer = document.createElement('div');
typeContainer.className = 'mb-3';
container.appendChild(typeContainer);
const type = explicitType || this.data[`type${i}`] || 'html';
// Check if type is a dynamic editor plugin registered in window.SISEditorPlugins
if (window.SISEditorPlugins && window.SISEditorPlugins[type]) {
try {
const pluginEntry = window.SISEditorPlugins[type];
const pluginClass = pluginEntry.class;
const pluginConfig = pluginEntry.config || {};
let parsedData = {};
if (this.data[`col${i}`]) {
try {
parsedData = typeof this.data[`col${i}`] === 'string' ? JSON.parse(this.data[`col${i}`]) : this.data[`col${i}`];
} catch (e) {
parsedData = { html: this.data[`col${i}`] }; // fallback
}
}
const instance = new pluginClass({
data: parsedData,
api: this.api,
readOnly: this.readOnly,
config: pluginConfig,
block: {
id: 'nested-flex-' + i + '-' + Math.random().toString(36).substring(7),
name: type,
holder: typeContainer,
isEmpty: false,
selected: false,
stretched: false,
tunes: {}
}
});
this.activeInstances[i] = instance;
const element = instance.render();
typeContainer.appendChild(element);
// Fix for Editor.js Image plugin 404 errors (spinner hangs forever)
if (type === 'image') {
// Custom UI override for nested images
const injectCustomButtons = () => {
if (element.querySelector('.sis-custom-image-controls')) return;
const fileBtn = element.querySelector('.cdx-button');
if (fileBtn) {
fileBtn.style.display = 'none'; // Hide native button
}
const controls = document.createElement('div');
controls.className = 'sis-custom-image-controls mt-2 p-2 border rounded bg-light';
controls.style.display = 'flex';
controls.style.flexDirection = 'column';
controls.style.gap = '8px';
controls.innerHTML = `
<div class="text-muted small text-center mb-1"><i class="fas fa-cog"></i> Image Controls</div>
<button type="button" class="btn btn-outline-primary btn-sm w-100" id="btn-upload-img-${i}"><i class="fas fa-upload"></i> Upload Image</button>
<button type="button" class="btn btn-outline-info btn-sm w-100" id="btn-fetch-url-${i}"><i class="fas fa-link"></i> Fetch URL</button>
<button type="button" class="btn btn-outline-secondary btn-sm w-100" id="btn-media-lib-${i}"><i class="fas fa-images"></i> Media Library</button>
<button type="button" class="btn btn-outline-danger btn-sm w-100 mt-2" id="btn-clear-img-${i}"><i class="fas fa-trash"></i> Remove Image</button>
`;
const uploadBtn = controls.querySelector(`#btn-upload-img-${i}`);
if (uploadBtn && fileBtn) {
uploadBtn.addEventListener('click', (e) => {
e.preventDefault();
fileBtn.click();
});
}
const urlBtn = controls.querySelector(`#btn-fetch-url-${i}`);
if (urlBtn) {
urlBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const url = prompt('Enter Image URL:');
if (url && url.trim() !== '') {
this.data[`col${i}`] = {
file: { url: url.trim() },
caption: '',
withBorder: false,
withBackground: false,
stretched: false
};
this._renderTypeSpecificInput(i, container);
}
});
}
const mediaBtn = controls.querySelector(`#btn-media-lib-${i}`);
if (mediaBtn) {
mediaBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
if (window.SISMediaPicker) {
SISMediaPicker.open((url, mediaObj, config) => {
if (url) {
this.data[`col${i}`] = {
file: { url: url },
caption: (config && config.alt) || '',
withBorder: false,
withBackground: false,
stretched: false
};
if (config) {
this.data[`imgStyle${i}`] = config.style || '';
this.data[`imgClass${i}`] = config.cssClass || '';
this.data[`imgAlt${i}`] = config.alt || '';
this.data[`imgAttrs${i}`] = config.customAttributes || '';
this.data[`imgAspectRatio${i}`] = config.aspectRatio || '';
}
this._renderTypeSpecificInput(i, container);
}
});
} else {
alert('Media Library is not yet configured on this system.');
}
});
}
const clearBtn = controls.querySelector(`#btn-clear-img-${i}`);
if (clearBtn) {
clearBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
if (confirm('Are you sure you want to remove this image?')) {
this.data[`col${i}`] = '';
this._renderTypeSpecificInput(i, container);
}
});
}
element.appendChild(controls);
};
injectCustomButtons();
const handleImageError = () => {
element.classList.remove('image-tool--loading');
element.classList.add('image-tool--empty');
const preloader = element.querySelector('.image-tool__image-preloader');
if (preloader) preloader.style.display = 'none';
};
const observer = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => {
if (node.tagName === 'IMG') {
node.addEventListener('error', handleImageError);
}
});
});
});
observer.observe(element, { childList: true, subtree: true });
const existingImg = element.querySelector('img');
if (existingImg) existingImg.addEventListener('error', handleImageError);
}
} catch (err) {
console.error(`Failed to render plugin ${type} inside FlexTool:`, err);
typeContainer.innerHTML = `<div class="alert alert-danger small">Error loading plugin ${type}</div>`;
}
} else if (type === 'html') {
const textarea = document.createElement('textarea');
textarea.className = 'form-control form-control-sm';
textarea.style.minHeight = '140px';
textarea.style.fontSize = '12px';
textarea.placeholder = `HTML / Text for Item ${i}...`;
textarea.value = typeof this.data[`col${i}`] === 'object' ? JSON.stringify(this.data[`col${i}`]) : this.data[`col${i}`] || '';
if (this.readOnly) textarea.disabled = true;
textarea.addEventListener('input', (e) => {
this.data[`col${i}`] = e.target.value;
});
typeContainer.appendChild(textarea);
} else if (type === 'image') {
const inputGroup = document.createElement('div');
inputGroup.className = 'input-group input-group-sm mb-2';
const input = document.createElement('input');
input.type = 'text';
input.className = 'form-control form-control-sm';
input.placeholder = 'Paste image URL here...';
input.value = this.data[`imgUrl${i}`] || '';
if (this.readOnly) input.disabled = true;
const appendDiv = document.createElement('div');
appendDiv.className = 'input-group-append';
const chooseBtn = document.createElement('button');
chooseBtn.type = 'button';
chooseBtn.className = 'btn btn-outline-secondary';
chooseBtn.innerText = 'Choose';
if (this.readOnly) chooseBtn.disabled = true;
chooseBtn.addEventListener('click', () => {
if (window.SISMediaPicker) {
SISMediaPicker.open((selectedUrl, mediaObj, config) => {
input.value = selectedUrl;
this.data[`imgUrl${i}`] = selectedUrl;
if (config) {
this.data[`imgStyle${i}`] = config.style || '';
this.data[`imgClass${i}`] = config.cssClass || '';
this.data[`imgAlt${i}`] = config.alt || '';
this.data[`imgAttrs${i}`] = config.customAttributes || '';
this.data[`imgAspectRatio${i}`] = config.aspectRatio || '';
}
previewImg.src = selectedUrl;
previewBox.style.display = 'block';
});
} else {
alert('Media Picker modal helper is not loaded.');
}
});
const uploadBtn = document.createElement('button');
uploadBtn.type = 'button';
uploadBtn.className = 'btn btn-outline-primary';
uploadBtn.innerText = 'Upload';
if (this.readOnly) uploadBtn.disabled = true;
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'image/*';
fileInput.style.display = 'none';
uploadBtn.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('image', file);
uploadBtn.disabled = true;
uploadBtn.innerText = '...';
try {
const response = await fetch('/api/manage/media/upload', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success && result.file && result.file.url) {
const url = result.file.url;
input.value = url;
this.data[`imgUrl${i}`] = url;
previewImg.src = url;
previewBox.style.display = 'block';
} else {
alert('Upload failed: ' + (result.message || 'Unknown error'));
}
} catch (err) {
console.error(err);
alert('Upload error occurred.');
} finally {
uploadBtn.disabled = false;
uploadBtn.innerText = 'Upload';
}
});
appendDiv.appendChild(chooseBtn);
appendDiv.appendChild(uploadBtn);
inputGroup.appendChild(input);
inputGroup.appendChild(appendDiv);
typeContainer.appendChild(inputGroup);
typeContainer.appendChild(fileInput);
const previewBox = document.createElement('div');
previewBox.className = 'border rounded p-1 text-center bg-light';
previewBox.style.display = this.data[`imgUrl${i}`] ? 'block' : 'none';
previewBox.style.maxHeight = '150px';
previewBox.style.overflow = 'hidden';
const previewImg = document.createElement('img');
previewImg.src = this.data[`imgUrl${i}`] || '';
previewImg.style.maxWidth = '100%';
previewImg.style.maxHeight = '140px';
previewImg.style.objectFit = 'contain';
previewBox.appendChild(previewImg);
typeContainer.appendChild(previewBox);
input.addEventListener('input', (e) => {
const val = e.target.value.trim();
this.data[`imgUrl${i}`] = val;
if (val) {
previewImg.src = val;
previewBox.style.display = 'block';
} else {
previewBox.style.display = 'none';
}
});
} else if (type === 'youtube') {
const input = document.createElement('input');
input.type = 'text';
input.className = 'form-control form-control-sm mb-2';
input.placeholder = 'Paste YouTube video URL...';
input.value = this.data[`ytUrl${i}`] || '';
if (this.readOnly) input.disabled = true;
const preview = document.createElement('div');
preview.className = 'small text-muted p-2 bg-light border rounded';
preview.innerText = 'Format: https://www.youtube.com/watch?v=XXXXXX';
input.addEventListener('input', (e) => {
this.data[`ytUrl${i}`] = e.target.value.trim();
});
typeContainer.appendChild(input);
typeContainer.appendChild(preview);
} else if (type === 'accordion') {
const titleInput = document.createElement('input');
titleInput.type = 'text';
titleInput.className = 'form-control form-control-sm mb-2';
titleInput.placeholder = 'Accordion Title...';
titleInput.value = this.data[`accTitle${i}`] || '';
if (this.readOnly) titleInput.disabled = true;
const contentTextarea = document.createElement('textarea');
contentTextarea.className = 'form-control form-control-sm';
contentTextarea.style.minHeight = '90px';
contentTextarea.style.fontSize = '12px';
contentTextarea.placeholder = 'Accordion Body HTML...';
contentTextarea.value = this.data[`accContent${i}`] || '';
if (this.readOnly) contentTextarea.disabled = true;
titleInput.addEventListener('input', (e) => {
this.data[`accTitle${i}`] = e.target.value;
});
contentTextarea.addEventListener('input', (e) => {
this.data[`accContent${i}`] = e.target.value;
});
typeContainer.appendChild(titleInput);
typeContainer.appendChild(contentTextarea);
}
// Advanced Settings (ID and Class)
const advancedHeader = document.createElement('div');
advancedHeader.className = 'font-weight-bold text-muted small mb-2';
advancedHeader.style.fontSize = '10px';
advancedHeader.innerText = 'ADVANCED SETTINGS';
container.appendChild(advancedHeader);
const advRow = document.createElement('div');
advRow.className = 'form-row';
const classCol = document.createElement('div');
classCol.className = 'col';
const classInput = document.createElement('input');
classInput.type = 'text';
classInput.className = 'form-control form-control-sm';
classInput.style.fontSize = '11px';
classInput.placeholder = 'CSS Class(es)...';
classInput.value = this.data[`class${i}`] || '';
if (this.readOnly) classInput.disabled = true;
classInput.addEventListener('input', (e) => {
this.data[`class${i}`] = e.target.value.trim();
});
classCol.appendChild(classInput);
const idCol = document.createElement('div');
idCol.className = 'col';
const idInput = document.createElement('input');
idInput.type = 'text';
idInput.className = 'form-control form-control-sm';
idInput.style.fontSize = '11px';
idInput.placeholder = 'HTML ID...';
idInput.value = this.data[`id${i}`] || '';
if (this.readOnly) idInput.disabled = true;
idInput.addEventListener('input', (e) => {
this.data[`id${i}`] = e.target.value.trim();
});
idCol.appendChild(idInput);
advRow.appendChild(classCol);
advRow.appendChild(idCol);
container.appendChild(advRow);
}
save(blockContent) {
const savedData = {
cols: this.data.cols,
direction: this.data.direction,
justify: this.data.justify,
align: this.data.align,
gap: this.data.gap,
globalClass: this.globalClassInput ? this.globalClassInput.value.trim() : (this.data.globalClass || ''),
globalId: this.globalIdInput ? this.globalIdInput.value.trim() : (this.data.globalId || ''),
globalAttributes: this.globalAttrInput ? this.globalAttrInput.value.trim() : (this.data.globalAttributes || '')
};
for (let i = 1; i <= this.data.cols; i++) {
const type = this.data[`type${i}`] || 'html';
savedData[`type${i}`] = type;
// If it is a dynamically nested plugin, save its output data
if (this.activeInstances[i]) {
try {
const pluginData = this.activeInstances[i].save();
savedData[`col${i}`] = pluginData;
} catch (e) {
console.error(`Failed to save plugin ${type} inside FlexTool:`, e);
savedData[`col${i}`] = this.data[`col${i}`];
}
} else {
savedData[`col${i}`] = this.data[`col${i}`] || '';
}
savedData[`imgUrl${i}`] = this.data[`imgUrl${i}`] || '';
savedData[`ytUrl${i}`] = this.data[`ytUrl${i}`] || '';
savedData[`accTitle${i}`] = this.data[`accTitle${i}`] || '';
savedData[`accContent${i}`] = this.data[`accContent${i}`] || '';
savedData[`class${i}`] = this.data[`class${i}`] || '';
savedData[`id${i}`] = this.data[`id${i}`] || '';
savedData[`attr${i}`] = this.data[`attr${i}`] || '';
savedData[`width${i}`] = this.data[`width${i}`] || 0;
savedData[`caption${i}`] = this.data[`caption${i}`] || '';
// Sub-plugin serialization
const subType = this.data[`subType${i}`] || 'none';
savedData[`subType${i}`] = subType;
if (this.activeInstances[`sub_${i}`]) {
try {
const subPluginData = this.activeInstances[`sub_${i}`].save();
savedData[`colsub_${i}`] = subPluginData;
} catch (e) {
console.error(`Failed to save sub-plugin ${subType} inside FlexTool:`, e);
savedData[`colsub_${i}`] = this.data[`colsub_${i}`];
}
} else {
savedData[`colsub_${i}`] = this.data[`colsub_${i}`] || '';
}
savedData[`imgUrlsub_${i}`] = this.data[`imgUrlsub_${i}`] || '';
savedData[`ytUrlsub_${i}`] = this.data[`ytUrlsub_${i}`] || '';
savedData[`accTitlesub_${i}`] = this.data[`accTitlesub_${i}`] || '';
savedData[`accContentsub_${i}`] = this.data[`accContentsub_${i}`] || '';
savedData[`classsub_${i}`] = this.data[`classsub_${i}`] || '';
savedData[`idsub_${i}`] = this.data[`idsub_${i}`] || '';
savedData[`attrsub_${i}`] = this.data[`attrsub_${i}`] || '';
}
return savedData;
}
}
// Register the plugin globally
window.SISEditorPlugins = window.SISEditorPlugins || {};
window.SISEditorPlugins['flex'] = {
class: FlexTool
};
@@ -0,0 +1,806 @@
/**
* Grid block tool for Editor.js.
* Allows users to create responsive layouts with any number of columns and choose different content types, manual widths,
* and dynamically instantiates other registered editor plugins inside columns.
*/
class GridTool {
static get toolbox() {
return {
title: 'Grid / Columns',
icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9"></rect><rect x="14" y="3" width="7" height="9"></rect><rect x="14" y="15" width="7" height="6"></rect><rect x="3" y="15" width="7" height="6"></rect></svg>'
};
}
constructor({ data, api, readOnly }) {
this.api = api;
this.readOnly = readOnly;
this.data = {
cols: parseInt(data.cols) || 2,
globalClass: data.globalClass || '',
globalId: data.globalId || '',
globalAttributes: data.globalAttributes || data.attributes || ''
};
this.activeInstances = {};
// Load column contents dynamically
for (let i = 1; i <= 12; i++) {
this.data[`type${i}`] = data[`type${i}`] || 'html';
this.data[`col${i}`] = data[`col${i}`] || '';
this.data[`imgUrl${i}`] = data[`imgUrl${i}`] || '';
this.data[`ytUrl${i}`] = data[`ytUrl${i}`] || '';
this.data[`accTitle${i}`] = data[`accTitle${i}`] || '';
this.data[`accContent${i}`] = data[`accContent${i}`] || '';
this.data[`class${i}`] = data[`class${i}`] || '';
this.data[`id${i}`] = data[`id${i}`] || '';
this.data[`attr${i}`] = data[`attr${i}`] || data[`attributes${i}`] || '';
this.data[`width${i}`] = parseInt(data[`width${i}`]) || 0; // 0 means auto
this.data[`caption${i}`] = data[`caption${i}`] || '';
// Sub-plugin properties
this.data[`subType${i}`] = data[`subType${i}`] || 'none';
this.data[`colsub_${i}`] = data[`colsub_${i}`] || '';
this.data[`imgUrlsub_${i}`] = data[`imgUrlsub_${i}`] || '';
this.data[`ytUrlsub_${i}`] = data[`ytUrlsub_${i}`] || '';
this.data[`accTitlesub_${i}`] = data[`accTitlesub_${i}`] || '';
this.data[`accContentsub_${i}`] = data[`accContentsub_${i}`] || '';
this.data[`classsub_${i}`] = data[`classsub_${i}`] || '';
this.data[`idsub_${i}`] = data[`idsub_${i}`] || '';
this.data[`attrsub_${i}`] = data[`attrsub_${i}`] || '';
}
this.wrapper = undefined;
}
render() {
this.wrapper = document.createElement('div');
this.wrapper.className = 'p-3 bg-light border rounded mb-3 ce-grid-tool-wrapper';
this.wrapper.style.fontFamily = 'inherit';
const title = document.createElement('div');
title.className = 'font-weight-bold text-primary small mb-3';
title.innerHTML = '<i class="fas fa-columns"></i> Multi-Column Grid Layout';
this.wrapper.appendChild(title);
// Layout Settings Row
const settingsRow = document.createElement('div');
settingsRow.className = 'form-row mb-3 pb-2 border-bottom';
// 1. Columns
const colDiv = document.createElement('div');
colDiv.className = 'col-md-2 mb-2';
colDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Columns</label>';
const colInput = document.createElement('input');
colInput.type = 'number';
colInput.className = 'form-control form-control-sm';
colInput.min = '1';
colInput.max = '12';
colInput.value = this.data.cols;
if (this.readOnly) colInput.disabled = true;
colInput.addEventListener('input', (e) => {
let val = parseInt(e.target.value) || 2;
if (val < 1) val = 1;
if (val > 12) val = 12;
this.data.cols = val;
this._renderColumnInputs();
});
colDiv.appendChild(colInput);
settingsRow.appendChild(colDiv);
// 2. Global CSS Class
const classDiv = document.createElement('div');
classDiv.className = 'col-md-3 mb-2';
classDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Grid CSS Class(es)</label>';
const classInput = document.createElement('input');
classInput.type = 'text';
classInput.className = 'form-control form-control-sm';
classInput.placeholder = 'e.g. my-custom-grid';
classInput.value = this.data.globalClass || '';
if (this.readOnly) classInput.disabled = true;
classInput.addEventListener('input', (e) => this.data.globalClass = e.target.value.trim());
this.globalClassInput = classInput;
classDiv.appendChild(classInput);
settingsRow.appendChild(classDiv);
// 3. Global HTML ID
const idDiv = document.createElement('div');
idDiv.className = 'col-md-3 mb-2';
idDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Grid HTML ID</label>';
const idInput = document.createElement('input');
idInput.type = 'text';
idInput.className = 'form-control form-control-sm';
idInput.placeholder = 'e.g. grid-section-1';
idInput.value = this.data.globalId || '';
if (this.readOnly) idInput.disabled = true;
idInput.addEventListener('input', (e) => this.data.globalId = e.target.value.trim());
this.globalIdInput = idInput;
idDiv.appendChild(idInput);
settingsRow.appendChild(idDiv);
// 4. Global Custom Attributes
const attrDiv = document.createElement('div');
attrDiv.className = 'col-md-4 mb-2';
attrDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1"><i class="fas fa-sliders-h"></i> Custom Attributes</label>';
const attrInput = document.createElement('input');
attrInput.type = 'text';
attrInput.className = 'form-control form-control-sm';
attrInput.placeholder = 'e.g. data-aos="fade-up" style="background:#fff"';
attrInput.value = this.data.globalAttributes || '';
if (this.readOnly) attrInput.disabled = true;
attrInput.addEventListener('input', (e) => this.data.globalAttributes = e.target.value.trim());
this.globalAttrInput = attrInput;
attrDiv.appendChild(attrInput);
settingsRow.appendChild(attrDiv);
this.wrapper.appendChild(settingsRow);
// Columns inputs container
this.inputsContainer = document.createElement('div');
this.wrapper.appendChild(this.inputsContainer);
this._renderColumnInputs();
return this.wrapper;
}
_renderColumnInputs() {
this.inputsContainer.innerHTML = '';
const row = document.createElement('div');
row.className = 'row d-flex flex-wrap';
const count = this.data.cols;
const colWidthClass = count === 1 ? 'col-12' : (count === 2 ? 'col-md-6' : (count === 3 ? 'col-md-4' : (count === 4 ? 'col-md-3' : 'col-md')));
for (let i = 1; i <= count; i++) {
const colDiv = document.createElement('div');
colDiv.className = `${colWidthClass} mb-3 p-2 border rounded bg-white`;
colDiv.style.boxShadow = '0 1px 3px rgba(0,0,0,0.05)';
const headerRow = document.createElement('div');
headerRow.className = 'd-flex justify-content-between align-items-center mb-2 pb-1 border-bottom';
const label = document.createElement('label');
label.className = 'small font-weight-bold text-dark mb-0';
label.innerText = `Col ${i}`;
headerRow.appendChild(label);
const controlsWrapper = document.createElement('div');
controlsWrapper.className = 'd-flex align-items-center gap-1';
// Width Selector
const widthSelect = document.createElement('select');
widthSelect.className = 'custom-select custom-select-sm mr-1';
widthSelect.style.width = '75px';
if (this.readOnly) widthSelect.disabled = true;
const widthOpts = [
{ val: 0, lbl: 'Auto' },
{ val: 1, lbl: '1/12' },
{ val: 2, lbl: '2/12' },
{ val: 3, lbl: '3/12' },
{ val: 4, lbl: '4/12' },
{ val: 5, lbl: '5/12' },
{ val: 6, lbl: '6/12' },
{ val: 7, lbl: '7/12' },
{ val: 8, lbl: '8/12' },
{ val: 9, lbl: '9/12' },
{ val: 10, lbl: '10/12' },
{ val: 11, lbl: '11/12' },
{ val: 12, lbl: '12/12' }
];
widthOpts.forEach(o => {
const opt = document.createElement('option');
opt.value = o.val;
opt.innerText = o.lbl;
opt.selected = this.data[`width${i}`] === o.val;
widthSelect.appendChild(opt);
});
widthSelect.addEventListener('change', (e) => {
this.data[`width${i}`] = parseInt(e.target.value) || 0;
});
controlsWrapper.appendChild(widthSelect);
// Column Type Select (Built-ins + Registered plugins dynamically)
const typeSelect = document.createElement('select');
typeSelect.className = 'custom-select custom-select-sm';
typeSelect.style.width = '100px';
if (this.readOnly) typeSelect.disabled = true;
// Base types
const types = [
{ value: 'html', label: 'HTML/Text' },
{ value: 'image', label: 'Image' },
{ value: 'youtube', label: 'YouTube' },
{ value: 'accordion', label: 'Accordion' }
];
// Dynamically add other registered plugins from window.SISEditorPlugins
if (window.SISEditorPlugins) {
Object.keys(window.SISEditorPlugins).forEach(key => {
// Allow nesting any registered plugins (no exclusions)
if (!types.some(t => t.value === key)) {
types.push({ value: key, label: `[Plugin] ${key}` });
}
});
}
types.forEach(t => {
const opt = document.createElement('option');
opt.value = t.value;
opt.innerText = t.label;
opt.selected = this.data[`type${i}`] === t.value;
typeSelect.appendChild(opt);
});
const contentDiv = document.createElement('div');
typeSelect.addEventListener('change', (e) => {
this.data[`type${i}`] = e.target.value;
this._renderTypeSpecificInput(i, contentDiv);
});
controlsWrapper.appendChild(typeSelect);
headerRow.appendChild(controlsWrapper);
colDiv.appendChild(headerRow);
colDiv.appendChild(contentDiv);
// --- Sub-Plugin (Sub-Content) Section ---
const subWrapper = document.createElement('div');
if (this.data[`subType${i}`] !== 'none') {
subWrapper.className = 'mt-3 pt-2 border-top';
}
const subHeader = document.createElement('div');
subHeader.className = 'd-flex justify-content-between align-items-center mb-2';
const subLabel = document.createElement('label');
subLabel.className = 'small font-weight-bold text-muted mb-0';
subLabel.style.fontSize = '10px';
subLabel.innerText = 'SUB-CONTENT / INJECT PLUGIN';
subHeader.appendChild(subLabel);
const subTypeSelect = document.createElement('select');
subTypeSelect.className = 'custom-select custom-select-sm';
subTypeSelect.style.width = '130px';
subTypeSelect.style.fontSize = '10px';
if (this.readOnly) subTypeSelect.disabled = true;
const subTypes = [
{ value: 'none', label: 'None' },
{ value: 'caption', label: 'Text Caption' },
{ value: 'html', label: 'HTML/Text' },
{ value: 'image', label: 'Image' },
{ value: 'youtube', label: 'YouTube' },
{ value: 'accordion', label: 'Accordion' }
];
if (window.SISEditorPlugins) {
Object.keys(window.SISEditorPlugins).forEach(key => {
if (!subTypes.some(t => t.value === key)) {
subTypes.push({ value: key, label: `[Plugin] ${key}` });
}
});
}
subTypes.forEach(t => {
const opt = document.createElement('option');
opt.value = t.value;
opt.innerText = t.label;
opt.selected = this.data[`subType${i}`] === t.value;
subTypeSelect.appendChild(opt);
});
subHeader.appendChild(subTypeSelect);
subWrapper.appendChild(subHeader);
const subContentDiv = document.createElement('div');
subWrapper.appendChild(subContentDiv);
colDiv.appendChild(subWrapper);
const renderSubInputs = () => {
subContentDiv.innerHTML = '';
const subType = this.data[`subType${i}`];
if (subType === 'none') {
subWrapper.className = '';
} else {
subWrapper.className = 'mt-3 pt-2 border-top';
}
if (subType === 'none') {
// Empty
} else if (subType === 'caption') {
const captionGroup = document.createElement('div');
captionGroup.className = 'form-group mb-2';
const captionInput = document.createElement('input');
captionInput.type = 'text';
captionInput.className = 'form-control form-control-sm';
captionInput.style.fontSize = '11px';
captionInput.placeholder = 'Optional column caption / text...';
captionInput.value = this.data[`caption${i}`] || '';
if (this.readOnly) captionInput.disabled = true;
captionInput.addEventListener('input', (e) => {
this.data[`caption${i}`] = e.target.value;
});
captionGroup.appendChild(captionInput);
subContentDiv.appendChild(captionGroup);
} else {
this._renderTypeSpecificInput(`sub_${i}`, subContentDiv, subType);
}
};
subTypeSelect.addEventListener('change', (e) => {
this.data[`subType${i}`] = e.target.value;
renderSubInputs();
});
row.appendChild(colDiv);
this._renderTypeSpecificInput(i, contentDiv);
renderSubInputs();
}
this.inputsContainer.appendChild(row);
}
_renderTypeSpecificInput(i, container, explicitType) {
container.innerHTML = '';
// Remove tracking of previous instance
if (this.activeInstances[i]) {
delete this.activeInstances[i];
}
const typeContainer = document.createElement('div');
typeContainer.className = 'mb-3';
container.appendChild(typeContainer);
const type = explicitType || this.data[`type${i}`] || 'html';
// Check if type is a dynamic editor plugin registered in window.SISEditorPlugins
if (window.SISEditorPlugins && window.SISEditorPlugins[type]) {
try {
const pluginEntry = window.SISEditorPlugins[type];
const pluginClass = pluginEntry.class;
const pluginConfig = pluginEntry.config || {};
let parsedData = {};
if (this.data[`col${i}`]) {
try {
parsedData = typeof this.data[`col${i}`] === 'string' ? JSON.parse(this.data[`col${i}`]) : this.data[`col${i}`];
} catch (e) {
parsedData = { html: this.data[`col${i}`] }; // fallback
}
}
const instance = new pluginClass({
data: parsedData,
api: this.api,
readOnly: this.readOnly,
config: pluginConfig,
block: {
id: 'nested-grid-' + i + '-' + Math.random().toString(36).substring(7),
name: type,
holder: typeContainer,
isEmpty: false,
selected: false,
stretched: false,
tunes: {}
}
});
this.activeInstances[i] = instance;
const element = instance.render();
typeContainer.appendChild(element);
// Fix for Editor.js Image plugin 404 errors (spinner hangs forever)
if (type === 'image') {
// Custom UI override for nested images
const injectCustomButtons = () => {
if (element.querySelector('.sis-custom-image-controls')) return;
const fileBtn = element.querySelector('.cdx-button');
if (fileBtn) {
fileBtn.style.display = 'none'; // Hide native button
}
const controls = document.createElement('div');
controls.className = 'sis-custom-image-controls mt-2 p-2 border rounded bg-light';
controls.style.display = 'flex';
controls.style.flexDirection = 'column';
controls.style.gap = '8px';
controls.innerHTML = `
<div class="text-muted small text-center mb-1"><i class="fas fa-cog"></i> Image Controls</div>
<button type="button" class="btn btn-outline-primary btn-sm w-100" id="btn-upload-img-${i}"><i class="fas fa-upload"></i> Upload Image</button>
<button type="button" class="btn btn-outline-info btn-sm w-100" id="btn-fetch-url-${i}"><i class="fas fa-link"></i> Fetch URL</button>
<button type="button" class="btn btn-outline-secondary btn-sm w-100" id="btn-media-lib-${i}"><i class="fas fa-images"></i> Media Library</button>
<button type="button" class="btn btn-outline-danger btn-sm w-100 mt-2" id="btn-clear-img-${i}"><i class="fas fa-trash"></i> Remove Image</button>
`;
const uploadBtn = controls.querySelector(`#btn-upload-img-${i}`);
if (uploadBtn && fileBtn) {
uploadBtn.addEventListener('click', (e) => {
e.preventDefault();
fileBtn.click();
});
}
const urlBtn = controls.querySelector(`#btn-fetch-url-${i}`);
if (urlBtn) {
urlBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const url = prompt('Enter Image URL:');
if (url && url.trim() !== '') {
this.data[`col${i}`] = {
file: { url: url.trim() },
caption: '',
withBorder: false,
withBackground: false,
stretched: false
};
this._renderTypeSpecificInput(i, container);
}
});
}
const mediaBtn = controls.querySelector(`#btn-media-lib-${i}`);
if (mediaBtn) {
mediaBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
if (window.SISMediaPicker) {
SISMediaPicker.open((url, mediaObj, config) => {
if (url) {
this.data[`col${i}`] = {
file: { url: url },
caption: (config && config.alt) || '',
withBorder: false,
withBackground: false,
stretched: false
};
if (config) {
this.data[`imgStyle${i}`] = config.style || '';
this.data[`imgClass${i}`] = config.cssClass || '';
this.data[`imgAlt${i}`] = config.alt || '';
this.data[`imgAttrs${i}`] = config.customAttributes || '';
this.data[`imgAspectRatio${i}`] = config.aspectRatio || '';
}
this._renderTypeSpecificInput(i, container);
}
});
} else {
alert('Media Library is not yet configured on this system.');
}
});
}
const clearBtn = controls.querySelector(`#btn-clear-img-${i}`);
if (clearBtn) {
clearBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
if (confirm('Are you sure you want to remove this image?')) {
this.data[`col${i}`] = '';
this._renderTypeSpecificInput(i, container);
}
});
}
element.appendChild(controls);
};
injectCustomButtons();
const handleImageError = () => {
element.classList.remove('image-tool--loading');
element.classList.add('image-tool--empty');
const preloader = element.querySelector('.image-tool__image-preloader');
if (preloader) preloader.style.display = 'none';
};
const observer = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => {
if (node.tagName === 'IMG') {
node.addEventListener('error', handleImageError);
}
});
});
});
observer.observe(element, { childList: true, subtree: true });
const existingImg = element.querySelector('img');
if (existingImg) existingImg.addEventListener('error', handleImageError);
}
} catch (err) {
console.error(`Failed to render plugin ${type} inside GridTool:`, err);
typeContainer.innerHTML = `<div class="alert alert-danger small">Error loading plugin ${type}</div>`;
}
} else if (type === 'html') {
const textarea = document.createElement('textarea');
textarea.className = 'form-control form-control-sm';
textarea.style.minHeight = '140px';
textarea.style.fontSize = '12px';
textarea.placeholder = `HTML / Text for Column ${i}...`;
textarea.value = typeof this.data[`col${i}`] === 'object' ? JSON.stringify(this.data[`col${i}`]) : this.data[`col${i}`] || '';
if (this.readOnly) textarea.disabled = true;
textarea.addEventListener('input', (e) => {
this.data[`col${i}`] = e.target.value;
});
typeContainer.appendChild(textarea);
} else if (type === 'image') {
const inputGroup = document.createElement('div');
inputGroup.className = 'input-group input-group-sm mb-2';
const input = document.createElement('input');
input.type = 'text';
input.className = 'form-control form-control-sm';
input.placeholder = 'Paste image URL here...';
input.value = this.data[`imgUrl${i}`] || '';
if (this.readOnly) input.disabled = true;
const appendDiv = document.createElement('div');
appendDiv.className = 'input-group-append';
const chooseBtn = document.createElement('button');
chooseBtn.type = 'button';
chooseBtn.className = 'btn btn-outline-secondary';
chooseBtn.innerText = 'Choose';
if (this.readOnly) chooseBtn.disabled = true;
chooseBtn.addEventListener('click', () => {
if (window.SISMediaPicker) {
SISMediaPicker.open((selectedUrl, mediaObj, config) => {
input.value = selectedUrl;
this.data[`imgUrl${i}`] = selectedUrl;
if (config) {
this.data[`imgStyle${i}`] = config.style || '';
this.data[`imgClass${i}`] = config.cssClass || '';
this.data[`imgAlt${i}`] = config.alt || '';
this.data[`imgAttrs${i}`] = config.customAttributes || '';
this.data[`imgAspectRatio${i}`] = config.aspectRatio || '';
}
previewImg.src = selectedUrl;
previewBox.style.display = 'block';
});
} else {
alert('Media Picker modal helper is not loaded.');
}
});
const uploadBtn = document.createElement('button');
uploadBtn.type = 'button';
uploadBtn.className = 'btn btn-outline-primary';
uploadBtn.innerText = 'Upload';
if (this.readOnly) uploadBtn.disabled = true;
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'image/*';
fileInput.style.display = 'none';
uploadBtn.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('image', file);
uploadBtn.disabled = true;
uploadBtn.innerText = '...';
try {
const response = await fetch('/api/manage/media/upload', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success && result.file && result.file.url) {
const url = result.file.url;
input.value = url;
this.data[`imgUrl${i}`] = url;
previewImg.src = url;
previewBox.style.display = 'block';
} else {
alert('Upload failed: ' + (result.message || 'Unknown error'));
}
} catch (err) {
console.error(err);
alert('Upload error occurred.');
} finally {
uploadBtn.disabled = false;
uploadBtn.innerText = 'Upload';
}
});
appendDiv.appendChild(chooseBtn);
appendDiv.appendChild(uploadBtn);
inputGroup.appendChild(input);
inputGroup.appendChild(appendDiv);
typeContainer.appendChild(inputGroup);
typeContainer.appendChild(fileInput);
const previewBox = document.createElement('div');
previewBox.className = 'border rounded p-1 text-center bg-light';
previewBox.style.display = this.data[`imgUrl${i}`] ? 'block' : 'none';
previewBox.style.maxHeight = '150px';
previewBox.style.overflow = 'hidden';
const previewImg = document.createElement('img');
previewImg.src = this.data[`imgUrl${i}`] || '';
previewImg.style.maxWidth = '100%';
previewImg.style.maxHeight = '140px';
previewImg.style.objectFit = 'contain';
previewBox.appendChild(previewImg);
typeContainer.appendChild(previewBox);
input.addEventListener('input', (e) => {
const val = e.target.value.trim();
this.data[`imgUrl${i}`] = val;
if (val) {
previewImg.src = val;
previewBox.style.display = 'block';
} else {
previewBox.style.display = 'none';
}
});
} else if (type === 'youtube') {
const input = document.createElement('input');
input.type = 'text';
input.className = 'form-control form-control-sm mb-2';
input.placeholder = 'Paste YouTube video URL...';
input.value = this.data[`ytUrl${i}`] || '';
if (this.readOnly) input.disabled = true;
const preview = document.createElement('div');
preview.className = 'small text-muted p-2 bg-light border rounded';
preview.innerText = 'Format: https://www.youtube.com/watch?v=XXXXXX';
input.addEventListener('input', (e) => {
this.data[`ytUrl${i}`] = e.target.value.trim();
});
typeContainer.appendChild(input);
typeContainer.appendChild(preview);
} else if (type === 'accordion') {
const titleInput = document.createElement('input');
titleInput.type = 'text';
titleInput.className = 'form-control form-control-sm mb-2';
titleInput.placeholder = 'Accordion Title...';
titleInput.value = this.data[`accTitle${i}`] || '';
if (this.readOnly) titleInput.disabled = true;
const contentTextarea = document.createElement('textarea');
contentTextarea.className = 'form-control form-control-sm';
contentTextarea.style.minHeight = '90px';
contentTextarea.style.fontSize = '12px';
contentTextarea.placeholder = 'Accordion Body HTML...';
contentTextarea.value = this.data[`accContent${i}`] || '';
if (this.readOnly) contentTextarea.disabled = true;
titleInput.addEventListener('input', (e) => {
this.data[`accTitle${i}`] = e.target.value;
});
contentTextarea.addEventListener('input', (e) => {
this.data[`accContent${i}`] = e.target.value;
});
typeContainer.appendChild(titleInput);
typeContainer.appendChild(contentTextarea);
}
// Advanced Settings (ID and Class)
const advancedHeader = document.createElement('div');
advancedHeader.className = 'font-weight-bold text-muted small mb-2';
advancedHeader.style.fontSize = '10px';
advancedHeader.innerText = 'ADVANCED SETTINGS';
container.appendChild(advancedHeader);
const advRow = document.createElement('div');
advRow.className = 'form-row';
const classCol = document.createElement('div');
classCol.className = 'col';
const classInput = document.createElement('input');
classInput.type = 'text';
classInput.className = 'form-control form-control-sm';
classInput.style.fontSize = '11px';
classInput.placeholder = 'CSS Class(es)...';
classInput.value = this.data[`class${i}`] || '';
if (this.readOnly) classInput.disabled = true;
classInput.addEventListener('input', (e) => {
this.data[`class${i}`] = e.target.value.trim();
});
classCol.appendChild(classInput);
const idCol = document.createElement('div');
idCol.className = 'col';
const idInput = document.createElement('input');
idInput.type = 'text';
idInput.className = 'form-control form-control-sm';
idInput.style.fontSize = '11px';
idInput.placeholder = 'HTML ID...';
idInput.value = this.data[`id${i}`] || '';
if (this.readOnly) idInput.disabled = true;
idInput.addEventListener('input', (e) => {
this.data[`id${i}`] = e.target.value.trim();
});
idCol.appendChild(idInput);
advRow.appendChild(classCol);
advRow.appendChild(idCol);
container.appendChild(advRow);
}
save(blockContent) {
const savedData = {
cols: this.data.cols,
globalClass: this.globalClassInput ? this.globalClassInput.value.trim() : (this.data.globalClass || ''),
globalId: this.globalIdInput ? this.globalIdInput.value.trim() : (this.data.globalId || ''),
globalAttributes: this.globalAttrInput ? this.globalAttrInput.value.trim() : (this.data.globalAttributes || '')
};
for (let i = 1; i <= this.data.cols; i++) {
const type = this.data[`type${i}`] || 'html';
savedData[`type${i}`] = type;
// If it is a dynamically nested plugin, save its output data
if (this.activeInstances[i]) {
try {
const pluginData = this.activeInstances[i].save();
savedData[`col${i}`] = pluginData;
} catch (e) {
console.error(`Failed to save plugin ${type} inside GridTool:`, e);
savedData[`col${i}`] = this.data[`col${i}`];
}
} else {
savedData[`col${i}`] = this.data[`col${i}`] || '';
}
savedData[`imgUrl${i}`] = this.data[`imgUrl${i}`] || '';
savedData[`ytUrl${i}`] = this.data[`ytUrl${i}`] || '';
savedData[`accTitle${i}`] = this.data[`accTitle${i}`] || '';
savedData[`accContent${i}`] = this.data[`accContent${i}`] || '';
savedData[`class${i}`] = this.data[`class${i}`] || '';
savedData[`id${i}`] = this.data[`id${i}`] || '';
savedData[`attr${i}`] = this.data[`attr${i}`] || '';
savedData[`width${i}`] = this.data[`width${i}`] || 0;
savedData[`caption${i}`] = this.data[`caption${i}`] || '';
// Sub-plugin serialization
const subType = this.data[`subType${i}`] || 'none';
savedData[`subType${i}`] = subType;
if (this.activeInstances[`sub_${i}`]) {
try {
const subPluginData = this.activeInstances[`sub_${i}`].save();
savedData[`colsub_${i}`] = subPluginData;
} catch (e) {
console.error(`Failed to save sub-plugin ${subType} inside GridTool:`, e);
savedData[`colsub_${i}`] = this.data[`colsub_${i}`];
}
} else {
savedData[`colsub_${i}`] = this.data[`colsub_${i}`] || '';
}
savedData[`imgUrlsub_${i}`] = this.data[`imgUrlsub_${i}`] || '';
savedData[`ytUrlsub_${i}`] = this.data[`ytUrlsub_${i}`] || '';
savedData[`accTitlesub_${i}`] = this.data[`accTitlesub_${i}`] || '';
savedData[`accContentsub_${i}`] = this.data[`accContentsub_${i}`] || '';
savedData[`classsub_${i}`] = this.data[`classsub_${i}`] || '';
savedData[`idsub_${i}`] = this.data[`idsub_${i}`] || '';
savedData[`attrsub_${i}`] = this.data[`attrsub_${i}`] || '';
}
return savedData;
}
}
// Register the plugin globally
window.SISEditorPlugins = window.SISEditorPlugins || {};
window.SISEditorPlugins['grid'] = {
class: GridTool
};
@@ -0,0 +1,9 @@
(function(){"use strict";try{if(typeof document<"u"){var e=document.createElement("style");e.appendChild(document.createTextNode(".ce-header{padding:.6em 0 3px;margin:0;line-height:1.25em;outline:none}.ce-header p,.ce-header div{padding:0!important;margin:0!important}")),document.head.appendChild(e)}}catch(n){console.error("vite-plugin-css-injected-by-js",n)}})();
(function(n,s){typeof exports=="object"&&typeof module<"u"?module.exports=s():typeof define=="function"&&define.amd?define(s):(n=typeof globalThis<"u"?globalThis:n||self,n.Header=s())})(this,function(){"use strict";const n="",s='<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M6 7L6 12M6 17L6 12M6 12L12 12M12 7V12M12 17L12 12"/><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M19 17V10.2135C19 10.1287 18.9011 10.0824 18.836 10.1367L16 12.5"/></svg>',a='<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M6 7L6 12M6 17L6 12M6 12L12 12M12 7V12M12 17L12 12"/><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M16 11C16 10 19 9.5 19 12C19 13.9771 16.0684 13.9997 16.0012 16.8981C15.9999 16.9533 16.0448 17 16.1 17L19.3 17"/></svg>',h='<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M6 7L6 12M6 17L6 12M6 12L12 12M12 7V12M12 17L12 12"/><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M16 11C16 10.5 16.8323 10 17.6 10C18.3677 10 19.5 10.311 19.5 11.5C19.5 12.5315 18.7474 12.9022 18.548 12.9823C18.5378 12.9864 18.5395 13.0047 18.5503 13.0063C18.8115 13.0456 20 13.3065 20 14.8C20 16 19.5 17 17.8 17C17.8 17 16 17 16 16.3"/></svg>',d='<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M6 7L6 12M6 17L6 12M6 12L12 12M12 7V12M12 17L12 12"/><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M18 10L15.2834 14.8511C15.246 14.9178 15.294 15 15.3704 15C16.8489 15 18.7561 15 20.2 15M19 17C19 15.7187 19 14.8813 19 13.6"/></svg>',u='<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M6 7L6 12M6 17L6 12M6 12L12 12M12 7V12M12 17L12 12"/><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M16 15.9C16 15.9 16.3768 17 17.8 17C19.5 17 20 15.6199 20 14.7C20 12.7323 17.6745 12.0486 16.1635 12.9894C16.094 13.0327 16 12.9846 16 12.9027V10.1C16 10.0448 16.0448 10 16.1 10H19.8"/></svg>',g='<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M6 7L6 12M6 17L6 12M6 12L12 12M12 7V12M12 17L12 12"/><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M19.5 10C16.5 10.5 16 13.3285 16 15M16 15V15C16 16.1046 16.8954 17 18 17H18.3246C19.3251 17 20.3191 16.3492 20.2522 15.3509C20.0612 12.4958 16 12.6611 16 15Z"/></svg>',c='<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M9 7L9 12M9 17V12M9 12L15 12M15 7V12M15 17L15 12"/></svg>';/**
* Header block for the Editor.js.
*
* @author CodeX (team@ifmo.su)
* @copyright CodeX 2018
* @license MIT
* @version 2.0.0
*/class v{constructor({data:e,config:t,api:i,readOnly:r}){this.api=i,this.readOnly=r,this._settings=t,this._data=this.normalizeData(e),this._element=this.getTag()}get _CSS(){return{block:this.api.styles.block,wrapper:"ce-header"}}isHeaderData(e){return e.text!==void 0}normalizeData(e){const t={text:"",level:this.defaultLevel.number};return this.isHeaderData(e)&&(t.text=e.text||"",e.level!==void 0&&!isNaN(parseInt(e.level.toString()))&&(t.level=parseInt(e.level.toString()))),t}render(){return this._element}renderSettings(){return this.levels.map(e=>({icon:e.svg,label:this.api.i18n.t(`Heading ${e.number}`),onActivate:()=>this.setLevel(e.number),closeOnActivate:!0,isActive:this.currentLevel.number===e.number,render:()=>document.createElement("div")}))}setLevel(e){this.data={level:e,text:this.data.text}}merge(e){this._element.insertAdjacentHTML("beforeend",e.text)}validate(e){return e.text.trim()!==""}save(e){return{text:e.innerHTML,level:this.currentLevel.number}}static get conversionConfig(){return{export:"text",import:"text"}}static get sanitize(){return{level:!1,text:{}}}static get isReadOnlySupported(){return!0}get data(){return this._data.text=this._element.innerHTML,this._data.level=this.currentLevel.number,this._data}set data(e){if(this._data=this.normalizeData(e),e.level!==void 0&&this._element.parentNode){const t=this.getTag();t.innerHTML=this._element.innerHTML,this._element.parentNode.replaceChild(t,this._element),this._element=t}e.text!==void 0&&(this._element.innerHTML=this._data.text||"")}getTag(){const e=document.createElement(this.currentLevel.tag);return e.innerHTML=this._data.text||"",e.classList.add(this._CSS.wrapper),e.contentEditable=this.readOnly?"false":"true",e.dataset.placeholder=this.api.i18n.t(this._settings.placeholder||""),e}get currentLevel(){let e=this.levels.find(t=>t.number===this._data.level);return e||(e=this.defaultLevel),e}get defaultLevel(){if(this._settings.defaultLevel){const e=this.levels.find(t=>t.number===this._settings.defaultLevel);if(e)return e;console.warn("(ง'̀-'́)ง Heading Tool: the default level specified was not found in available levels")}return this.levels[1]}get levels(){const e=[{number:1,tag:"H1",svg:s},{number:2,tag:"H2",svg:a},{number:3,tag:"H3",svg:h},{number:4,tag:"H4",svg:d},{number:5,tag:"H5",svg:u},{number:6,tag:"H6",svg:g}];return this._settings.levels?e.filter(t=>this._settings.levels.includes(t.number)):e}onPaste(e){const t=e.detail;if("data"in t){const i=t.data;let r=this.defaultLevel.number;switch(i.tagName){case"H1":r=1;break;case"H2":r=2;break;case"H3":r=3;break;case"H4":r=4;break;case"H5":r=5;break;case"H6":r=6;break}this._settings.levels&&(r=this._settings.levels.reduce((o,l)=>Math.abs(l-r)<Math.abs(o-r)?l:o)),this.data={level:r,text:i.innerHTML}}}static get pasteConfig(){return{tags:["H1","H2","H3","H4","H5","H6"]}}static get toolbox(){return{icon:c,title:"Heading"}}}return v});
@@ -0,0 +1,246 @@
/**
* SISHeroBannerTool — Hero Banner Plugin for Editor.js
* Allows content managers to build dynamic hero banners with live preview.
*/
class SISHeroBannerTool {
static get toolbox() {
return {
title: 'Hero Banner',
icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg>'
};
}
constructor({ data, api, readOnly }) {
this.api = api;
this.readOnly = readOnly;
this.data = {
title: (data && data.title) ? data.title : '',
subtitle: (data && data.subtitle) ? data.subtitle : '',
bgImage: (data && data.bgImage) ? data.bgImage : '',
btnText: (data && data.btnText) ? data.btnText : '',
btnLink: (data && data.btnLink) ? data.btnLink : '',
height: (data && data.height) ? data.height : '350px',
textAlign: (data && data.textAlign) ? data.textAlign : 'center',
overlayOpacity: (data && data.overlayOpacity !== undefined) ? data.overlayOpacity : '0.4',
stretched: (data && data.stretched !== undefined) ? !!data.stretched : true
};
}
render() {
const container = document.createElement('div');
container.style.border = '1px solid #e3e6f0';
container.style.borderRadius = '8px';
container.style.padding = '14px';
container.style.background = '#fff';
container.style.marginBottom = '12px';
const headerLabel = document.createElement('label');
headerLabel.className = 'font-weight-bold text-primary small mb-2 d-block';
headerLabel.innerHTML = '<i class="fas fa-image"></i> Hero Banner Block Settings';
container.appendChild(headerLabel);
// Stretch toggle
const stretchWrapper = document.createElement('div');
stretchWrapper.className = 'custom-control custom-switch mb-3';
const stretchCheck = document.createElement('input');
stretchCheck.type = 'checkbox';
stretchCheck.className = 'custom-control-input';
stretchCheck.id = 'hero_stretch_' + Math.random().toString(36).substring(7);
stretchCheck.checked = !!this.data.stretched;
const stretchLabel = document.createElement('label');
stretchLabel.className = 'custom-control-label small font-weight-bold text-secondary';
stretchLabel.htmlFor = stretchCheck.id;
stretchLabel.innerHTML = '<i class="fas fa-arrows-alt-h"></i> Stretch Banner to Full Screen Width';
stretchCheck.addEventListener('change', () => {
this.data.stretched = stretchCheck.checked;
});
stretchWrapper.appendChild(stretchCheck);
stretchWrapper.appendChild(stretchLabel);
container.appendChild(stretchWrapper);
// Inputs
this.titleInput = this._createInput('Banner Title', 'e.g. Bệnh Viện Đa Khoa Quốc Tế S.I.S Cần Thơ', this.data.title);
this.subtitleInput = this._createInput('Subtitle / Description', 'e.g. Trao niềm tin - Nhận sức khỏe...', this.data.subtitle);
this.bgImageInput = this._createImageInput('Background Image URL', 'https://example.com/hero-banner.jpg', this.data.bgImage);
// Row for Height & Button Link
const configRow = document.createElement('div');
configRow.className = 'form-row';
const heightCol = document.createElement('div');
heightCol.className = 'col-md-4 mb-2';
this.heightInput = this._createInput('Banner Height (e.g. 350px, 500px, 60vh)', 'e.g. 350px', this.data.height);
heightCol.appendChild(this.heightInput);
const btnCol1 = document.createElement('div');
btnCol1.className = 'col-md-4 mb-2';
this.btnTextInput = this._createInput('Button Text (Optional)', 'e.g. Đặt Lịch Khám', this.data.btnText);
btnCol1.appendChild(this.btnTextInput);
const btnCol2 = document.createElement('div');
btnCol2.className = 'col-md-4 mb-2';
this.btnLinkInput = this._createInput('Button Link URL (Optional)', 'e.g. /dat-lich', this.data.btnLink);
btnCol2.appendChild(this.btnLinkInput);
configRow.appendChild(heightCol);
configRow.appendChild(btnCol1);
configRow.appendChild(btnCol2);
// Live Preview Box
const previewBox = document.createElement('div');
previewBox.className = 'hero-banner-preview p-4 rounded text-white my-2';
previewBox.style.position = 'relative';
previewBox.style.minHeight = this.data.height || '350px';
previewBox.style.display = 'flex';
previewBox.style.flexDirection = 'column';
previewBox.style.justifyContent = 'center';
previewBox.style.alignItems = 'center';
previewBox.style.textAlign = 'center';
previewBox.style.backgroundSize = 'cover';
previewBox.style.backgroundPosition = 'center';
previewBox.style.overflow = 'hidden';
const overlay = document.createElement('div');
overlay.style.position = 'absolute';
overlay.style.top = '0';
overlay.style.left = '0';
overlay.style.right = '0';
overlay.style.bottom = '0';
overlay.style.background = '#000';
overlay.style.zIndex = '1';
previewBox.appendChild(overlay);
const contentBox = document.createElement('div');
contentBox.style.position = 'relative';
contentBox.style.zIndex = '2';
previewBox.appendChild(contentBox);
const updatePreview = () => {
const bg = this.bgImageInput.querySelector('input').value.trim();
const title = this.titleInput.querySelector('input').value.trim() || 'Hero Banner Title';
const sub = this.subtitleInput.querySelector('input').value.trim();
const btnT = this.btnTextInput.querySelector('input').value.trim();
const bannerH = this.heightInput.querySelector('input').value.trim() || '350px';
previewBox.style.minHeight = bannerH;
previewBox.style.backgroundImage = bg ? 'url("' + bg + '")' : 'linear-gradient(135deg, #002554, #881C1C)';
overlay.style.opacity = this.data.overlayOpacity || '0.4';
let html = '<h4 class="font-weight-bold mb-1 text-white">' + title + '</h4>';
if (sub) html += '<p class="small mb-2 text-light">' + sub + '</p>';
if (btnT) html += '<span class="btn btn-sm btn-danger font-weight-bold px-3">' + btnT + '</span>';
contentBox.innerHTML = html;
};
[this.titleInput, this.subtitleInput, this.bgImageInput].forEach(wrapper => {
const input = wrapper.querySelector('input');
input.addEventListener('input', updatePreview);
if (this.readOnly) input.disabled = true;
container.appendChild(wrapper);
});
[this.heightInput, this.btnTextInput, this.btnLinkInput].forEach(wrapper => {
const input = wrapper.querySelector('input');
input.addEventListener('input', updatePreview);
if (this.readOnly) input.disabled = true;
});
container.appendChild(configRow);
container.appendChild(previewBox);
updatePreview();
return container;
}
_createInput(labelText, placeholder, value) {
const wrapper = document.createElement('div');
wrapper.className = 'form-group mb-2';
const lbl = document.createElement('label');
lbl.className = 'small font-weight-bold text-secondary mb-1 d-block';
lbl.innerText = labelText;
const inp = document.createElement('input');
inp.type = 'text';
inp.className = 'form-control form-control-sm';
inp.placeholder = placeholder;
inp.value = value || '';
wrapper.appendChild(lbl);
wrapper.appendChild(inp);
return wrapper;
}
_createImageInput(labelText, placeholder, value) {
const wrapper = document.createElement('div');
wrapper.className = 'form-group mb-2';
const lbl = document.createElement('label');
lbl.className = 'small font-weight-bold text-secondary mb-1 d-block';
lbl.innerText = labelText;
wrapper.appendChild(lbl);
const inputGroup = document.createElement('div');
inputGroup.className = 'input-group input-group-sm';
const inp = document.createElement('input');
inp.type = 'text';
inp.className = 'form-control';
inp.placeholder = placeholder;
inp.value = value || '';
if (this.readOnly) inp.disabled = true;
inputGroup.appendChild(inp);
if (!this.readOnly) {
const appendDiv = document.createElement('div');
appendDiv.className = 'input-group-append';
const mediaBtn = document.createElement('button');
mediaBtn.type = 'button';
mediaBtn.className = 'btn btn-outline-info';
mediaBtn.innerHTML = '<i class="fas fa-images"></i> Media Library';
mediaBtn.addEventListener('click', () => {
if (window.SISMediaPicker) {
SISMediaPicker.open((selectedUrl, mediaObj, config) => {
inp.value = selectedUrl;
if (config) {
this._bgImageConfig = config;
}
// Dispatch input event to trigger preview update
inp.dispatchEvent(new Event('input', { bubbles: true }));
});
} else {
alert('Media modal function openSISMediaModal is not available.');
}
});
appendDiv.appendChild(mediaBtn);
inputGroup.appendChild(appendDiv);
}
wrapper.appendChild(inputGroup);
return wrapper;
}
save(blockContent) {
const stretchCheck = blockContent.querySelector('.custom-control-input');
return {
title: this.titleInput ? this.titleInput.querySelector('input').value : this.data.title,
subtitle: this.subtitleInput ? this.subtitleInput.querySelector('input').value : this.data.subtitle,
bgImage: this.bgImageInput ? this.bgImageInput.querySelector('input').value : this.data.bgImage,
bgImageStyle: (this._bgImageConfig && this._bgImageConfig.style) || this.data.bgImageStyle || '',
bgImageClass: (this._bgImageConfig && this._bgImageConfig.cssClass) || this.data.bgImageClass || '',
bgImageAlt: (this._bgImageConfig && this._bgImageConfig.alt) || this.data.bgImageAlt || '',
bgImageAttrs: (this._bgImageConfig && this._bgImageConfig.customAttributes) || this.data.bgImageAttrs || '',
bgImageAspectRatio: (this._bgImageConfig && this._bgImageConfig.aspectRatio) || this.data.bgImageAspectRatio || '',
btnText: this.btnTextInput ? this.btnTextInput.querySelector('input').value : this.data.btnText,
btnLink: this.btnLinkInput ? this.btnLinkInput.querySelector('input').value : this.data.btnLink,
height: this.heightInput ? this.heightInput.querySelector('input').value : this.data.height,
textAlign: this.data.textAlign || 'center',
overlayOpacity: this.data.overlayOpacity || '0.4',
stretched: stretchCheck ? stretchCheck.checked : !!this.data.stretched
};
}
}
// Register plugin globally
window.SISEditorPlugins = window.SISEditorPlugins || {};
window.SISEditorPlugins['hero'] = { class: SISHeroBannerTool };
@@ -0,0 +1,473 @@
/**
* SISHoverCardTool — Dedicated Hover Cards Block Tool for Editor.js
* Allows content managers to freely add, remove, reorder, and configure interactive hover cards.
*/
class SISHoverCardTool {
static get toolbox() {
return {
title: 'Hover Cards',
icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"></rect><rect x="14" y="3" width="7" height="7" rx="1"></rect><rect x="14" y="14" width="7" height="7" rx="1"></rect><rect x="3" y="14" width="7" height="7" rx="1"></rect></svg>'
};
}
constructor({ data, api, readOnly }) {
this.api = api;
this.readOnly = readOnly;
this.instanceId = 'hc_' + Math.random().toString(36).substring(7);
this.data = {
cols: parseInt(data && data.cols) || 4,
styleVariant: (data && data.styleVariant) || 'v1',
globalClass: (data && data.globalClass) || 'sis-hover-card-section',
globalId: (data && data.globalId) || '',
globalAttributes: (data && data.globalAttributes) || (data && data.attributes) || '',
items: (data && data.items && Array.isArray(data.items)) ? data.items : []
};
// Add initial default item if list is empty
if (this.data.items.length === 0) {
this.data.items.push({
title: 'Card Title',
description: 'Card description text...',
imageUrl: '',
active: true
});
}
this.wrapper = undefined;
}
render() {
this.wrapper = document.createElement('div');
this.wrapper.className = 'p-3 bg-light border rounded mb-3 ce-hovercard-tool-wrapper';
this.wrapper.style.fontFamily = 'inherit';
const titleLabel = document.createElement('div');
titleLabel.className = 'font-weight-bold text-primary small mb-3';
titleLabel.innerHTML = '<i class="fas fa-th-large"></i> Dedicated Hover Cards Settings';
this.wrapper.appendChild(titleLabel);
// Global Settings Row
const settingsRow = document.createElement('div');
settingsRow.className = 'form-row mb-3 pb-2 border-bottom';
// 1. Cols selector
const colDiv = document.createElement('div');
colDiv.className = 'col-md-3 mb-2';
colDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Columns per Row</label>';
const colSelect = document.createElement('select');
colSelect.className = 'form-control form-control-sm';
[1, 2, 3, 4, 6].forEach(num => {
const opt = document.createElement('option');
opt.value = num;
opt.textContent = `${num} Column${num > 1 ? 's' : ''}`;
if (num === this.data.cols) opt.selected = true;
colSelect.appendChild(opt);
});
if (this.readOnly) colSelect.disabled = true;
colSelect.addEventListener('change', (e) => {
this.data.cols = parseInt(e.target.value) || 4;
});
this.colsSelect = colSelect;
colDiv.appendChild(colSelect);
settingsRow.appendChild(colDiv);
// 2. Style Variant Selector (Hover 1 vs Hover 2)
const variantDiv = document.createElement('div');
variantDiv.className = 'col-md-3 mb-2';
variantDiv.innerHTML = '<label class="small font-weight-bold text-primary mb-1"><i class="fas fa-palette"></i> Card Style Variant</label>';
const variantSelect = document.createElement('select');
variantSelect.className = 'form-control form-control-sm';
const optV1 = document.createElement('option');
optV1.value = 'v1';
optV1.textContent = 'Hover 1 (Standard Light)';
if (this.data.styleVariant === 'v1') optV1.selected = true;
variantSelect.appendChild(optV1);
const optV2 = document.createElement('option');
optV2.value = 'v2';
optV2.textContent = 'Hover 2 (Modern Blue & Glow)';
if (this.data.styleVariant === 'v2') optV2.selected = true;
variantSelect.appendChild(optV2);
if (this.readOnly) variantSelect.disabled = true;
variantSelect.addEventListener('change', (e) => {
this.data.styleVariant = e.target.value;
});
this.variantSelect = variantSelect;
variantDiv.appendChild(variantSelect);
settingsRow.appendChild(variantDiv);
// 2. Global CSS Class
const classDiv = document.createElement('div');
classDiv.className = 'col-md-3 mb-2';
classDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Global CSS Class(es)</label>';
const classInput = document.createElement('input');
classInput.type = 'text';
classInput.className = 'form-control form-control-sm';
classInput.placeholder = 'e.g. sis-hover-card-section';
classInput.value = this.data.globalClass || '';
if (this.readOnly) classInput.disabled = true;
classInput.addEventListener('input', (e) => this.data.globalClass = e.target.value.trim());
this.globalClassInput = classInput;
classDiv.appendChild(classInput);
settingsRow.appendChild(classDiv);
// 3. Global HTML ID
const idDiv = document.createElement('div');
idDiv.className = 'col-md-3 mb-2';
idDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Global HTML ID</label>';
const idInput = document.createElement('input');
idInput.type = 'text';
idInput.className = 'form-control form-control-sm';
idInput.placeholder = 'e.g. hover-cards-section';
idInput.value = this.data.globalId || '';
if (this.readOnly) idInput.disabled = true;
idInput.addEventListener('input', (e) => this.data.globalId = e.target.value.trim());
this.globalIdInput = idInput;
idDiv.appendChild(idInput);
settingsRow.appendChild(idDiv);
// 4. Global Custom Attributes
const attrDiv = document.createElement('div');
attrDiv.className = 'col-md-3 mb-2';
attrDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1"><i class="fas fa-sliders-h"></i> Custom Attributes</label>';
const attrInput = document.createElement('input');
attrInput.type = 'text';
attrInput.className = 'form-control form-control-sm';
attrInput.placeholder = 'e.g. data-aos="fade-up" style="..."';
attrInput.value = this.data.globalAttributes || '';
if (this.readOnly) attrInput.disabled = true;
attrInput.addEventListener('input', (e) => this.data.globalAttributes = e.target.value.trim());
this.globalAttrInput = attrInput;
attrDiv.appendChild(attrInput);
settingsRow.appendChild(attrDiv);
this.wrapper.appendChild(settingsRow);
// Cards items container
this.itemsContainer = document.createElement('div');
this.wrapper.appendChild(this.itemsContainer);
this._renderAllItems();
if (!this.readOnly) {
const addButton = document.createElement('button');
addButton.type = 'button';
addButton.className = 'btn btn-sm btn-success mt-2';
addButton.innerHTML = '<i class="fas fa-plus"></i> Add Hover Card';
addButton.addEventListener('click', () => {
this.data.items.push({
title: 'New Card Title',
description: 'New card description text...',
imageUrl: '',
active: false
});
this._renderAllItems();
});
this.wrapper.appendChild(addButton);
}
return this.wrapper;
}
_renderAllItems() {
this.itemsContainer.innerHTML = '';
this.data.items.forEach((item, index) => {
this._renderItem(item, index);
});
}
_renderItem(item, index) {
const itemBox = document.createElement('div');
itemBox.className = 'mb-3 p-3 bg-white border rounded shadow-sm';
itemBox.style.position = 'relative';
// Top Control Action buttons (Move Up, Move Down, Delete)
if (!this.readOnly) {
const actionGroup = document.createElement('div');
actionGroup.style.position = 'absolute';
actionGroup.style.top = '10px';
actionGroup.style.right = '10px';
actionGroup.className = 'btn-group btn-group-sm';
if (index > 0) {
const upBtn = document.createElement('button');
upBtn.type = 'button';
upBtn.className = 'btn btn-outline-secondary';
upBtn.innerHTML = '<i class="fas fa-arrow-up"></i>';
upBtn.title = 'Move Up';
upBtn.addEventListener('click', () => {
const temp = this.data.items[index];
this.data.items[index] = this.data.items[index - 1];
this.data.items[index - 1] = temp;
this._renderAllItems();
});
actionGroup.appendChild(upBtn);
}
if (index < this.data.items.length - 1) {
const downBtn = document.createElement('button');
downBtn.type = 'button';
downBtn.className = 'btn btn-outline-secondary';
downBtn.innerHTML = '<i class="fas fa-arrow-down"></i>';
downBtn.title = 'Move Down';
downBtn.addEventListener('click', () => {
const temp = this.data.items[index];
this.data.items[index] = this.data.items[index + 1];
this.data.items[index + 1] = temp;
this._renderAllItems();
});
actionGroup.appendChild(downBtn);
}
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'btn btn-danger';
removeBtn.innerHTML = '<i class="fas fa-trash-alt"></i>';
removeBtn.title = 'Remove Card';
removeBtn.addEventListener('click', () => {
this.data.items.splice(index, 1);
this._renderAllItems();
});
actionGroup.appendChild(removeBtn);
itemBox.appendChild(actionGroup);
}
// Header Label
const itemHeader = document.createElement('h6');
itemHeader.className = 'font-weight-bold text-dark mb-3';
itemHeader.innerHTML = `<i class="fas fa-id-card text-info mr-1"></i> Card #${index + 1}`;
itemBox.appendChild(itemHeader);
// 1. Card Title
const titleGroup = document.createElement('div');
titleGroup.className = 'form-group mb-2';
titleGroup.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Card Title</label>';
const titleInput = document.createElement('input');
titleInput.type = 'text';
titleInput.className = 'form-control form-control-sm';
titleInput.placeholder = 'e.g. Tiên phong';
titleInput.value = item.title || '';
if (this.readOnly) titleInput.disabled = true;
titleInput.addEventListener('input', (e) => { item.title = e.target.value; });
titleGroup.appendChild(titleInput);
itemBox.appendChild(titleGroup);
// 2. Card Description
const descGroup = document.createElement('div');
descGroup.className = 'form-group mb-2';
descGroup.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Card Description</label>';
const descInput = document.createElement('textarea');
descInput.className = 'form-control form-control-sm';
descInput.rows = 2;
descInput.placeholder = 'e.g. Phát triển và ứng dụng kỹ thuật tiên tiến...';
descInput.value = item.description || '';
if (this.readOnly) descInput.disabled = true;
descInput.addEventListener('input', (e) => { item.description = e.target.value; });
descGroup.appendChild(descInput);
itemBox.appendChild(descGroup);
// 3. Image URL + Media Library Selector
const imgGroup = document.createElement('div');
imgGroup.className = 'form-group mb-2';
imgGroup.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Card Icon / Image URL</label>';
const inputGroup = document.createElement('div');
inputGroup.className = 'input-group input-group-sm mb-2';
const imgInput = document.createElement('input');
imgInput.type = 'text';
imgInput.className = 'form-control';
imgInput.placeholder = '/uploads/2026/07/icon.jpg';
imgInput.value = item.imageUrl || '';
if (this.readOnly) imgInput.disabled = true;
const previewContainer = document.createElement('div');
previewContainer.className = 'mt-1';
const updatePreview = (url) => {
previewContainer.innerHTML = '';
if (url) {
const img = document.createElement('img');
img.src = url;
img.style.maxHeight = '60px';
img.style.maxWidth = '100px';
img.style.objectFit = 'contain';
img.className = 'border rounded p-1 bg-light';
previewContainer.appendChild(img);
}
};
updatePreview(item.imageUrl);
imgInput.addEventListener('input', (e) => {
item.imageUrl = e.target.value.trim();
updatePreview(item.imageUrl);
});
inputGroup.appendChild(imgInput);
if (!this.readOnly) {
const appendDiv = document.createElement('div');
appendDiv.className = 'input-group-append';
const mediaBtn = document.createElement('button');
mediaBtn.type = 'button';
mediaBtn.className = 'btn btn-outline-info';
mediaBtn.innerHTML = '<i class="fas fa-images"></i> Media Library';
mediaBtn.addEventListener('click', () => {
if (window.SISMediaPicker) {
SISMediaPicker.open((selectedUrl, mediaObj, config) => {
imgInput.value = selectedUrl;
item.imageUrl = selectedUrl;
if (config) {
item.imageStyle = config.style || '';
item.imageClass = config.cssClass || '';
item.imageAlt = config.alt || '';
item.imageAttrs = config.customAttributes || '';
item.imageAspectRatio = config.aspectRatio || '';
}
updatePreview(selectedUrl);
});
} else {
alert('Media modal function openSISMediaModal is not available.');
}
});
appendDiv.appendChild(mediaBtn);
inputGroup.appendChild(appendDiv);
}
imgGroup.appendChild(inputGroup);
imgGroup.appendChild(previewContainer);
itemBox.appendChild(imgGroup);
// 4. Hover Icon / Image URL (Swapped on hover)
const hoverImgGroup = document.createElement('div');
hoverImgGroup.className = 'form-group mb-2';
hoverImgGroup.innerHTML = '<label class="small font-weight-bold text-info mb-1"><i class="fas fa-sync-alt"></i> Hover Icon / Image URL (Optional - Swaps on Hover)</label>';
const hoverInputGroup = document.createElement('div');
hoverInputGroup.className = 'input-group input-group-sm mb-2';
const hoverImgInput = document.createElement('input');
hoverImgInput.type = 'text';
hoverImgInput.className = 'form-control';
hoverImgInput.placeholder = '/uploads/2026/07/hover-icon.jpg (optional)';
hoverImgInput.value = item.hoverImageUrl || '';
if (this.readOnly) hoverImgInput.disabled = true;
const hoverPreviewContainer = document.createElement('div');
hoverPreviewContainer.className = 'mt-1';
const updateHoverPreview = (url) => {
hoverPreviewContainer.innerHTML = '';
if (url) {
const img = document.createElement('img');
img.src = url;
img.style.maxHeight = '60px';
img.style.maxWidth = '100px';
img.style.objectFit = 'contain';
img.className = 'border rounded p-1 bg-info text-white';
hoverPreviewContainer.appendChild(img);
}
};
updateHoverPreview(item.hoverImageUrl);
hoverImgInput.addEventListener('input', (e) => {
item.hoverImageUrl = e.target.value.trim();
updateHoverPreview(item.hoverImageUrl);
});
hoverInputGroup.appendChild(hoverImgInput);
if (!this.readOnly) {
const appendDiv = document.createElement('div');
appendDiv.className = 'input-group-append';
const mediaBtn = document.createElement('button');
mediaBtn.type = 'button';
mediaBtn.className = 'btn btn-outline-info';
mediaBtn.innerHTML = '<i class="fas fa-images"></i> Media Library';
mediaBtn.addEventListener('click', () => {
if (window.SISMediaPicker) {
SISMediaPicker.open((selectedUrl, mediaObj, config) => {
hoverImgInput.value = selectedUrl;
item.hoverImageUrl = selectedUrl;
if (config) {
item.hoverImageStyle = config.style || '';
item.hoverImageClass = config.cssClass || '';
item.hoverImageAlt = config.alt || '';
item.hoverImageAttrs = config.customAttributes || '';
item.hoverImageAspectRatio = config.aspectRatio || '';
}
updateHoverPreview(selectedUrl);
});
} else {
alert('Media modal function openSISMediaModal is not available.');
}
});
appendDiv.appendChild(mediaBtn);
hoverInputGroup.appendChild(appendDiv);
}
hoverImgGroup.appendChild(hoverInputGroup);
hoverImgGroup.appendChild(hoverPreviewContainer);
// Custom Attributes field for individual card
const cardAttrDiv = document.createElement('div');
cardAttrDiv.className = 'form-group mb-2';
cardAttrDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1"><i class="fas fa-sliders-h"></i> Card Custom Attributes</label>';
const cardAttrInput = document.createElement('input');
cardAttrInput.type = 'text';
cardAttrInput.className = 'form-control form-control-sm';
cardAttrInput.placeholder = 'e.g. data-aos="fade-up" style="background:#fff"';
cardAttrInput.value = item.attributes || '';
if (this.readOnly) cardAttrInput.disabled = true;
cardAttrInput.addEventListener('input', (e) => {
item.attributes = e.target.value.trim();
});
cardAttrDiv.appendChild(cardAttrInput);
itemBox.appendChild(cardAttrDiv);
// Default Active Radio (Only 1 option active at a time)
const activeRadioDiv = document.createElement('div');
activeRadioDiv.className = 'custom-control custom-radio mt-2';
const activeRadio = document.createElement('input');
activeRadio.type = 'radio';
activeRadio.name = 'hovercard_active_' + this.instanceId;
activeRadio.className = 'custom-control-input';
const radioId = 'hovercard_active_' + index + '_' + Math.random().toString(36).substring(7);
activeRadio.id = radioId;
activeRadio.checked = !!item.active;
if (this.readOnly) activeRadio.disabled = true;
activeRadio.addEventListener('change', () => {
this.data.items.forEach((it, i) => {
it.active = (i === index);
});
});
const activeLabel = document.createElement('label');
activeLabel.className = 'custom-control-label small font-weight-bold text-secondary';
activeLabel.htmlFor = radioId;
activeLabel.innerHTML = 'Default Active Card (Only 1 active at a time)';
activeRadioDiv.appendChild(activeRadio);
activeRadioDiv.appendChild(activeLabel);
itemBox.appendChild(activeRadioDiv);
this.itemsContainer.appendChild(itemBox);
}
save() {
return {
cols: this.colsSelect ? parseInt(this.colsSelect.value) : (this.data.cols || 4),
styleVariant: this.variantSelect ? this.variantSelect.value : (this.data.styleVariant || 'v1'),
globalClass: this.globalClassInput ? this.globalClassInput.value.trim() : (this.data.globalClass || 'sis-hover-card-section'),
globalId: this.globalIdInput ? this.globalIdInput.value.trim() : (this.data.globalId || ''),
globalAttributes: this.globalAttrInput ? this.globalAttrInput.value.trim() : (this.data.globalAttributes || ''),
items: this.data.items || []
};
}
}
// Register the plugin globally
window.SISEditorPlugins = window.SISEditorPlugins || {};
window.SISEditorPlugins['hovercard'] = { class: SISHoverCardTool };
@@ -0,0 +1,91 @@
/**
* Custom Editor.js Block Tool for embedding predefined HTML snippets.
* Uses iframe for WYSIWYG preview - snippets look exactly like on the live page.
*/
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 || '',
globalAttributes: data.globalAttributes || data.attributes || ''
};
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.borderRadius = '5px';
this.wrapper.style.background = '#fafafa';
this.wrapper.style.width = '100%';
this.wrapper.style.boxSizing = 'border-box';
this._showInput();
return this.wrapper;
}
_showInput() {
this.wrapper.innerHTML = '';
const title = document.createElement('h4');
title.style.marginTop = '0';
title.style.marginBottom = '10px';
title.style.padding = '15px 15px 0 15px';
title.innerText = 'HTML Snippet ID & Custom Attributes:';
title.style.fontSize = '14px';
title.style.color = '#333';
const inputContainer = document.createElement('div');
inputContainer.style.display = 'flex';
inputContainer.style.gap = '10px';
inputContainer.style.padding = '0 15px 15px 15px';
const input = document.createElement('input');
input.classList.add('ce-input');
input.placeholder = 'Enter Snippet ID (e.g., test_banner)';
input.value = this.data.id;
input.style.flex = '1';
input.addEventListener('input', (e) => {
this.data.id = e.target.value.trim();
});
const attrInput = document.createElement('input');
attrInput.classList.add('ce-input');
attrInput.placeholder = 'Custom Attributes (e.g. data-aos="fade-up" style="...")';
attrInput.value = this.data.globalAttributes || '';
attrInput.style.flex = '1';
attrInput.addEventListener('input', (e) => {
this.data.globalAttributes = e.target.value.trim();
});
inputContainer.appendChild(input);
inputContainer.appendChild(attrInput);
this.wrapper.appendChild(title);
this.wrapper.appendChild(inputContainer);
}
save(blockContent) {
return {
id: this.data.id,
globalAttributes: this.data.globalAttributes || ''
};
}
}
// Register the plugin globally
window.SISEditorPlugins = window.SISEditorPlugins || {};
window.SISEditorPlugins['snippet'] = {
class: HtmlSnippetTool
};
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
(function(){"use strict";try{if(typeof document<"u"){var e=document.createElement("style");e.appendChild(document.createTextNode(".inline-code{background:rgba(250,239,240,.78);color:#b44437;padding:3px 4px;border-radius:5px;margin:0 1px;font-family:inherit;font-size:.86em;font-weight:500;letter-spacing:.3px}")),document.head.appendChild(e)}}catch(n){console.error("vite-plugin-css-injected-by-js",n)}})();
(function(i,s){typeof exports=="object"&&typeof module<"u"?module.exports=s():typeof define=="function"&&define.amd?define(s):(i=typeof globalThis<"u"?globalThis:i||self,i.InlineCode=s())})(this,function(){"use strict";const i="",s='<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 8L5 12L9 16"/><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 8L19 12L15 16"/></svg>';class n{constructor({api:t}){this.tag="CODE",this.api=t,this.button=null,this.iconClasses={base:this.api.styles.inlineToolButton,active:this.api.styles.inlineToolButtonActive}}static get CSS(){return"inline-code"}static get isInline(){return!0}render(){return this.button=document.createElement("button"),this.button.type="button",this.button.classList.add(this.iconClasses.base),this.button.innerHTML=this.toolboxIcon,this.button}surround(t){if(!t)return;let e=this.api.selection.findParentTag(this.tag,n.CSS);e?this.unwrap(e):this.wrap(t)}wrap(t){let e=document.createElement(this.tag);e.classList.add(n.CSS),e.appendChild(t.extractContents()),t.insertNode(e),this.api.selection.expandToTag(e)}unwrap(t){var r;this.api.selection.expandToTag(t);const e=window.getSelection();if(!e)return;const o=e.getRangeAt(0),a=o.extractContents();(r=t.parentNode)==null||r.removeChild(t),o.insertNode(a),e.removeAllRanges(),e.addRange(o)}checkState(){const t=this.api.selection.findParentTag(this.tag,n.CSS);return this.button&&this.button.classList.toggle(this.iconClasses.active,!!t),!!t}get toolboxIcon(){return s}static get sanitize(){return{code:{class:n.CSS}}}}return n});
@@ -0,0 +1 @@
(function(factory){if(typeof define==="function"&&define.amd){define(["jquery"],function($){return factory($)})}else if(typeof module==="object"&&typeof module.exports==="object"){exports=factory(require("jquery"))}else{factory(jQuery)}})(function($){$.easing.jswing=$.easing.swing;var pow=Math.pow,sqrt=Math.sqrt,sin=Math.sin,cos=Math.cos,PI=Math.PI,c1=1.70158,c2=c1*1.525,c3=c1+1,c4=2*PI/3,c5=2*PI/4.5;function bounceOut(x){var n1=7.5625,d1=2.75;if(x<1/d1){return n1*x*x}else if(x<2/d1){return n1*(x-=1.5/d1)*x+.75}else if(x<2.5/d1){return n1*(x-=2.25/d1)*x+.9375}else{return n1*(x-=2.625/d1)*x+.984375}}$.extend($.easing,{def:"easeOutQuad",swing:function(x){return $.easing[$.easing.def](x)},easeInQuad:function(x){return x*x},easeOutQuad:function(x){return 1-(1-x)*(1-x)},easeInOutQuad:function(x){return x<.5?2*x*x:1-pow(-2*x+2,2)/2},easeInCubic:function(x){return x*x*x},easeOutCubic:function(x){return 1-pow(1-x,3)},easeInOutCubic:function(x){return x<.5?4*x*x*x:1-pow(-2*x+2,3)/2},easeInQuart:function(x){return x*x*x*x},easeOutQuart:function(x){return 1-pow(1-x,4)},easeInOutQuart:function(x){return x<.5?8*x*x*x*x:1-pow(-2*x+2,4)/2},easeInQuint:function(x){return x*x*x*x*x},easeOutQuint:function(x){return 1-pow(1-x,5)},easeInOutQuint:function(x){return x<.5?16*x*x*x*x*x:1-pow(-2*x+2,5)/2},easeInSine:function(x){return 1-cos(x*PI/2)},easeOutSine:function(x){return sin(x*PI/2)},easeInOutSine:function(x){return-(cos(PI*x)-1)/2},easeInExpo:function(x){return x===0?0:pow(2,10*x-10)},easeOutExpo:function(x){return x===1?1:1-pow(2,-10*x)},easeInOutExpo:function(x){return x===0?0:x===1?1:x<.5?pow(2,20*x-10)/2:(2-pow(2,-20*x+10))/2},easeInCirc:function(x){return 1-sqrt(1-pow(x,2))},easeOutCirc:function(x){return sqrt(1-pow(x-1,2))},easeInOutCirc:function(x){return x<.5?(1-sqrt(1-pow(2*x,2)))/2:(sqrt(1-pow(-2*x+2,2))+1)/2},easeInElastic:function(x){return x===0?0:x===1?1:-pow(2,10*x-10)*sin((x*10-10.75)*c4)},easeOutElastic:function(x){return x===0?0:x===1?1:pow(2,-10*x)*sin((x*10-.75)*c4)+1},easeInOutElastic:function(x){return x===0?0:x===1?1:x<.5?-(pow(2,20*x-10)*sin((20*x-11.125)*c5))/2:pow(2,-20*x+10)*sin((20*x-11.125)*c5)/2+1},easeInBack:function(x){return c3*x*x*x-c1*x*x},easeOutBack:function(x){return 1+c3*pow(x-1,3)+c1*pow(x-1,2)},easeInOutBack:function(x){return x<.5?pow(2*x,2)*((c2+1)*2*x-c2)/2:(pow(2*x-2,2)*((c2+1)*(x*2-2)+c2)+2)/2},easeInBounce:function(x){return 1-bounceOut(1-x)},easeOutBounce:bounceOut,easeInOutBounce:function(x){return x<.5?(1-bounceOut(1-2*x))/2:(1+bounceOut(2*x-1))/2}})});
@@ -0,0 +1,2 @@
(function(){"use strict";try{if(typeof document<"u"){var e=document.createElement("style");e.appendChild(document.createTextNode(".cdx-marker{background:rgba(245,235,111,.29);padding:3px 0}")),document.head.appendChild(e)}}catch(d){console.error("vite-plugin-css-injected-by-js",d)}})();
(function(i,s){typeof exports=="object"&&typeof module<"u"?module.exports=s():typeof define=="function"&&define.amd?define(s):(i=typeof globalThis<"u"?globalThis:i||self,i.Marker=s())})(this,function(){"use strict";const i="",s='<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-width="2" d="M11.3536 9.31802L12.7678 7.90381C13.5488 7.12276 14.8151 7.12276 15.5962 7.90381C16.3772 8.68486 16.3772 9.95119 15.5962 10.7322L14.182 12.1464M11.3536 9.31802L7.96729 12.7043C7.40889 13.2627 7.02827 13.9739 6.8734 14.7482L6.69798 15.6253C6.55804 16.325 7.17496 16.942 7.87468 16.802L8.75176 16.6266C9.52612 16.4717 10.2373 16.0911 10.7957 15.5327L14.182 12.1464M11.3536 9.31802L14.182 12.1464"/><line x1="15" x2="19" y1="17" y2="17" stroke="currentColor" stroke-linecap="round" stroke-width="2"/></svg>';class n{static get CSS(){return"cdx-marker"}constructor({api:t}){this.api=t,this.button=null,this.tag="MARK",this.iconClasses={base:this.api.styles.inlineToolButton,active:this.api.styles.inlineToolButtonActive}}static get isInline(){return!0}render(){return this.button=document.createElement("button"),this.button.type="button",this.button.classList.add(this.iconClasses.base),this.button.innerHTML=this.toolboxIcon,this.button}surround(t){if(!t)return;let e=this.api.selection.findParentTag(this.tag,n.CSS);e?this.unwrap(e):this.wrap(t)}wrap(t){let e=document.createElement(this.tag);e.classList.add(n.CSS),e.appendChild(t.extractContents()),t.insertNode(e),this.api.selection.expandToTag(e)}unwrap(t){this.api.selection.expandToTag(t);let e=window.getSelection(),o=e.getRangeAt(0),a=o.extractContents();t.parentNode.removeChild(t),o.insertNode(a),e.removeAllRanges(),e.addRange(o)}checkState(){const t=this.api.selection.findParentTag(this.tag,n.CSS);this.button.classList.toggle(this.iconClasses.active,!!t)}get toolboxIcon(){return s}static get sanitize(){return{mark:{class:n.CSS}}}}return n});
@@ -0,0 +1,314 @@
/**
* SISStickyNavTool — Sticky Navigation & Sub-menu Plugin for Editor.js
* Allows content managers to build dynamic page navigation sub-menus with smooth anchor links.
*/
class SISStickyNavTool {
static get toolbox() {
return {
title: 'Sticky Nav / Sub-menu',
icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>'
};
}
constructor({ data, api, readOnly }) {
this.api = api;
this.readOnly = readOnly;
this.data = {
title: (data && data.title) ? data.title : 'Quick Navigation',
bgColor: (data && data.bgColor) ? data.bgColor : '#002554',
globalClass: (data && data.globalClass) || '',
globalId: (data && data.globalId) || '',
globalAttributes: (data && data.globalAttributes) || (data && data.attributes) || '',
items: (data && Array.isArray(data.items)) ? data.items : [
{ label: 'Giới thiệu', link: '#sec-intro' },
{ label: 'Chuyên khoa', link: '#sec-specialty' },
{ label: 'Quy trình', link: '#sec-process' }
],
sticky: (data && data.sticky !== undefined) ? !!data.sticky : true,
stretched: (data && data.stretched !== undefined) ? !!data.stretched : true
};
this.titleInput = null;
this.itemsContainer = null;
}
render() {
const container = document.createElement('div');
container.style.border = '1px solid #e3e6f0';
container.style.borderRadius = '8px';
container.style.padding = '14px';
container.style.background = '#fff';
container.style.marginBottom = '12px';
const headerLabel = document.createElement('label');
headerLabel.className = 'font-weight-bold text-primary small mb-2 d-block';
headerLabel.innerHTML = '<i class="fas fa-bars"></i> Sticky Page Navigation Bar Settings';
container.appendChild(headerLabel);
// Settings Row for Title, Class, ID & Attributes
const metaRow = document.createElement('div');
metaRow.className = 'form-row mb-3';
// 1. Mobile Title
const titleDiv = document.createElement('div');
titleDiv.className = 'col-md-3 mb-2';
titleDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1 d-block">Dropdown Title</label>';
this.titleInput = document.createElement('input');
this.titleInput.type = 'text';
this.titleInput.className = 'form-control form-control-sm';
this.titleInput.placeholder = 'e.g. Quick Navigation';
this.titleInput.value = this.data.title || 'Quick Navigation';
if (this.readOnly) this.titleInput.disabled = true;
titleDiv.appendChild(this.titleInput);
metaRow.appendChild(titleDiv);
// 2. CSS Class
const classDiv = document.createElement('div');
classDiv.className = 'col-md-3 mb-2';
classDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1 d-block">CSS Class(es)</label>';
this.classInput = document.createElement('input');
this.classInput.type = 'text';
this.classInput.className = 'form-control form-control-sm';
this.classInput.placeholder = 'e.g. my-custom-nav';
this.classInput.value = this.data.globalClass || '';
if (this.readOnly) this.classInput.disabled = true;
this.classInput.addEventListener('input', (e) => {
this.data.globalClass = e.target.value.trim();
});
classDiv.appendChild(this.classInput);
metaRow.appendChild(classDiv);
// 3. HTML ID
const idDiv = document.createElement('div');
idDiv.className = 'col-md-3 mb-2';
idDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1 d-block">HTML ID</label>';
this.idInput = document.createElement('input');
this.idInput.type = 'text';
this.idInput.className = 'form-control form-control-sm';
this.idInput.placeholder = 'e.g. main-sticky-nav';
this.idInput.value = this.data.globalId || '';
if (this.readOnly) this.idInput.disabled = true;
this.idInput.addEventListener('input', (e) => {
this.data.globalId = e.target.value.trim();
});
idDiv.appendChild(this.idInput);
metaRow.appendChild(idDiv);
// 4. Custom Attributes
const attrDiv = document.createElement('div');
attrDiv.className = 'col-md-3 mb-2';
attrDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1 d-block"><i class="fas fa-sliders-h"></i> Custom Attributes</label>';
this.attrInput = document.createElement('input');
this.attrInput.type = 'text';
this.attrInput.className = 'form-control form-control-sm';
this.attrInput.placeholder = 'e.g. data-aos="fade-down" style="..."';
this.attrInput.value = this.data.globalAttributes || '';
if (this.readOnly) this.attrInput.disabled = true;
this.attrInput.addEventListener('input', (e) => {
this.data.globalAttributes = e.target.value.trim();
});
attrDiv.appendChild(this.attrInput);
metaRow.appendChild(attrDiv);
container.appendChild(metaRow);
// Switches Row (Sticky + Stretched)
const switchesRow = document.createElement('div');
switchesRow.className = 'd-flex flex-wrap gap-3 mb-3';
// Sticky switch
const stickyWrapper = document.createElement('div');
stickyWrapper.className = 'custom-control custom-switch mr-3';
const stickyCheck = document.createElement('input');
stickyCheck.type = 'checkbox';
stickyCheck.className = 'custom-control-input';
stickyCheck.id = 'nav_sticky_' + Math.random().toString(36).substring(7);
stickyCheck.checked = !!this.data.sticky;
if (this.readOnly) stickyCheck.disabled = true;
const stickyLabel = document.createElement('label');
stickyLabel.className = 'custom-control-label small font-weight-bold text-primary';
stickyLabel.htmlFor = stickyCheck.id;
stickyLabel.innerHTML = '<i class="fas fa-thumbtack"></i> Sticky to Top on Scroll';
stickyCheck.addEventListener('change', () => {
this.data.sticky = stickyCheck.checked;
});
stickyWrapper.appendChild(stickyCheck);
stickyWrapper.appendChild(stickyLabel);
switchesRow.appendChild(stickyWrapper);
// Stretch switch
const stretchWrapper = document.createElement('div');
stretchWrapper.className = 'custom-control custom-switch';
this.stretchCheck = document.createElement('input');
this.stretchCheck.type = 'checkbox';
this.stretchCheck.className = 'custom-control-input';
this.stretchCheck.id = 'nav_stretch_' + Math.random().toString(36).substring(7);
this.stretchCheck.checked = !!this.data.stretched;
if (this.readOnly) this.stretchCheck.disabled = true;
const stretchLabel = document.createElement('label');
stretchLabel.className = 'custom-control-label small font-weight-bold text-secondary';
stretchLabel.htmlFor = this.stretchCheck.id;
stretchLabel.innerHTML = '<i class="fas fa-arrows-alt-h"></i> Stretch to Full Screen Width';
this.stretchCheck.addEventListener('change', () => {
this.data.stretched = this.stretchCheck.checked;
});
stretchWrapper.appendChild(this.stretchCheck);
stretchWrapper.appendChild(stretchLabel);
switchesRow.appendChild(stretchWrapper);
container.appendChild(switchesRow);
// Background Color Selection
const bgGroup = document.createElement('div');
bgGroup.className = 'form-group mb-3';
const bgLabel = document.createElement('label');
bgLabel.className = 'small font-weight-bold text-secondary mb-1 d-block';
bgLabel.innerText = 'Background Style';
this.bgSelect = document.createElement('select');
this.bgSelect.className = 'form-control form-control-sm';
this.bgSelect.innerHTML = `
<option value="#002554">Dark Blue (SIS Primary - #002554)</option>
<option value="#881C1C">Brand Red (#881C1C)</option>
<option value="#1a252f">Charcoal Dark (#1a252f)</option>
<option value="#ffffff">White (#ffffff)</option>
`;
this.bgSelect.value = this.data.bgColor || '#002554';
if (this.readOnly) this.bgSelect.disabled = true;
bgGroup.appendChild(bgLabel);
bgGroup.appendChild(this.bgSelect);
container.appendChild(bgGroup);
// Navigation Items List
const itemsLabel = document.createElement('label');
itemsLabel.className = 'small font-weight-bold text-secondary mb-2 d-block';
itemsLabel.innerText = 'Navigation Links / Anchors';
container.appendChild(itemsLabel);
this.itemsContainer = document.createElement('div');
this.itemsContainer.className = 'nav-items-list mb-2';
container.appendChild(this.itemsContainer);
const renderItemsInputs = () => {
this.itemsContainer.innerHTML = '';
this.data.items.forEach((item, index) => {
const itemRow = document.createElement('div');
itemRow.className = 'form-row mb-2 align-items-center';
const colLabel = document.createElement('div');
colLabel.className = 'col-md-5';
const labelInp = document.createElement('input');
labelInp.type = 'text';
labelInp.className = 'form-control form-control-sm';
labelInp.placeholder = 'Link Label (e.g. Giới thiệu)';
labelInp.value = item.label || '';
if (this.readOnly) labelInp.disabled = true;
labelInp.addEventListener('input', () => {
item.label = labelInp.value;
updatePreview();
});
colLabel.appendChild(labelInp);
const colLink = document.createElement('div');
colLink.className = 'col-md-5';
const linkInp = document.createElement('input');
linkInp.type = 'text';
linkInp.className = 'form-control form-control-sm';
linkInp.placeholder = 'Target ID or URL (e.g. #sec-intro)';
linkInp.value = item.link || '';
if (this.readOnly) linkInp.disabled = true;
linkInp.addEventListener('input', () => {
item.link = linkInp.value;
updatePreview();
});
colLink.appendChild(linkInp);
const colDel = document.createElement('div');
colDel.className = 'col-md-2';
const delBtn = document.createElement('button');
delBtn.type = 'button';
delBtn.className = 'btn btn-sm btn-outline-danger btn-block';
delBtn.innerHTML = '<i class="fas fa-trash"></i>';
if (this.readOnly) delBtn.disabled = true;
delBtn.addEventListener('click', () => {
this.data.items.splice(index, 1);
renderItemsInputs();
updatePreview();
});
colDel.appendChild(delBtn);
itemRow.appendChild(colLabel);
itemRow.appendChild(colLink);
itemRow.appendChild(colDel);
this.itemsContainer.appendChild(itemRow);
});
};
if (!this.readOnly) {
const addBtn = document.createElement('button');
addBtn.type = 'button';
addBtn.className = 'btn btn-sm btn-outline-primary mb-3';
addBtn.innerHTML = '<i class="fas fa-plus"></i> Add Nav Link';
addBtn.addEventListener('click', () => {
this.data.items.push({ label: 'New Link', link: '#section' });
renderItemsInputs();
updatePreview();
});
container.appendChild(addBtn);
}
// Live Preview Box
const previewBox = document.createElement('div');
previewBox.className = 'nav-preview p-2 rounded my-2 d-flex flex-wrap justify-content-center align-items-center gap-2';
previewBox.style.transition = 'background 0.3s ease';
container.appendChild(previewBox);
const updatePreview = () => {
const bg = this.bgSelect.value;
const isLight = bg === '#ffffff';
previewBox.style.background = bg;
let html = '';
this.data.items.forEach(item => {
const label = item.label || 'Link';
const textColor = isLight ? '#002554' : '#ffffff';
html += `<span class="badge px-3 py-2 mr-2 mb-1" style="background: rgba(255,255,255,0.15); color: ${textColor}; font-size: 13px; font-weight: 600;"><i class="fas fa-link mr-1"></i>${label}</span>`;
});
previewBox.innerHTML = html || '<span class="text-muted small">No nav links added</span>';
};
this.bgSelect.addEventListener('change', () => {
this.data.bgColor = this.bgSelect.value;
updatePreview();
});
renderItemsInputs();
updatePreview();
return container;
}
save() {
return {
title: this.titleInput ? this.titleInput.value : (this.data.title || 'Quick Navigation'),
bgColor: this.bgSelect ? this.bgSelect.value : this.data.bgColor,
globalClass: this.classInput ? this.classInput.value.trim() : (this.data.globalClass || ''),
globalId: this.idInput ? this.idInput.value.trim() : (this.data.globalId || ''),
globalAttributes: this.attrInput ? this.attrInput.value.trim() : (this.data.globalAttributes || ''),
items: this.data.items || [],
sticky: this.data.sticky !== undefined ? !!this.data.sticky : true,
stretched: this.stretchCheck ? this.stretchCheck.checked : !!this.data.stretched
};
}
}
// Alias class for nav
class SISNavTool extends SISStickyNavTool {}
// Register plugin globally
window.SISEditorPlugins = window.SISEditorPlugins || {};
window.SISEditorPlugins['stickyNav'] = { class: SISStickyNavTool };
window.SISEditorPlugins['nav'] = { class: SISNavTool };
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
<!DOCTYPE html>
<!-- saved from url=(0012)about:srcdoc -->
<html><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><link rel="stylesheet" type="text/css" id="mce-u0" href="https://cdn.jsdelivr.net/npm/tinymce@6/skins/ui/oxide/content.min.css"><link rel="stylesheet" type="text/css" id="mce-u1" href="https://cdn.jsdelivr.net/npm/tinymce@6/skins/content/default/content.min.css"></head><body id="tinymce" class="mce-content-body " data-id="tinymce-editor-e06xjl" aria-label="Rich Text Area. Press ALT-0 for help." contenteditable="true" spellcheck="false"><h3 style="text-align: center;" data-mce-style="text-align: center;"><strong><span style="color: rgb(224, 62, 45);" data-mce-style="color: rgb(224, 62, 45);">Phó giám đốc điều hành</span></strong></h3><h3 style="text-align: center;" data-mce-style="text-align: center;"><strong><span style="color: rgb(224, 62, 45);" data-mce-style="color: rgb(224, 62, 45);">BS.CKII PHAN TRỊNH MINH HIẾU</span></strong></h3></body></html>
@@ -0,0 +1,3 @@
<!DOCTYPE html>
<!-- saved from url=(0012)about:srcdoc -->
<html><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><link rel="stylesheet" type="text/css" id="mce-u0" href="https://cdn.jsdelivr.net/npm/tinymce@6/skins/ui/oxide/content.min.css"><link rel="stylesheet" type="text/css" id="mce-u1" href="https://cdn.jsdelivr.net/npm/tinymce@6/skins/content/default/content.min.css"></head><body id="tinymce" class="mce-content-body " data-id="tinymce-editor-hkzhv" aria-label="Rich Text Area. Press ALT-0 for help." contenteditable="true" spellcheck="false"><h3 style="text-align: center;" data-mce-style="text-align: center;"><span style="color: rgb(224, 62, 45);" data-mce-style="color: rgb(224, 62, 45);">BS Thái</span></h3><h3 style="text-align: center;" data-mce-style="text-align: center;"><span style="color: rgb(224, 62, 45);" data-mce-style="color: rgb(224, 62, 45);">Trưởng Khoa</span></h3></body></html>
@@ -0,0 +1,3 @@
<!DOCTYPE html>
<!-- saved from url=(0012)about:srcdoc -->
<html><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><link rel="stylesheet" type="text/css" id="mce-u0" href="https://cdn.jsdelivr.net/npm/tinymce@6/skins/ui/oxide/content.min.css"><link rel="stylesheet" type="text/css" id="mce-u1" href="https://cdn.jsdelivr.net/npm/tinymce@6/skins/content/default/content.min.css"></head><body id="tinymce" class="mce-content-body " data-id="tinymce-editor-7uyna" aria-label="Rich Text Area. Press ALT-0 for help." contenteditable="true" spellcheck="false"><h3 style="text-align: center;" data-mce-style="text-align: center;"><span style="color: rgb(224, 62, 45);" data-mce-style="color: rgb(224, 62, 45);">BS Thái</span></h3><h3 style="text-align: center;" data-mce-style="text-align: center;"><span style="color: rgb(224, 62, 45);" data-mce-style="color: rgb(224, 62, 45);">Trưởng Khoa</span></h3></body></html>
@@ -0,0 +1,3 @@
<!DOCTYPE html>
<!-- saved from url=(0012)about:srcdoc -->
<html><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><link rel="stylesheet" type="text/css" id="mce-u0" href="https://cdn.jsdelivr.net/npm/tinymce@6/skins/ui/oxide/content.min.css"><link rel="stylesheet" type="text/css" id="mce-u1" href="https://cdn.jsdelivr.net/npm/tinymce@6/skins/content/default/content.min.css"></head><body id="tinymce" class="mce-content-body " data-id="tinymce-editor-h7dxj1d" aria-label="Rich Text Area. Press ALT-0 for help." contenteditable="true" spellcheck="false"><h3 style="text-align: center;" data-mce-style="text-align: center;"><span style="color: rgb(224, 62, 45);" data-mce-style="color: rgb(224, 62, 45);">BS Thái</span></h3><h3 style="text-align: center;" data-mce-style="text-align: center;"><span style="color: rgb(224, 62, 45);" data-mce-style="color: rgb(224, 62, 45);">Trưởng Khoa</span></h3></body></html>
@@ -0,0 +1,3 @@
<!DOCTYPE html>
<!-- saved from url=(0012)about:srcdoc -->
<html><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><link rel="stylesheet" type="text/css" id="mce-u0" href="https://cdn.jsdelivr.net/npm/tinymce@6/skins/ui/oxide/content.min.css"><link rel="stylesheet" type="text/css" id="mce-u1" href="https://cdn.jsdelivr.net/npm/tinymce@6/skins/content/default/content.min.css"></head><body id="tinymce" class="mce-content-body " data-id="tinymce-editor-yxw5l9" aria-label="Rich Text Area. Press ALT-0 for help." contenteditable="true" spellcheck="false"><h3 style="text-align: center;" data-mce-style="text-align: center;"><span style="color: rgb(224, 62, 45);" data-mce-style="color: rgb(224, 62, 45);">Giám đốc</span></h3><h3 style="text-align: center;" data-mce-style="text-align: center;"><span style="color: rgb(224, 62, 45);" data-mce-style="color: rgb(224, 62, 45);">TS.BS TRẦN CHÍ CƯỜNG</span></h3></body></html>
File diff suppressed because one or more lines are too long
@@ -0,0 +1,7 @@
/*!
* Start Bootstrap - SB Admin 2 v4.1.4 (https://startbootstrap.com/theme/sb-admin-2)
* Copyright 2013-2021 Start Bootstrap
* Licensed under MIT (https://github.com/StartBootstrap/startbootstrap-sb-admin-2/blob/master/LICENSE)
*/
!function(l){"use strict";l("#sidebarToggle, #sidebarToggleTop").on("click",function(e){l("body").toggleClass("sidebar-toggled"),l(".sidebar").toggleClass("toggled"),l(".sidebar").hasClass("toggled")&&l(".sidebar .collapse").collapse("hide")}),l(window).resize(function(){l(window).width()<768&&l(".sidebar .collapse").collapse("hide"),l(window).width()<480&&!l(".sidebar").hasClass("toggled")&&(l("body").addClass("sidebar-toggled"),l(".sidebar").addClass("toggled"),l(".sidebar .collapse").collapse("hide"))}),l("body.fixed-nav .sidebar").on("mousewheel DOMMouseScroll wheel",function(e){var o;768<l(window).width()&&(o=(o=e.originalEvent).wheelDelta||-o.detail,this.scrollTop+=30*(o<0?1:-1),e.preventDefault())}),l(document).on("scroll",function(){100<l(this).scrollTop()?l(".scroll-to-top").fadeIn():l(".scroll-to-top").fadeOut()}),l(document).on("click","a.scroll-to-top",function(e){var o=l(this);l("html, body").stop().animate({scrollTop:l(o.attr("href")).offset().top},1e3,"easeInOutExpo"),e.preventDefault()})}(jQuery);
Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

@@ -0,0 +1,461 @@
/**
* SIS Media Picker & Library Modal Utility
* Standalone reusable module to select and upload media files anywhere in the application.
*
* Usage (simple - just URL returned):
* window.openSISMediaModal(function(selectedUrl, selectedMediaObj) {
* console.log("Selected Image URL:", selectedUrl);
* });
*
* Usage (extended - returns URL + config object with style, attributes, size):
* SISMediaPicker.open(function(selectedUrl, selectedMediaObj, config) {
* // config = { style, customAttributes, width, height }
* });
*
* Or via object API:
* SISMediaPicker.open(callback, options);
* options = { showConfig: true } // default: true
*/
(function (window, document) {
'use strict';
var PREDEFINED_SIZES = [
{ label: 'Auto', width: '', height: '' },
{ label: '25%', width: '25%', height: '' },
{ label: '50%', width: '50%', height: '' },
{ label: '75%', width: '75%', height: '' },
{ label: '100%', width: '100%', height: '' },
{ label: '100px', width: '100px', height: '100px' },
{ label: '150px', width: '150px', height: '150px' },
{ label: '200px', width: '200px', height: '200px' },
{ label: '300px', width: '300px', height: 'auto' },
{ label: '400px', width: '400px', height: 'auto' },
{ label: '500px', width: '500px', height: 'auto' },
{ label: 'Full Width', width: '100%', height: 'auto' },
{ label: 'Thumbnail (80×80)', width: '80px', height: '80px' },
{ label: 'Avatar (120×120)', width: '120px', height: '120px' },
{ label: 'Banner (1200×400)', width: '1200px', height: '400px' },
];
var SISMediaPicker = {
callback: null,
mediaData: [],
modalId: 'sisMediaLibraryModal',
configPanelId: 'sisMediaConfigPanel',
selectedUrl: null,
selectedMediaObj: null,
initModal: function () {
var modalEl = document.getElementById(this.modalId);
if (modalEl) return modalEl;
modalEl = document.createElement('div');
modalEl.id = this.modalId;
modalEl.className = 'modal fade';
modalEl.setAttribute('tabindex', '-1');
modalEl.setAttribute('role', 'dialog');
modalEl.setAttribute('aria-hidden', 'true');
modalEl.innerHTML =
'<div class="modal-dialog modal-xl" role="document">' +
'<div class="modal-content border-0 shadow-lg">' +
'<div class="modal-header bg-primary text-white p-3">' +
'<h5 class="modal-title font-weight-bold" id="' + this.modalId + 'Label">' +
'<i class="fas fa-images mr-2"></i> Thư viện Media / Chọn Hình ảnh' +
'</h5>' +
'<button type="button" class="close text-white opacity-100" data-dismiss="modal" aria-label="Close">' +
'<span aria-hidden="true">&times;</span>' +
'</button>' +
'</div>' +
'<div class="modal-body bg-light p-3">' +
/* Toolbar row */
'<div class="d-flex flex-wrap justify-content-between align-items-center mb-3 bg-white p-2 border rounded shadow-sm">' +
'<div class="input-group input-group-sm mb-2 mb-md-0" style="max-width: 380px;">' +
'<div class="input-group-prepend"><span class="input-group-text bg-light"><i class="fas fa-search text-muted"></i></span></div>' +
'<input type="text" class="form-control" id="sisMediaSearchInput" placeholder="Tìm kiếm theo tên file..." />' +
'</div>' +
'<div>' +
'<label class="btn btn-sm btn-success mb-0 cursor-pointer shadow-sm" style="cursor: pointer;">' +
'<i class="fas fa-cloud-upload-alt mr-1"></i> Tải ảnh mới lên' +
'<input type="file" id="sisMediaUploadInput" accept="image/*" style="display: none;" />' +
'</label>' +
'</div>' +
'</div>' +
/* Media Grid */
'<div id="sisMediaGrid" class="row" style="max-height: 380px; overflow-y: auto; align-content: start;">' +
'<div class="col-12 text-center text-muted py-5"><i class="fas fa-spinner fa-spin fa-2x"></i><br/><span class="mt-2 d-block">Đang tải danh sách hình ảnh...</span></div>' +
'</div>' +
/* Config Panel - hidden until an image is selected */
'<div id="' + this.configPanelId + '" class="mt-3 p-3 bg-white border rounded shadow-sm" style="display: none;">' +
'<h6 class="font-weight-bold text-primary mb-3"><i class="fas fa-sliders-h mr-1"></i> Cấu hình hình ảnh đã chọn</h6>' +
'<div class="row">' +
/* Preview */
'<div class="col-md-3 text-center mb-3">' +
'<p class="small text-muted mb-1 font-weight-bold">Xem trước</p>' +
'<div class="border rounded p-2 bg-light" style="min-height: 100px; display: flex; align-items: center; justify-content: center;">' +
'<img id="sisMediaConfigPreview" src="" alt="Preview" style="max-width: 100%; max-height: 150px; object-fit: contain;" />' +
'</div>' +
'<p id="sisMediaConfigName" class="small text-truncate mt-1 text-muted mb-0"></p>' +
'</div>' +
/* Config Fields */
'<div class="col-md-9">' +
'<div class="row">' +
/* Predefined Sizes */
'<div class="col-12 mb-2">' +
'<label class="small font-weight-bold text-muted d-block mb-1" style="font-size: 10px; text-transform: uppercase;">Kích thước nhanh</label>' +
'<div id="sisMediaSizeButtons" class="d-flex flex-wrap gap-1" style="gap: 4px;">' +
'</div>' +
'</div>' +
/* Width */
'<div class="col-md-4 mb-2">' +
'<label class="small font-weight-bold text-muted mb-1" style="font-size: 10px;">Chiều rộng (width)</label>' +
'<input type="text" id="sisMediaConfigWidth" class="form-control form-control-sm" placeholder="e.g. 100%, 300px, auto" />' +
'</div>' +
/* Height */
'<div class="col-md-4 mb-2">' +
'<label class="small font-weight-bold text-muted mb-1" style="font-size: 10px;">Chiều cao (height)</label>' +
'<input type="text" id="sisMediaConfigHeight" class="form-control form-control-sm" placeholder="e.g. auto, 200px" />' +
'</div>' +
/* Object Fit */
'<div class="col-md-4 mb-2">' +
'<label class="small font-weight-bold text-muted mb-1" style="font-size: 10px;">Object Fit</label>' +
'<select id="sisMediaConfigObjectFit" class="form-control form-control-sm">' +
'<option value="">-- default --</option>' +
'<option value="cover">cover</option>' +
'<option value="contain">contain</option>' +
'<option value="fill">fill</option>' +
'<option value="none">none</option>' +
'<option value="scale-down">scale-down</option>' +
'</select>' +
'</div>' +
/* Aspect Ratio */
'<div class="col-md-4 mb-2">' +
'<label class="small font-weight-bold text-muted mb-1" style="font-size: 10px;">Aspect Ratio</label>' +
'<select id="sisMediaConfigAspectRatio" class="form-control form-control-sm">' +
'<option value="">-- none --</option>' +
'<option value="1/1">1:1 (Square)</option>' +
'<option value="4/3">4:3 (Standard)</option>' +
'<option value="16/9">16:9 (Widescreen)</option>' +
'<option value="21/9">21:9 (Ultrawide)</option>' +
'<option value="3/4">3:4 (Portrait)</option>' +
'<option value="9/16">9:16 (Vertical Video)</option>' +
'<option value="2/1">2:1 (Panorama)</option>' +
'<option value="3/2">3:2 (Photo)</option>' +
'<option value="5/4">5:4</option>' +
'</select>' +
'</div>' +
/* CSS Class */
'<div class="col-md-4 mb-2">' +
'<label class="small font-weight-bold text-muted mb-1" style="font-size: 10px;">CSS Class</label>' +
'<input type="text" id="sisMediaConfigClass" class="form-control form-control-sm" placeholder="e.g. img-fluid rounded shadow" />' +
'</div>' +
/* Inline Style */
'<div class="col-md-6 mb-2">' +
'<label class="small font-weight-bold text-muted mb-1" style="font-size: 10px;">Inline Style (CSS)</label>' +
'<input type="text" id="sisMediaConfigStyle" class="form-control form-control-sm" placeholder="e.g. border-radius: 8px; opacity: 0.9;" />' +
'</div>' +
/* Alt Text */
'<div class="col-md-6 mb-2">' +
'<label class="small font-weight-bold text-muted mb-1" style="font-size: 10px;">Alt Text</label>' +
'<input type="text" id="sisMediaConfigAlt" class="form-control form-control-sm" placeholder="Image description..." />' +
'</div>' +
/* Custom Attributes */
'<div class="col-md-6 mb-2">' +
'<label class="small font-weight-bold text-muted mb-1" style="font-size: 10px;">Custom Attributes (HTML)</label>' +
'<input type="text" id="sisMediaConfigAttrs" class="form-control form-control-sm" placeholder=\'e.g. data-id="5" loading="lazy"\' />' +
'</div>' +
'</div>' +
'<div class="text-right mt-2">' +
'<button type="button" class="btn btn-sm btn-secondary mr-2" id="sisMediaConfigCancel">Hủy</button>' +
'<button type="button" class="btn btn-sm btn-primary" id="sisMediaConfigConfirm"><i class="fas fa-check mr-1"></i> Chọn ảnh này</button>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
'<div class="modal-footer bg-white p-2">' +
'<button type="button" class="btn btn-sm btn-secondary px-3" data-dismiss="modal">Đóng</button>' +
'</div>' +
'</div>' +
'</div>';
document.body.appendChild(modalEl);
// Build predefined size buttons
var sizeContainer = document.getElementById('sisMediaSizeButtons');
if (sizeContainer) {
PREDEFINED_SIZES.forEach(function (size) {
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-xs btn-outline-secondary mb-1';
btn.style.fontSize = '10px';
btn.style.padding = '1px 6px';
btn.style.marginRight = '3px';
btn.innerText = size.label;
btn.addEventListener('click', function () {
document.getElementById('sisMediaConfigWidth').value = size.width;
document.getElementById('sisMediaConfigHeight').value = size.height;
sizeContainer.querySelectorAll('button').forEach(function (b) {
b.classList.remove('btn-secondary');
b.classList.add('btn-outline-secondary');
});
btn.classList.remove('btn-outline-secondary');
btn.classList.add('btn-secondary');
});
sizeContainer.appendChild(btn);
});
}
// Bind Search Input Handler
var self = this;
document.addEventListener('input', function (e) {
if (e.target && e.target.id === 'sisMediaSearchInput') {
var term = e.target.value.toLowerCase().trim();
var filtered = self.mediaData.filter(function (item) {
var name = (item.originalFilename || item.name || '').toLowerCase();
return name.includes(term);
});
self.renderGrid(filtered);
}
});
// Bind Upload Input Handler
document.addEventListener('change', function (e) {
if (e.target && e.target.id === 'sisMediaUploadInput') {
var file = e.target.files[0];
if (!file) return;
var formData = new FormData();
formData.append('file', file);
var grid = document.getElementById('sisMediaGrid');
if (grid) {
grid.innerHTML = '<div class="col-12 text-center text-info py-5"><i class="fas fa-spinner fa-spin fa-2x"></i><br/><span class="mt-2 d-block">Đang tải tệp lên server...</span></div>';
}
fetch('/api/manage/media/upload', {
method: 'POST',
body: formData
})
.then(function (res) { return res.json(); })
.then(function (data) {
if (data && data.success === 1 && data.file && data.file.url) {
self.showConfigPanel(data.file.url, data.file);
} else {
alert('Tải tệp lên thất bại. Vui lòng thử lại!');
self.fetchMediaList();
}
})
.catch(function (err) {
alert('Lỗi tải tệp: ' + err.message);
self.fetchMediaList();
});
}
});
// Config panel Cancel button
document.addEventListener('click', function (e) {
if (e.target && e.target.id === 'sisMediaConfigCancel') {
self.hideConfigPanel();
}
if (e.target && e.target.id === 'sisMediaConfigConfirm') {
self.confirmWithConfig();
}
});
return modalEl;
},
open: function (callback) {
this.callback = callback;
var modalEl = this.initModal();
if (typeof $ !== 'undefined' && $.fn && $.fn.modal) {
$(modalEl).modal('show');
} else {
modalEl.style.display = 'block';
modalEl.classList.add('show');
}
this.fetchMediaList();
},
fetchMediaList: function () {
var grid = document.getElementById('sisMediaGrid');
if (grid) {
grid.innerHTML = '<div class="col-12 text-center text-muted py-5"><i class="fas fa-spinner fa-spin fa-2x text-primary"></i><br/><span class="mt-2 d-block small">Đang nạp thư viện...</span></div>';
}
this.hideConfigPanel();
var self = this;
fetch('/api/manage/media/list')
.then(function (res) { return res.json(); })
.then(function (data) {
self.mediaData = data || [];
self.renderGrid(self.mediaData);
})
.catch(function (err) {
if (grid) {
grid.innerHTML = '<div class="col-12 text-center text-danger py-4"><i class="fas fa-exclamation-triangle fa-2x mb-2"></i><br/>Không thể tải danh sách tệp. Bạn có thể dán đường dẫn URL trực tiếp.</div>';
}
});
},
renderGrid: function (list) {
var grid = document.getElementById('sisMediaGrid');
if (!grid) return;
if (!list || list.length === 0) {
grid.innerHTML = '<div class="col-12 text-center text-muted py-5"><i class="far fa-folder-open fa-2x mb-2"></i><br/>Chưa có tệp hình ảnh nào. Bấm nút "Tải ảnh mới lên" để thêm.</div>';
return;
}
var self = this;
grid.innerHTML = list.map(function (item) {
var url = item.fileUrl || item.url || '';
var name = item.originalFilename || item.name || 'Image';
return '<div class="col-lg-2 col-md-3 col-sm-4 col-6 mb-3">' +
'<div class="card h-100 border shadow-sm media-select-card" style="cursor: pointer; transition: all 0.2s;" data-url="' + url + '" data-name="' + name + '">' +
'<div style="height: 110px; background: #f8f9fc; display: flex; align-items: center; justify-content: center; overflow: hidden;" class="p-1">' +
'<img src="' + url + '" style="max-height: 100%; max-width: 100%; object-fit: contain;" alt="' + name + '" />' +
'</div>' +
'<div class="card-body p-2 text-center bg-white border-top">' +
'<p class="card-text small text-truncate m-0 font-weight-bold text-dark" title="' + name + '">' + name + '</p>' +
'</div>' +
'</div>' +
'</div>';
}).join('');
// Add click listeners to items
grid.querySelectorAll('.media-select-card').forEach(function (card) {
card.addEventListener('click', function () {
var selectedUrl = card.getAttribute('data-url');
var selectedName = card.getAttribute('data-name');
// Highlight selected
grid.querySelectorAll('.media-select-card').forEach(function (c) {
c.style.outline = '';
});
card.style.outline = '3px solid #007bff';
self.showConfigPanel(selectedUrl, { name: selectedName, url: selectedUrl });
});
});
},
showConfigPanel: function (url, mediaObj) {
this.selectedUrl = url;
this.selectedMediaObj = mediaObj || {};
var panel = document.getElementById(this.configPanelId);
if (!panel) return;
// Populate preview
var previewImg = document.getElementById('sisMediaConfigPreview');
if (previewImg) previewImg.src = url;
var nameEl = document.getElementById('sisMediaConfigName');
if (nameEl) nameEl.textContent = mediaObj && (mediaObj.originalFilename || mediaObj.name) ? (mediaObj.originalFilename || mediaObj.name) : '';
// Reset all config fields
var fields = ['sisMediaConfigWidth', 'sisMediaConfigHeight', 'sisMediaConfigStyle', 'sisMediaConfigClass', 'sisMediaConfigAlt', 'sisMediaConfigAttrs'];
var selectFields = ['sisMediaConfigObjectFit', 'sisMediaConfigAspectRatio'];
selectFields.forEach(function (id) { var el = document.getElementById(id); if (el) el.value = ''; });
fields.forEach(function (id) {
var el = document.getElementById(id);
if (el) el.value = '';
});
var fitEl = document.getElementById('sisMediaConfigObjectFit');
if (fitEl) fitEl.value = '';
// Reset size button highlights
var sizeContainer = document.getElementById('sisMediaSizeButtons');
if (sizeContainer) {
sizeContainer.querySelectorAll('button').forEach(function (b) {
b.classList.remove('btn-secondary');
b.classList.add('btn-outline-secondary');
});
}
panel.style.display = 'block';
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
},
hideConfigPanel: function () {
var panel = document.getElementById(this.configPanelId);
if (panel) panel.style.display = 'none';
this.selectedUrl = null;
this.selectedMediaObj = null;
},
confirmWithConfig: function () {
if (!this.selectedUrl) return;
var width = (document.getElementById('sisMediaConfigWidth') || {}).value || '';
var height = (document.getElementById('sisMediaConfigHeight') || {}).value || '';
var objectFit = (document.getElementById('sisMediaConfigObjectFit') || {}).value || '';
var aspectRatio = (document.getElementById('sisMediaConfigAspectRatio') || {}).value || '';
var style = (document.getElementById('sisMediaConfigStyle') || {}).value || '';
var cssClass = (document.getElementById('sisMediaConfigClass') || {}).value || '';
var alt = (document.getElementById('sisMediaConfigAlt') || {}).value || '';
var customAttrs = (document.getElementById('sisMediaConfigAttrs') || {}).value || '';
// Build composite style string from size + aspect-ratio + object-fit + inline style
var computedStyle = '';
if (width) computedStyle += 'width: ' + width + '; ';
if (height) computedStyle += 'height: ' + height + '; ';
if (aspectRatio) computedStyle += 'aspect-ratio: ' + aspectRatio + '; ';
if (objectFit) computedStyle += 'object-fit: ' + objectFit + '; ';
if (style) computedStyle += style;
computedStyle = computedStyle.trim();
var config = {
style: computedStyle,
cssClass: cssClass,
alt: alt,
customAttributes: customAttrs,
width: width,
height: height,
objectFit: objectFit,
aspectRatio: aspectRatio
};
if (typeof this.callback === 'function') {
this.callback(this.selectedUrl, this.selectedMediaObj, config);
}
// Close modal
var modalEl = document.getElementById(this.modalId);
if (modalEl) {
if (typeof $ !== 'undefined' && $.fn && $.fn.modal) {
$(modalEl).modal('hide');
} else {
modalEl.style.display = 'none';
modalEl.classList.remove('show');
}
}
this.hideConfigPanel();
},
selectItem: function (url, mediaObj) {
// Legacy direct select (no config panel) - used by upload flow or external callers
if (typeof this.callback === 'function') {
this.callback(url, mediaObj, {});
}
var modalEl = document.getElementById(this.modalId);
if (modalEl) {
if (typeof $ !== 'undefined' && $.fn && $.fn.modal) {
$(modalEl).modal('hide');
} else {
modalEl.style.display = 'none';
modalEl.classList.remove('show');
}
}
}
};
// Expose standalone module globally
window.SISMediaPicker = SISMediaPicker;
// Backward-compatible global helper function
window.openSISMediaModal = function (callback) {
SISMediaPicker.open(callback);
};
})(window, document);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,201 @@
/**
* SISTagTool — Custom HTML Tag Opening, Closing & Wrapper Block Tool for Editor.js
* Allows content managers to define custom HTML tags (div, section, article, header, footer, etc.)
* with custom Class(es), ID, and Attributes (data-*, style, aria-*, etc.).
*/
class SISTagTool {
static get toolbox() {
return {
title: 'HTML Tag (Open/Close)',
icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 18l6-6-6-6M8 6l-6 6 6 6"></path></svg>'
};
}
constructor({ data, api, readOnly }) {
this.api = api;
this.readOnly = readOnly;
this.data = {
mode: data && data.mode ? data.mode : 'open', // 'open' or 'close'
tagName: data && data.tagName ? data.tagName : 'div',
elementId: data && data.elementId ? data.elementId : '',
elementClass: data && data.elementClass ? data.elementClass : '',
attributes: data && data.attributes ? data.attributes : ''
};
this.wrapper = undefined;
}
render() {
this.wrapper = document.createElement('div');
this.wrapper.className = 'p-3 bg-light border rounded mb-3 ce-tag-tool-wrapper';
this.wrapper.style.fontFamily = 'inherit';
this.wrapper.style.borderLeft = '4px solid #007bff';
const headerDiv = document.createElement('div');
headerDiv.className = 'font-weight-bold text-primary small mb-3 d-flex justify-content-between align-items-center';
headerDiv.innerHTML = '<span><i class="fas fa-code"></i> HTML Custom Tag Settings</span><span class="badge badge-primary tag-preview-badge" style="font-family: monospace;">&lt;div&gt;</span>';
this.wrapper.appendChild(headerDiv);
const formRow = document.createElement('div');
formRow.className = 'form-row';
// 1. Tag Mode (Opening Tag / Closing Tag)
const modeDiv = document.createElement('div');
modeDiv.className = 'col-md-3 mb-2';
modeDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Tag Action / Mode</label>';
const modeSelect = document.createElement('select');
modeSelect.className = 'form-control form-control-sm';
const optOpen = document.createElement('option');
optOpen.value = 'open';
optOpen.textContent = 'Opening Tag (<tag>)';
if (this.data.mode === 'open') optOpen.selected = true;
modeSelect.appendChild(optOpen);
const optClose = document.createElement('option');
optClose.value = 'close';
optClose.textContent = 'Closing Tag (</tag>)';
if (this.data.mode === 'close') optClose.selected = true;
modeSelect.appendChild(optClose);
if (this.readOnly) modeSelect.disabled = true;
this.modeSelect = modeSelect;
modeDiv.appendChild(modeSelect);
formRow.appendChild(modeDiv);
// 2. Tag Name (div, section, article, header, footer, etc.)
const nameDiv = document.createElement('div');
nameDiv.className = 'col-md-3 mb-2';
nameDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Tag Name</label>';
const nameInput = document.createElement('input');
nameInput.type = 'text';
nameInput.className = 'form-control form-control-sm';
nameInput.placeholder = 'e.g. div, section, article';
nameInput.value = this.data.tagName || 'div';
if (this.readOnly) nameInput.disabled = true;
this.nameInput = nameInput;
nameDiv.appendChild(nameInput);
formRow.appendChild(nameDiv);
// 3. Element ID
const idDiv = document.createElement('div');
idDiv.className = 'col-md-3 mb-2';
idDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">ID (id="..." )</label>';
const idInput = document.createElement('input');
idInput.type = 'text';
idInput.className = 'form-control form-control-sm';
idInput.placeholder = 'e.g. heroSection';
idInput.value = this.data.elementId || '';
if (this.readOnly) idInput.disabled = true;
this.idInput = idInput;
idDiv.appendChild(idInput);
formRow.appendChild(idDiv);
// 4. Element CSS Class(es)
const classDiv = document.createElement('div');
classDiv.className = 'col-md-3 mb-2';
classDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Class(es) (class="..." )</label>';
const classInput = document.createElement('input');
classInput.type = 'text';
classInput.className = 'form-control form-control-sm';
classInput.placeholder = 'e.g. container py-5 my-section';
classInput.value = this.data.elementClass || '';
if (this.readOnly) classInput.disabled = true;
this.classInput = classInput;
classDiv.appendChild(classInput);
formRow.appendChild(classDiv);
// 5. Custom Attributes Row
const attrRow = document.createElement('div');
attrRow.className = 'form-row mt-2 attr-row';
const attrDiv = document.createElement('div');
attrDiv.className = 'col-12';
attrDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1"><i class="fas fa-sliders-h"></i> Custom Attributes (e.g. data-aos="fade-up" style="background:#fff")</label>';
const attrInput = document.createElement('input');
attrInput.type = 'text';
attrInput.className = 'form-control form-control-sm';
attrInput.placeholder = 'e.g. data-aos="fade-up" style="background: #f8f9fa;" aria-label="Section"';
attrInput.value = this.data.attributes || '';
if (this.readOnly) attrInput.disabled = true;
this.attrInput = attrInput;
attrDiv.appendChild(attrInput);
attrRow.appendChild(attrDiv);
// Live HTML Code Preview Box
const previewBox = document.createElement('div');
previewBox.className = 'mt-3 p-2 bg-dark text-success rounded small font-weight-bold text-monospace live-tag-preview';
previewBox.style.fontSize = '12px';
const updatePreview = () => {
const mode = this.modeSelect.value;
const tag = (this.nameInput.value.trim() || 'div').toLowerCase();
const idVal = this.idInput.value.trim();
const classVal = this.classInput.value.trim();
const attrVal = this.attrInput.value.trim();
if (mode === 'close') {
previewBox.textContent = `</${tag}>`;
attrRow.style.display = 'none';
idDiv.style.display = 'none';
classDiv.style.display = 'none';
} else {
attrRow.style.display = 'flex';
idDiv.style.display = 'block';
classDiv.style.display = 'block';
let tagStr = `<${tag}`;
if (idVal) tagStr += ` id="${idVal}"`;
if (classVal) tagStr += ` class="${classVal}"`;
if (attrVal) tagStr += ` ${attrVal}`;
tagStr += `>`;
previewBox.textContent = tagStr;
}
const previewBadge = this.wrapper.querySelector('.tag-preview-badge');
if (previewBadge) previewBadge.textContent = previewBox.textContent;
};
this.modeSelect.addEventListener('change', updatePreview);
this.nameInput.addEventListener('input', updatePreview);
this.idInput.addEventListener('input', updatePreview);
this.classInput.addEventListener('input', updatePreview);
this.attrInput.addEventListener('input', updatePreview);
this.wrapper.appendChild(formRow);
this.wrapper.appendChild(attrRow);
this.wrapper.appendChild(previewBox);
updatePreview();
return this.wrapper;
}
save() {
const mode = this.modeSelect ? this.modeSelect.value : (this.data.mode || 'open');
const tagName = this.nameInput ? (this.nameInput.value.trim().toLowerCase() || 'div') : (this.data.tagName || 'div');
const elementId = this.idInput ? this.idInput.value.trim() : (this.data.elementId || '');
const elementClass = this.classInput ? this.classInput.value.trim() : (this.data.elementClass || '');
const attributes = this.attrInput ? this.attrInput.value.trim() : (this.data.attributes || '');
let rawHtmlTag = '';
if (mode === 'close') {
rawHtmlTag = `</${tagName}>`;
} else {
rawHtmlTag = `<${tagName}`;
if (elementId) rawHtmlTag += ` id="${elementId}"`;
if (elementClass) rawHtmlTag += ` class="${elementClass}"`;
if (attributes) rawHtmlTag += ` ${attributes}`;
rawHtmlTag += `>`;
}
return {
mode: mode,
tagName: tagName,
elementId: elementId,
elementClass: elementClass,
attributes: attributes,
rawHtmlTag: rawHtmlTag
};
}
}
// Register the plugin globally
window.SISEditorPlugins = window.SISEditorPlugins || {};
window.SISEditorPlugins['tag'] = { class: SISTagTool };
@@ -0,0 +1,182 @@
/**
* TextStylingTune — Block Tune for Editor.js
* Adds alignment, custom CSS class, custom CSS style, and HTML ID settings to blocks.
*/
class TextStylingTune {
constructor({ api, block, data }) {
this.api = api;
this.block = block;
this.data = data || {
alignment: '',
cssClass: '',
customStyle: '',
elementId: '',
customAttrs: ''
};
}
static get isTune() {
return true;
}
render() {
const wrapper = document.createElement('div');
wrapper.className = 'cdx-settings-text-styling p-2 border-top';
wrapper.style.fontFamily = 'inherit';
const title = document.createElement('div');
title.className = 'small font-weight-bold text-primary mb-2';
title.innerHTML = '<i class="fas fa-palette"></i> Style & Alignment';
wrapper.appendChild(title);
// Alignment row
const alignWrapper = document.createElement('div');
alignWrapper.className = 'd-flex mb-2 align-items-center justify-content-start';
const alignments = [
{ name: 'left', icon: '<i class="fas fa-align-left"></i>', title: 'Align Left' },
{ name: 'center', icon: '<i class="fas fa-align-center"></i>', title: 'Align Center' },
{ name: 'right', icon: '<i class="fas fa-align-right"></i>', title: 'Align Right' },
{ name: 'justify', icon: '<i class="fas fa-align-justify"></i>', title: 'Align Justify' }
];
alignments.forEach(align => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-xs btn-light mr-1 border';
btn.style.width = '30px';
btn.style.height = '30px';
btn.style.padding = '0';
btn.title = align.title;
if (this.data.alignment === align.name) {
btn.className = 'btn btn-xs btn-info mr-1 border';
}
btn.innerHTML = align.icon;
btn.addEventListener('click', () => {
this.data.alignment = this.data.alignment === align.name ? '' : align.name;
alignWrapper.querySelectorAll('button').forEach((b, idx) => {
b.className = (this.data.alignment === alignments[idx].name) ? 'btn btn-xs btn-info mr-1 border' : 'btn btn-xs btn-light mr-1 border';
});
this.applyTuneToBlock();
});
alignWrapper.appendChild(btn);
});
wrapper.appendChild(alignWrapper);
// CSS Class Group
const classGroup = document.createElement('div');
classGroup.className = 'form-group mb-1';
const classLabel = document.createElement('label');
classLabel.className = 'small font-weight-bold text-secondary mb-0 d-block';
classLabel.innerText = 'CSS Class';
const classInput = document.createElement('input');
classInput.type = 'text';
classInput.className = 'form-control form-control-sm py-0';
classInput.style.height = '24px';
classInput.placeholder = 'e.g. lead text-primary font-weight-bold';
classInput.value = this.data.cssClass || '';
classInput.addEventListener('input', (e) => {
this.data.cssClass = e.target.value.trim();
this.applyTuneToBlock();
});
classGroup.appendChild(classLabel);
classGroup.appendChild(classInput);
wrapper.appendChild(classGroup);
// Custom CSS Style Group
const styleGroup = document.createElement('div');
styleGroup.className = 'form-group mb-1';
const styleLabel = document.createElement('label');
styleLabel.className = 'small font-weight-bold text-secondary mb-0 d-block';
styleLabel.innerText = 'CSS Style';
const styleInput = document.createElement('input');
styleInput.type = 'text';
styleInput.className = 'form-control form-control-sm py-0';
styleInput.style.height = '24px';
styleInput.placeholder = 'e.g. color: #dc3545; font-size: 1.5rem;';
styleInput.value = this.data.customStyle || '';
styleInput.addEventListener('input', (e) => {
this.data.customStyle = e.target.value.trim();
this.applyTuneToBlock();
});
styleGroup.appendChild(styleLabel);
styleGroup.appendChild(styleInput);
wrapper.appendChild(styleGroup);
// HTML ID Group
const idGroup = document.createElement('div');
idGroup.className = 'form-group mb-1';
const idLabel = document.createElement('label');
idLabel.className = 'small font-weight-bold text-secondary mb-0 d-block';
idLabel.innerText = 'Element ID';
const idInput = document.createElement('input');
idInput.type = 'text';
idInput.className = 'form-control form-control-sm py-0';
idInput.style.height = '24px';
idInput.placeholder = 'e.g. section-main-title';
idInput.value = this.data.elementId || '';
idInput.addEventListener('input', (e) => {
this.data.elementId = e.target.value.trim();
this.applyTuneToBlock();
});
idGroup.appendChild(idLabel);
idGroup.appendChild(idInput);
wrapper.appendChild(idGroup);
// Custom HTML Attributes Group
const attrsGroup = document.createElement('div');
attrsGroup.className = 'form-group mb-0';
const attrsLabel = document.createElement('label');
attrsLabel.className = 'small font-weight-bold text-secondary mb-0 d-block';
attrsLabel.innerText = 'Custom Attributes';
const attrsInput = document.createElement('input');
attrsInput.type = 'text';
attrsInput.className = 'form-control form-control-sm py-0';
attrsInput.style.height = '24px';
attrsInput.placeholder = 'e.g. data-aos="fade-up" target="_blank"';
attrsInput.value = this.data.customAttrs || '';
attrsInput.addEventListener('input', (e) => {
this.data.customAttrs = e.target.value.trim();
});
attrsGroup.appendChild(attrsLabel);
attrsGroup.appendChild(attrsInput);
wrapper.appendChild(attrsGroup);
return wrapper;
}
applyTuneToBlock() {
if (!this.block || !this.block.holder) return;
const blockContent = this.block.holder.querySelector('.ce-block__content');
if (blockContent) {
// Live alignment preview
blockContent.style.textAlign = this.data.alignment || '';
// Live styling preview
const currentElement = blockContent.firstElementChild;
if (currentElement) {
// Apply custom inline styles on top of defaults
currentElement.style.cssText = this.data.customStyle || '';
}
}
}
save() {
return this.data;
}
wrap(blockContent) {
// Applies styling during initial render / loading
if (this.data.alignment) {
blockContent.style.textAlign = this.data.alignment;
}
const currentElement = blockContent.firstElementChild;
if (currentElement && this.data.customStyle) {
currentElement.style.cssText = this.data.customStyle;
}
return blockContent;
}
}
// Register globally
window.SISEditorPlugins = window.SISEditorPlugins || {};
window.SISEditorPlugins['textStyling'] = { class: TextStylingTune };
@@ -0,0 +1,338 @@
class SISTimelineTool {
static get toolbox() {
return {
title: 'Timeline Group',
icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>'
};
}
constructor({ data, api, readOnly }) {
this.api = api;
this.readOnly = readOnly;
this.data = {
globalAttributes: (data && data.globalAttributes) || (data && data.attributes) || '',
items: (data && data.items && Array.isArray(data.items)) ? data.items : []
};
// Add an initial empty item if list is empty
if (this.data.items.length === 0) {
this.data.items.push({ date: '', description: '', imageUrl: '' });
}
this.wrapper = undefined;
}
render() {
this.wrapper = document.createElement('div');
this.wrapper.className = 'timeline-tool-wrapper';
this.wrapper.style.border = '1px solid #e3e6f0';
this.wrapper.style.borderRadius = '8px';
this.wrapper.style.padding = '14px';
this.wrapper.style.background = '#f8f9fc';
this.wrapper.style.marginBottom = '12px';
const headerLabel = document.createElement('label');
headerLabel.className = 'font-weight-bold text-primary small mb-2 d-block';
headerLabel.innerHTML = '<i class="fas fa-stream"></i> Timeline Group Settings';
this.wrapper.appendChild(headerLabel);
const globalAttrGroup = this._createInput('e.g. data-aos="fade-up" style="..."', this.data.globalAttributes, 'global-attr', '<i class="fas fa-sliders-h"></i> Global Custom Attributes');
this.wrapper.appendChild(globalAttrGroup);
this.itemsContainer = document.createElement('div');
this.wrapper.appendChild(this.itemsContainer);
this.data.items.forEach((item, index) => {
this._renderItem(item, index);
});
if (!this.readOnly) {
const addButton = document.createElement('button');
addButton.type = 'button';
addButton.className = 'btn btn-sm btn-outline-primary mt-2';
addButton.innerHTML = '<i class="fas fa-plus"></i> Add Event';
addButton.addEventListener('click', () => {
const newItem = { date: '', description: '', imageUrl: '' };
this.data.items.push(newItem);
this._renderItem(newItem, this.data.items.length - 1);
});
this.wrapper.appendChild(addButton);
}
return this.wrapper;
}
_renderItem(item, index) {
const itemBox = document.createElement('div');
itemBox.className = 'timeline-item-edit-box mb-3 p-3 bg-white border rounded';
itemBox.style.position = 'relative';
if (!this.readOnly) {
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'btn btn-sm btn-danger';
removeBtn.style.position = 'absolute';
removeBtn.style.top = '10px';
removeBtn.style.right = '10px';
removeBtn.innerHTML = '<i class="fas fa-times"></i>';
removeBtn.addEventListener('click', () => {
itemBox.remove();
});
itemBox.appendChild(removeBtn);
}
const dateGroup = this._createInput('Date (e.g. 10/04/1994)', item.date, 'date', '<i class="fas fa-calendar-alt"></i> Date / Milestone');
const descGroup = this._createTextarea('Description (e.g. Bệnh viện khai trương)', item.description, 'desc', '<i class="fas fa-align-left"></i> Description');
// Image input with preview, upload, and media library buttons
const imageGroup = document.createElement('div');
imageGroup.className = 'form-group mb-2';
const imageLabel = document.createElement('label');
imageLabel.className = 'small font-weight-bold text-secondary mb-1 d-block';
imageLabel.innerHTML = '<i class="fas fa-image"></i> Image URL (Optional)';
const inputRow = document.createElement('div');
inputRow.className = 'input-group input-group-sm mb-2';
const imageInput = document.createElement('input');
imageInput.type = 'text';
imageInput.className = 'form-control ce-timeline-input-image';
imageInput.placeholder = 'Paste image URL here...';
imageInput.value = item.imageUrl || '';
if (this.readOnly) imageInput.disabled = true;
const appendDiv = document.createElement('div');
appendDiv.className = 'input-group-append';
const chooseBtn = document.createElement('button');
chooseBtn.type = 'button';
chooseBtn.className = 'btn btn-info';
chooseBtn.innerHTML = '<i class="fas fa-folder-open"></i> Choose';
if (this.readOnly) chooseBtn.disabled = true;
const uploadBtn = document.createElement('button');
uploadBtn.type = 'button';
uploadBtn.className = 'btn btn-primary';
uploadBtn.innerHTML = '<i class="fas fa-upload"></i> Upload';
if (this.readOnly) uploadBtn.disabled = true;
// Hidden file input for uploading
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'image/*';
fileInput.style.display = 'none';
chooseBtn.addEventListener('click', () => {
if (window.SISMediaPicker) {
SISMediaPicker.open((url, mediaObj, config) => {
imageInput.value = url;
// Store config in hidden inputs on the item box
let styleInp = itemBox.querySelector('.ce-timeline-input-imgstyle');
if (!styleInp) {
styleInp = document.createElement('input'); styleInp.type = 'hidden';
styleInp.className = 'ce-timeline-input-imgstyle'; itemBox.appendChild(styleInp);
}
let classInp = itemBox.querySelector('.ce-timeline-input-imgclass');
if (!classInp) {
classInp = document.createElement('input'); classInp.type = 'hidden';
classInp.className = 'ce-timeline-input-imgclass'; itemBox.appendChild(classInp);
}
let altInp = itemBox.querySelector('.ce-timeline-input-imgalt');
if (!altInp) {
altInp = document.createElement('input'); altInp.type = 'hidden';
altInp.className = 'ce-timeline-input-imgalt'; itemBox.appendChild(altInp);
}
let attrsInp = itemBox.querySelector('.ce-timeline-input-imgattrs');
if (!attrsInp) {
attrsInp = document.createElement('input'); attrsInp.type = 'hidden';
attrsInp.className = 'ce-timeline-input-imgattrs'; itemBox.appendChild(attrsInp);
}
let arInp = itemBox.querySelector('.ce-timeline-input-imgar');
if (!arInp) {
arInp = document.createElement('input'); arInp.type = 'hidden';
arInp.className = 'ce-timeline-input-imgar'; itemBox.appendChild(arInp);
}
if (config) {
styleInp.value = config.style || '';
classInp.value = config.cssClass || '';
altInp.value = config.alt || '';
attrsInp.value = config.customAttributes || '';
arInp.value = config.aspectRatio || '';
}
updatePreview();
});
} else {
alert('SISMediaPicker is not loaded.');
}
});
uploadBtn.addEventListener('click', () => {
fileInput.click();
});
const updatePreview = () => {
const url = imageInput.value.trim();
if (url) {
imagePreview.style.backgroundImage = 'url("' + url + '")';
imagePreview.style.display = 'block';
} else {
imagePreview.style.backgroundImage = 'none';
imagePreview.style.display = 'none';
}
};
fileInput.addEventListener('change', (e) => {
if (e.target.files && e.target.files[0]) {
const formData = new FormData();
formData.append('file', e.target.files[0]);
uploadBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
uploadBtn.disabled = true;
fetch('/api/manage/media/upload', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success === 1 && data.file && data.file.url) {
imageInput.value = data.file.url;
updatePreview();
} else {
alert('Upload failed: ' + (data.message || 'Unknown error'));
}
})
.catch(err => {
console.error('Error uploading image', err);
alert('Upload failed: ' + err.message);
})
.finally(() => {
uploadBtn.innerHTML = '<i class="fas fa-upload"></i> Upload';
uploadBtn.disabled = false;
fileInput.value = ''; // Reset
});
}
});
appendDiv.appendChild(chooseBtn);
appendDiv.appendChild(uploadBtn);
inputRow.appendChild(imageInput);
inputRow.appendChild(appendDiv);
inputRow.appendChild(fileInput);
// Image Preview Container
const imagePreview = document.createElement('div');
imagePreview.style.width = '100%';
imagePreview.style.height = '180px';
imagePreview.style.backgroundColor = '#f8f9fc';
imagePreview.style.border = '1px dashed #d1d3e2';
imagePreview.style.borderRadius = '6px';
imagePreview.style.backgroundSize = 'cover';
imagePreview.style.backgroundPosition = 'center';
imagePreview.style.display = 'none';
imageInput.addEventListener('input', updatePreview);
updatePreview();
imageGroup.appendChild(imageLabel);
imageGroup.appendChild(inputRow);
imageGroup.appendChild(imagePreview);
const attrGroup = this._createInput('e.g. data-aos="fade-right" style="..."', item.attributes, 'attr', '<i class="fas fa-sliders-h"></i> Step Custom Attributes');
itemBox.appendChild(dateGroup);
itemBox.appendChild(descGroup);
itemBox.appendChild(imageGroup);
itemBox.appendChild(attrGroup);
this.itemsContainer.appendChild(itemBox);
}
_createInput(placeholder, value, type, labelHtml) {
const wrapper = document.createElement('div');
wrapper.className = 'form-group mb-2';
if (labelHtml) {
const label = document.createElement('label');
label.className = 'small font-weight-bold text-secondary mb-1 d-block';
label.innerHTML = labelHtml;
wrapper.appendChild(label);
}
const inp = document.createElement('input');
inp.type = 'text';
inp.className = 'form-control form-control-sm ce-timeline-input-' + type;
inp.placeholder = placeholder;
inp.value = value || '';
if (this.readOnly) inp.disabled = true;
wrapper.appendChild(inp);
return wrapper;
}
_createTextarea(placeholder, value, type, labelHtml) {
const wrapper = document.createElement('div');
wrapper.className = 'form-group mb-2';
if (labelHtml) {
const label = document.createElement('label');
label.className = 'small font-weight-bold text-secondary mb-1 d-block';
label.innerHTML = labelHtml;
wrapper.appendChild(label);
}
const inp = document.createElement('textarea');
inp.className = 'form-control form-control-sm ce-timeline-input-' + type;
inp.placeholder = placeholder;
inp.value = value || '';
inp.style.minHeight = '70px';
if (this.readOnly) inp.disabled = true;
wrapper.appendChild(inp);
return wrapper;
}
save(blockContent) {
const itemBoxes = this.itemsContainer.querySelectorAll('.timeline-item-edit-box');
const items = [];
itemBoxes.forEach(box => {
const date = box.querySelector('.ce-timeline-input-date').value.trim();
const desc = box.querySelector('.ce-timeline-input-desc').value.trim();
const img = box.querySelector('.ce-timeline-input-image').value.trim();
const attrEl = box.querySelector('.ce-timeline-input-attr');
const attr = attrEl ? attrEl.value.trim() : '';
const imgStyle = (box.querySelector('.ce-timeline-input-imgstyle') || {}).value || '';
const imgClass = (box.querySelector('.ce-timeline-input-imgclass') || {}).value || '';
const imgAlt = (box.querySelector('.ce-timeline-input-imgalt') || {}).value || '';
const imgAttrs = (box.querySelector('.ce-timeline-input-imgattrs') || {}).value || '';
const imgAspectRatio = (box.querySelector('.ce-timeline-input-imgar') || {}).value || '';
if (date || desc || img || attr) {
items.push({
date: date, description: desc, imageUrl: img, attributes: attr,
imageStyle: imgStyle, imageClass: imgClass, imageAlt: imgAlt,
imageAttrs: imgAttrs, imageAspectRatio: imgAspectRatio
});
}
});
const globalAttrEl = this.wrapper.querySelector('.ce-timeline-input-global-attr');
return {
globalAttributes: globalAttrEl ? globalAttrEl.value.trim() : (this.data.globalAttributes || ''),
items: items
};
}
}
// Register the plugin globally
window.SISEditorPlugins = window.SISEditorPlugins || {};
window.SISEditorPlugins['timeline'] = {
class: SISTimelineTool
};
// openMediaPickerModal — legacy stub, replaced by SISMediaPicker
function openMediaPickerModal(onSelectCallback) {
if (window.SISMediaPicker) {
SISMediaPicker.open(function(url) { onSelectCallback(url); });
} else {
var url = prompt('Enter image URL:');
if (url) onSelectCallback(url);
}
}
@@ -0,0 +1,130 @@
(function() {
'use strict';
class SISTinyMceTool {
static get toolbox() {
return {
title: 'TinyMCE Editor',
icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16v16H4z"></path><path d="M8 8h8"></path><path d="M12 8v8"></path><path d="M10 16h4"></path></svg>'
};
}
constructor({ data, api, readOnly }) {
this.api = api;
this.readOnly = readOnly;
this.data = {
html: data.html || ''
};
this.editorId = 'tinymce-editor-' + Math.random().toString(36).substring(7);
this.editorInstance = null;
}
render() {
const wrapper = document.createElement('div');
wrapper.className = 'sis-tinymce-block-wrapper border rounded p-2 bg-white';
wrapper.style.minHeight = '280px';
const textarea = document.createElement('textarea');
textarea.id = this.editorId;
textarea.value = this.data.html;
textarea.style.width = '100%';
textarea.style.minHeight = '250px';
wrapper.appendChild(textarea);
this.textareaElement = textarea;
this._initTinyMCE();
return wrapper;
}
_initTinyMCE() {
const self = this;
if (typeof tinymce !== 'undefined') {
self._createEditor();
return;
}
// Global loading queue to prevent duplicate script tags
window.SISTinyMceLoading = window.SISTinyMceLoading || {
loaded: false,
callbacks: []
};
if (window.SISTinyMceLoading.loaded) {
self._createEditor();
return;
}
window.SISTinyMceLoading.callbacks.push(() => {
self._createEditor();
});
if (window.SISTinyMceLoading.callbacks.length === 1) {
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/tinymce@6/tinymce.min.js';
script.referrerPolicy = 'origin';
script.onload = () => {
window.SISTinyMceLoading.loaded = true;
while (window.SISTinyMceLoading.callbacks.length > 0) {
const cb = window.SISTinyMceLoading.callbacks.shift();
try { cb(); } catch (e) { console.error(e); }
}
};
document.head.appendChild(script);
}
}
_createEditor() {
const self = this;
const textarea = this.textareaElement || document.getElementById(this.editorId);
if (!textarea) return;
// Wait until the element is actually attached to the document DOM
const checkAndInit = () => {
if (!document.body.contains(textarea)) {
requestAnimationFrame(checkAndInit);
return;
}
tinymce.init({
target: textarea,
height: 250,
menubar: false,
readonly: !!self.readOnly,
plugins: 'advlist autolink lists link image charmap preview anchor searchreplace visualblocks code fullscreen insertdatetime media table code help wordcount',
toolbar: 'undo redo | blocks | bold italic forecolor backcolor | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | table link image | code removeformat',
setup: function(editor) {
self.editorInstance = editor;
editor.on('change keyup undo redo', function() {
self.data.html = editor.getContent();
});
}
});
};
checkAndInit();
}
save(blockContent) {
if (this.editorInstance) {
this.data.html = this.editorInstance.getContent();
}
return {
html: this.data.html
};
}
destroy() {
if (this.editorInstance) {
tinymce.remove(this.editorInstance);
this.editorInstance = null;
}
}
}
// Register globally
window.SISEditorPlugins = window.SISEditorPlugins || {};
window.SISEditorPlugins['tinymce'] = {
class: SISTinyMceTool
};
})();
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 25.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 108.3 108.3" style="enable-background:new 0 0 108.3 108.3;" xml:space="preserve">
<style type="text/css">
.st0{fill:#E6E6E6;}
.st1{fill:#FFB8B8;}
.st2{fill:#575A89;}
.st3{fill:#2F2E41;}
</style>
<g id="Group_45" transform="translate(-191 -152.079)">
<g id="Group_30" transform="translate(282.246 224.353)">
<path id="Path_944" class="st0" d="M17.1-18.1c0,10.5-3,20.8-8.8,29.6c-1.2,1.9-2.5,3.6-4,5.3c-3.4,4-7.3,7.4-11.6,10.3
c-1.2,0.8-2.4,1.5-3.6,2.2c-6.5,3.6-13.7,5.8-21,6.5c-1.7,0.2-3.4,0.2-5.1,0.2c-4.7,0-9.4-0.6-14-1.8c-2.6-0.7-5.1-1.6-7.6-2.6
c-1.3-0.5-2.5-1.1-3.7-1.8c-2.9-1.5-5.6-3.3-8.2-5.3c-1.2-0.9-2.3-1.9-3.4-2.9C-95.8,1.3-97.1-33-76.8-54.9s54.6-23.3,76.5-2.9
C10.8-47.6,17.1-33.2,17.1-18.1L17.1-18.1z"/>
<path id="Path_945" class="st1" d="M-50.2-13.2c0,0,4.9,13.7,1.1,21.4s6,16.4,6,16.4s25.8-13.1,22.5-19.7s-8.8-15.3-7.7-20.8
L-50.2-13.2z"/>
<ellipse id="Ellipse_185" class="st1" cx="-40.6" cy="-25.5" rx="17.5" ry="17.5"/>
<path id="Path_946" class="st2" d="M-51.1,34.2c-2.6-0.7-5.1-1.6-7.6-2.6l0.5-13.3l4.9-11c1.1,0.9,2.3,1.6,3.5,2.3
c0.3,0.2,0.6,0.3,0.9,0.5c4.6,2.2,12.2,4.2,19.5-1.3c2.7-2.1,5-4.7,6.7-7.6L-8.8,9l0.7,8.4l0.8,9.8c-1.2,0.8-2.4,1.5-3.6,2.2
c-6.5,3.6-13.7,5.8-21,6.5c-1.7,0.2-3.4,0.2-5.1,0.2C-41.8,36.1-46.5,35.4-51.1,34.2z"/>
<path id="Path_947" class="st2" d="M-47.7-0.9L-47.7-0.9l-0.7,7.2l-0.4,3.8l-0.5,5.6l-1.8,18.5c-2.6-0.7-5.1-1.6-7.6-2.6
c-1.3-0.5-2.5-1.1-3.7-1.8c-2.9-1.5-5.6-3.3-8.2-5.3l-1.9-9l0.1-0.1L-47.7-0.9z"/>
<path id="Path_948" class="st2" d="M-10.9,29.3c-6.5,3.6-13.7,5.8-21,6.5c0.4-6.7,1-13.1,1.6-18.8c0.3-2.9,0.7-5.7,1.1-8.2
c1.2-8,2.5-13.5,3.4-14.2l6.1,4L4.9,7.3l-0.5,9.5c-3.4,4-7.3,7.4-11.6,10.3C-8.5,27.9-9.7,28.7-10.9,29.3z"/>
<path id="Path_949" class="st2" d="M-70.5,24.6c-1.2-0.9-2.3-1.9-3.4-2.9l0.9-6.1l0.7-0.1l3.1-0.4l6.8,14.8
C-65.2,28.3-67.9,26.6-70.5,24.6L-70.5,24.6z"/>
<path id="Path_950" class="st2" d="M8.3,11.5c-1.2,1.9-2.5,3.6-4,5.3c-3.4,4-7.3,7.4-11.6,10.3c-1.2,0.8-2.4,1.5-3.6,2.2l-0.6-2.8
l3.5-9.1l4.2-11.1l8.8,1.1C6.1,8.7,7.2,10.1,8.3,11.5z"/>
<path id="Path_951" class="st3" d="M-23.9-41.4c-2.7-4.3-6.8-7.5-11.6-8.9l-3.6,2.9l1.4-3.3c-1.2-0.2-2.3-0.2-3.5-0.2l-3.2,4.1
l1.3-4c-5.6,0.7-10.7,3.7-14,8.3c-4.1,5.9-4.8,14.1-0.8,20c1.1-3.4,2.4-6.6,3.5-9.9c0.9,0.1,1.7,0.1,2.6,0l1.3-3.1l0.4,3
c4.2-0.4,10.3-1.2,14.3-1.9l-0.4-2.3l2.3,1.9c1.2-0.3,1.9-0.5,1.9-0.7c2.9,4.7,5.8,7.7,8.8,12.5C-22.1-29.8-20.2-35.3-23.9-41.4z"
/>
<ellipse id="Ellipse_186" class="st1" cx="-24.9" cy="-26.1" rx="1.2" ry="2.4"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

@@ -0,0 +1,2 @@
(function(){"use strict";try{if(typeof document<"u"){var e=document.createElement("style");e.appendChild(document.createTextNode(`.cdx-warning{position:relative}@media all and (min-width: 736px){.cdx-warning{padding-left:36px}}.cdx-warning [contentEditable=true][data-placeholder]:before{position:absolute;content:attr(data-placeholder);color:#707684;font-weight:400;opacity:0}.cdx-warning [contentEditable=true][data-placeholder]:empty:before{opacity:1}.cdx-warning [contentEditable=true][data-placeholder]:empty:focus:before{opacity:0}.cdx-warning:before{content:"";background-image:url("data:image/svg+xml,%3Csvg width='24' height='24' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='5' y='5' width='14' height='14' rx='4' stroke='black' stroke-width='2'/%3E%3Cline x1='12' y1='9' x2='12' y2='12' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3Cpath d='M12 15.02V15.01' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E");width:24px;height:24px;background-size:24px 24px;position:absolute;margin-top:8px;left:0}@media all and (max-width: 735px){.cdx-warning:before{display:none}}.cdx-warning__message{min-height:85px}.cdx-warning__title{margin-bottom:6px}`)),document.head.appendChild(e)}}catch(t){console.error("vite-plugin-css-injected-by-js",t)}})();
(function(r,n){typeof exports=="object"&&typeof module<"u"?module.exports=n():typeof define=="function"&&define.amd?define(n):(r=typeof globalThis<"u"?globalThis:r||self,r.Warning=n())})(this,function(){"use strict";const r='<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><rect width="14" height="14" x="5" y="5" stroke="currentColor" stroke-width="2" rx="4"/><line x1="12" x2="12" y1="9" y2="12" stroke="currentColor" stroke-linecap="round" stroke-width="2"/><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M12 15.02V15.01"/></svg>',n="";class a{static get isReadOnlySupported(){return!0}static get toolbox(){return{icon:r,title:"Warning"}}static get enableLineBreaks(){return!0}static get DEFAULT_TITLE_PLACEHOLDER(){return"Title"}static get DEFAULT_MESSAGE_PLACEHOLDER(){return"Message"}get CSS(){return{baseClass:this.api.styles.block,wrapper:"cdx-warning",title:"cdx-warning__title",input:this.api.styles.input,message:"cdx-warning__message"}}constructor({data:t,config:e,api:s,readOnly:i}){this.api=s,this.readOnly=i,this.titlePlaceholder=(e==null?void 0:e.titlePlaceholder)||a.DEFAULT_TITLE_PLACEHOLDER,this.messagePlaceholder=(e==null?void 0:e.messagePlaceholder)||a.DEFAULT_MESSAGE_PLACEHOLDER,this.data={title:t.title||"",message:t.message||""}}render(){const t=this._make("div",[this.CSS.baseClass,this.CSS.wrapper]),e=this._make("div",[this.CSS.input,this.CSS.title],{contentEditable:!this.readOnly,innerHTML:this.data.title}),s=this._make("div",[this.CSS.input,this.CSS.message],{contentEditable:!this.readOnly,innerHTML:this.data.message});return e.dataset.placeholder=this.titlePlaceholder,s.dataset.placeholder=this.messagePlaceholder,t.appendChild(e),t.appendChild(s),t}save(t){const e=t.querySelector(`.${this.CSS.title}`),s=t.querySelector(`.${this.CSS.message}`);return Object.assign(this.data,{title:(e==null?void 0:e.innerHTML)??"",message:(s==null?void 0:s.innerHTML)??""})}_make(t,e=null,s={}){const i=document.createElement(t);Array.isArray(e)?i.classList.add(...e):e&&i.classList.add(e);for(const l in s)i[l]=s[l];return i}static get sanitize(){return{title:{},message:{}}}}return a});
+2 -2
View File
@@ -1744,10 +1744,10 @@ Date: Wed, 29 Jul 2026 15:57:14 GMT
<div class="slides-container">
<div class="image-video-container has-video">
<img
src="https://www.umass.edu/sites/default/files/styles/1_1_1920x1920/public/2025-09/250616_UMASS_4515.jpg"
src="/uploads/2026/07/dotquy201.webp"
alt="Students collaborate using a driving simulator in the UMass Center for Transportation."
srcset="
https://www.umass.edu/sites/default/files/styles/1_1_1920x1920/public/2025-09/250616_UMASS_4515.jpg?h=9855f42d&amp;itok=d27r8GaT 1920w
/uploads/2026/07/dotquy201.webp?h=9855f42d&amp;itok=d27r8GaT 1920w
"
/>
<div class="f--ambient-video" id="hero-ambient-video">
@@ -7,11 +7,9 @@
<div class="slides-container">
<div class="image-video-container has-video">
<img
src="https://www.umass.edu/sites/default/files/styles/1_1_1920x1920/public/2025-09/250616_UMASS_4515.jpg"
src="/uploads/2026/07/dotquy201.webp"
alt="Students collaborate using a driving simulator in the UMass Center for Transportation."
srcset="
https://www.umass.edu/sites/default/files/styles/1_1_1920x1920/public/2025-09/250616_UMASS_4515.jpg?h=9855f42d&amp;itok=d27r8GaT 1920w
"
srcset="/uploads/2026/07/dotquy201.webp 1920w"
/>
<div class="f--ambient-video" id="hero-ambient-video">
<video
@@ -1,73 +0,0 @@
package com.sisvietnamvn.web.config;
import com.sisvietnamvn.web.domain.Menu;
import com.sisvietnamvn.web.domain.MenuItem;
import com.sisvietnamvn.web.domain.Page;
import com.sisvietnamvn.web.domain.PageLayout;
import com.sisvietnamvn.web.repository.MenuItemRepository;
import com.sisvietnamvn.web.repository.MenuRepository;
import com.sisvietnamvn.web.service.PageService;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.stream.Collectors;
@Component
public class MenuSeeder implements CommandLineRunner {
private final MenuRepository menuRepository;
private final MenuItemRepository menuItemRepository;
private final PageService pageService;
public MenuSeeder(MenuRepository menuRepository, MenuItemRepository menuItemRepository, PageService pageService) {
this.menuRepository = menuRepository;
this.menuItemRepository = menuItemRepository;
this.pageService = pageService;
}
@Override
@org.springframework.transaction.annotation.Transactional
public void run(String... args) {
Menu primaryMenu = menuRepository.findAll().stream()
.filter(m -> m.getName() != null && m.getName().trim().equals("primaryMenu"))
.findFirst().orElse(null);
if (primaryMenu == null) return;
MenuItem chuyenKhoa = menuItemRepository.findByMenu_Id(primaryMenu.getId()).stream()
.filter(i -> i.getLabel() != null && "CHUYÊN KHOA".equalsIgnoreCase(i.getLabel().trim()))
.findFirst().orElse(null);
if (chuyenKhoa == null) return;
List<Page> specialties = pageService.findAll().stream()
.filter(p -> PageLayout.SPECIALTY_DETAIL.equals(p.getLayout()))
.collect(Collectors.toList());
int maxOrder = menuItemRepository.findByMenu_Id(primaryMenu.getId()).stream()
.filter(i -> chuyenKhoa.equals(i.getParent()))
.mapToInt(i -> i.getDisplayOrder() != null ? i.getDisplayOrder() : 0)
.max().orElse(0);
for (Page spec : specialties) {
boolean exists = menuItemRepository.findByMenu_Id(primaryMenu.getId()).stream()
.filter(i -> chuyenKhoa.equals(i.getParent()))
.anyMatch(i -> (i.getLabel() != null && i.getLabel().equalsIgnoreCase(spec.getTitle())) ||
(i.getTitle() != null && i.getTitle().equalsIgnoreCase(spec.getTitle())));
if (!exists) {
maxOrder++;
MenuItem newItem = new MenuItem();
newItem.setMenu(primaryMenu);
newItem.setParent(chuyenKhoa);
newItem.setLabel(spec.getTitle()); // Set label
newItem.setTitle(spec.getTitle()); // Set title as well
newItem.setUrl("/chuyen-khoa/" + spec.getSlug());
newItem.setDisplayOrder(maxOrder);
menuItemRepository.save(newItem);
System.out.println("ADDED MENU ITEM: " + spec.getTitle());
}
}
}
}
@@ -1,40 +0,0 @@
package com.sisvietnamvn.web.config;
import com.sisvietnamvn.web.domain.Authority;
import com.sisvietnamvn.web.repository.AuthorityRepository;
import com.sisvietnamvn.web.security.AuthoritiesConstants;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import java.util.Arrays;
import java.util.List;
@Component
public class RoleSeeder implements CommandLineRunner {
private final AuthorityRepository authorityRepository;
public RoleSeeder(AuthorityRepository authorityRepository) {
this.authorityRepository = authorityRepository;
}
@Override
public void run(String... args) {
List<String> roles = Arrays.asList(
AuthoritiesConstants.EDITOR,
AuthoritiesConstants.AUTHOR,
AuthoritiesConstants.CONTRIBUTOR,
AuthoritiesConstants.SUBSCRIBER,
AuthoritiesConstants.PRE_AUTH_2FA
);
for (String roleName : roles) {
if (authorityRepository.findById(roleName).isEmpty()) {
Authority authority = new Authority();
authority.setName(roleName);
authorityRepository.save(authority);
System.out.println("Seeded role: " + roleName);
}
}
}
}
@@ -1,46 +0,0 @@
package com.sisvietnamvn.web.config;
import com.sisvietnamvn.web.domain.Page;
import com.sisvietnamvn.web.domain.PageLayout;
import com.sisvietnamvn.web.domain.PageStatus;
import com.sisvietnamvn.web.domain.PageType;
import com.sisvietnamvn.web.service.PageService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import java.util.Optional;
@Component
public class SeedRunner implements CommandLineRunner {
private static final Logger LOG = LoggerFactory.getLogger(SeedRunner.class);
private final PageService pageService;
public SeedRunner(PageService pageService) {
this.pageService = pageService;
}
@Override
public void run(String... args) {
try {
Optional<Page> existingPage = pageService.findByPageType(PageType.CONTACT_US);
Page page = existingPage.orElseGet(Page::new);
LOG.info("Seeding/Updating Contact Us Page...");
page.setTitle("Liên hệ");
page.setSlug("contact-us");
page.setPageType(PageType.CONTACT_US);
page.setStatus(PageStatus.PUBLISHED);
page.setLayout(PageLayout.STANDARD);
String content = "{\"time\":1721470000000,\"blocks\":[{\"type\":\"header\",\"data\":{\"text\":\"Cơ sở 1\",\"level\":2}},{\"type\":\"list\",\"data\":{\"style\":\"unordered\",\"items\":[\"Địa chỉ: 215 Hồng Bàng, Phường 11, Quận 5, TP.HCM\",\"Điện thoại: (84.28) 3855 4269\",\"Email: bvdhyd@umc.edu.vn\",\"Website: https://bvdaihoc.com.vn\",\"Map: https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d3919.646549247659!2d106.66014561533418!3d10.75539506240683!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x31752eef2f3484f9%3A0xc34ccfa994f1388b!2sUniversity%20Medical%20Center%20HCMC!5e0!3m2!1sen!2s!4v1689304381861!5m2!1sen!2s\"]}},{\"type\":\"header\",\"data\":{\"text\":\"Cơ sở 2\",\"level\":2}},{\"type\":\"list\",\"data\":{\"style\":\"unordered\",\"items\":[\"Địa chỉ: 201 Nguyễn Chí Thanh, Phường 12, Quận 5, TP.HCM\",\"Điện thoại: (84.28) 3955 5548\",\"Email: bvdaihoccoso2@umc.edu.vn\",\"Website: https://bvdaihoccoso2.com.vn\",\"Map: https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d3919.6105822604626!2d106.66107411533414!3d10.764472862358826!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x31752efc8f2ba129%3A0x63353bd7e2dbf9e!2zQ8ahIHPhu58gMiBC4buHbmggdmnhu4duIMSQ4bqhaSBo4buNYyBZIETGsOG7o2MgVFAuSENN!5e0!3m2!1sen!2s!4v1689304419810!5m2!1sen!2s\"]}},{\"type\":\"header\",\"data\":{\"text\":\"Cơ sở 3\",\"level\":2}},{\"type\":\"list\",\"data\":{\"style\":\"unordered\",\"items\":[\"Địa chỉ: 221B Hoàng Văn Thụ, Phường 8, Quận Phú Nhuận, TP.HCM\",\"Điện thoại: (84.28) 3842 0070\",\"Email: bvdaihoccoso3@umc.edu.vn\",\"Website: https://bvdaihoccoso3.com.vn\",\"Map: https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d3919.1673832863925!2d106.67498701533446!3d10.801646261739345!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x317528dfeb485ec3%3A0x8898124976ea6558!2zQ8ahIHPhu58gMyBC4buHbmggdmnhu4duIMSQ4bqhaSBo4buNYyBZIETGsOG7o2MgVFAuSENN!5e0!3m2!1sen!2s!4v1689304443657!5m2!1sen!2s\"]}},{\"type\":\"header\",\"data\":{\"text\":\"Đơn vị hợp tác\",\"level\":2}},{\"type\":\"list\",\"data\":{\"style\":\"unordered\",\"items\":[\"Địa chỉ: 20-22 Dương Quang Trung, Phường Hòa Hưng, Quận 10, TP.HCM\",\"Điện thoại: 1900 6923\",\"Email: contact.us@umcclinic.com.vn\",\"Website: https://umcclinic.com.vn/\",\"Map: https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d3919.4244583196924!2d106.66699101533423!3d10.778768062145718!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x31752ed1807ebf05%3A0xc3f8e53be131ccb4!2sPh%C3%B2ng%20kh%C3%A1m%20B%E1%BB%87nh%20vi%E1%BB%87n%20%C4%90%E1%BA%A1i%20h%E1%BB%8Dc%20Y%20D%C6%B0%E1%BB%A3c%201!5e0!3m2!1sen!2s!4v1689304495719!5m2!1sen!2s\"]}}],\"version\":\"2.29.1\"}";
page.setContent(content);
pageService.save(page);
LOG.info("Contact Us Page seeded successfully!");
} catch (Exception e) {
LOG.error("Failed to seed Contact Us Page", e);
}
}
}
@@ -1,86 +0,0 @@
package com.sisvietnamvn.web.config;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sisvietnamvn.web.domain.Post;
import com.sisvietnamvn.web.domain.Tag;
import com.sisvietnamvn.web.plugins.swiperslider.SwiperSliderAdminController.SliderGroup;
import com.sisvietnamvn.web.plugins.swiperslider.SwiperSliderAdminController.SliderItem;
import com.sisvietnamvn.web.repository.PostRepository;
import com.sisvietnamvn.web.repository.TagRepository;
import com.sisvietnamvn.web.service.SettingService;
import org.springframework.boot.CommandLineRunner;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Component
public class SliderSeeder implements CommandLineRunner {
private final PostRepository postRepository;
private final TagRepository tagRepository;
private final SettingService settingService;
private final ObjectMapper objectMapper;
public SliderSeeder(PostRepository postRepository, TagRepository tagRepository, SettingService settingService, ObjectMapper objectMapper) {
this.postRepository = postRepository;
this.tagRepository = tagRepository;
this.settingService = settingService;
this.objectMapper = objectMapper;
}
@Override
public void run(String... args) throws Exception {
System.out.println("=== STARTING SWIPER SLIDER SEEDER ===");
Optional<Tag> tagOpt = tagRepository.findByName("Đào tạo");
if (tagOpt.isEmpty()) {
List<Tag> possible = tagRepository.findByNameContainingIgnoreCase("Đào tạo");
if (!possible.isEmpty()) {
tagOpt = Optional.of(possible.get(0));
System.out.println("Using fallback tag: " + tagOpt.get().getName());
}
}
if (tagOpt.isEmpty()) {
System.out.println("Tag 'Đào tạo' not found!");
return;
}
List<Post> posts = postRepository.findByTagId(tagOpt.get().getId(), PageRequest.of(0, 10));
System.out.println("Found " + posts.size() + " posts with tag " + tagOpt.get().getName());
List<SliderItem> items = posts.stream().map(post -> {
String imageUrl = post.getFeaturedImage() != null ? post.getFeaturedImage() : "";
String linkUrl = "/dao-tao/" + post.getSlug();
return new SliderItem(post.getTitle(), imageUrl, linkUrl, "", null, null, null);
}).collect(Collectors.toList());
SliderGroup newGroup = new SliderGroup(
"chuong-trinh-khac",
"Chương trình khác",
"card",
"",
"",
"Tự động tạo từ thẻ " + tagOpt.get().getName(),
items
);
String json = settingService.getValue("plugin_swiper_slider_data", "[]");
List<SliderGroup> groups = new ArrayList<>();
try {
groups = objectMapper.readValue(json, new TypeReference<List<SliderGroup>>() {});
} catch (Exception e) {}
groups.removeIf(g -> "chuong-trinh-khac".equals(g.slug()));
groups.add(newGroup);
String newJson = objectMapper.writeValueAsString(groups);
settingService.setValue("plugin_swiper_slider_data", newJson);
System.out.println("=== SWIPER SLIDER SEEDED SUCCESSFULLY ===");
}
}
@@ -1,6 +1,5 @@
package com.sisvietnamvn.web.controller;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -19,13 +18,13 @@ import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sisvietnamvn.web.domain.Page;
import com.sisvietnamvn.web.domain.PageStatus;
import com.sisvietnamvn.web.domain.Post;
import com.sisvietnamvn.web.hook.HookManager;
import com.sisvietnamvn.web.repository.PostRepository;
import com.sisvietnamvn.web.security.AuthoritiesConstants;
import com.sisvietnamvn.web.security.SecurityUtils;
import com.sisvietnamvn.web.service.PageService;
import com.sisvietnamvn.web.domain.Post;
import com.sisvietnamvn.web.domain.PageStatus;
import com.sisvietnamvn.web.repository.PostRepository;
/**
* Controller for rendering public-facing pages dynamically.
@@ -424,6 +423,19 @@ public class PageController {
data.put("htmlContent", snippetService.getSnippetContent(snippetId));
}
}
if (block.containsKey("tunes")) {
Map<String, Object> tunes = (Map<String, Object>) block.get("tunes");
if (tunes != null && tunes.containsKey("textStyling")) {
Map<String, Object> textStyling = (Map<String, Object>) tunes.get("textStyling");
if (textStyling != null) {
block.put("cssClass", textStyling.get("cssClass"));
block.put("customStyle", textStyling.get("customStyle"));
block.put("alignment", textStyling.get("alignment"));
block.put("elementId", textStyling.get("elementId"));
block.put("customAttrs", textStyling.get("customAttrs"));
}
}
}
}
}
} catch (JsonProcessingException e) {
@@ -4,6 +4,9 @@ import com.sisvietnamvn.web.domain.Media;
import com.sisvietnamvn.web.service.MediaService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@@ -71,8 +74,32 @@ public class MediaController {
}
}
/**
* Paginated media list with optional keyword search.
* GET /api/manage/media/list?page=0&size=24&keyword=photo
* Returns: { content: [...], totalPages, totalElements, number, last }
*/
@GetMapping("/list")
public ResponseEntity<List<Media>> getMediaList() {
return ResponseEntity.ok(mediaService.findAll());
public ResponseEntity<Map<String, Object>> getMediaList(
@RequestParam(value = "page", defaultValue = "0") int page,
@RequestParam(value = "size", defaultValue = "24") int size,
@RequestParam(value = "keyword", required = false) String keyword) {
log.debug("REST request to list media (page={}, size={}, keyword={})", page, size, keyword);
// Clamp size to prevent abuse
size = Math.min(size, 100);
Pageable pageable = PageRequest.of(page, size);
Page<Media> mediaPage = mediaService.findFiltered(null, keyword, pageable);
Map<String, Object> result = new HashMap<>();
result.put("content", mediaPage.getContent());
result.put("totalPages", mediaPage.getTotalPages());
result.put("totalElements", mediaPage.getTotalElements());
result.put("number", mediaPage.getNumber());
result.put("last", mediaPage.isLast());
return ResponseEntity.ok(result);
}
}
@@ -67,7 +67,7 @@ h6,
display: none !important;
}
.desktop-only {
display: block !important;
display: block;
}
/* ==========================================================================
@@ -1738,3 +1738,98 @@ Large Desktop (@media (min-width: 1366px)): Lines 1421 - 1481. */
font-size: 1.05rem !important;
}
}
/* ==========================================================================
CSS Grid System Override for Grid Block (sis-grid-layout)
========================================================================== */
.sis-grid-layout {
display: grid !important;
grid-template-columns: 1fr !important;
gap: 1.5rem !important;
}
/* CSS Grid Alignment Support */
@media (min-width: 768px) {
.sis-grid-layout.justify-content-center {
display: flex !important;
flex-wrap: nowrap !important;
justify-content: center !important;
}
.sis-grid-layout.justify-content-center > [class*="sis-grid-span-"],
.sis-grid-layout.justify-content-center > [class*="col-"] {
flex: 0 0 auto !important;
width: auto !important;
max-width: none !important;
}
.sis-grid-layout.justify-content-center > .sis-grid-span-4,
.sis-grid-layout.justify-content-center > .col-md-4 {
flex: 0 0 calc(33.333333% - 1rem) !important;
width: calc(33.333333% - 1rem) !important;
max-width: calc(33.333333% - 1rem) !important;
}
.sis-grid-layout.justify-content-center > .sis-grid-span-6,
.sis-grid-layout.justify-content-center > .col-md-6 {
flex: 0 0 calc(50% - 1rem) !important;
width: calc(50% - 1rem) !important;
max-width: calc(50% - 1rem) !important;
}
.sis-grid-layout.justify-content-center > .sis-grid-span-3,
.sis-grid-layout.justify-content-center > .col-md-3 {
flex: 0 0 calc(25% - 1rem) !important;
width: calc(25% - 1rem) !important;
max-width: calc(25% - 1rem) !important;
}
}
.sis-grid-layout.justify-content-end {
display: flex !important;
justify-content: flex-end !important;
}
.sis-grid-layout.align-items-center {
align-items: center !important;
}
.sis-grid-layout.justify-content-between {
justify-content: space-between !important;
}
.sis-grid-layout.justify-content-around {
justify-content: space-around !important;
}
@media (min-width: 768px) {
.sis-grid-layout {
grid-template-columns: repeat(12, 1fr) !important;
}
}
/* Fallback/Mobile-first grid spans (full width on mobile) */
.sis-grid-span-1,
.sis-grid-span-2,
.sis-grid-span-3,
.sis-grid-span-4,
.sis-grid-span-5,
.sis-grid-span-6,
.sis-grid-span-7,
.sis-grid-span-8,
.sis-grid-span-9,
.sis-grid-span-10,
.sis-grid-span-11,
.sis-grid-span-12 {
grid-column: span 12 !important;
}
/* Tablet & Desktop grid spans */
@media (min-width: 768px) {
.sis-grid-span-1 { grid-column: span 1 !important; }
.sis-grid-span-2 { grid-column: span 2 !important; }
.sis-grid-span-3 { grid-column: span 3 !important; }
.sis-grid-span-4 { grid-column: span 4 !important; }
.sis-grid-span-5 { grid-column: span 5 !important; }
.sis-grid-span-6 { grid-column: span 6 !important; }
.sis-grid-span-7 { grid-column: span 7 !important; }
.sis-grid-span-8 { grid-column: span 8 !important; }
.sis-grid-span-9 { grid-column: span 9 !important; }
.sis-grid-span-10 { grid-column: span 10 !important; }
.sis-grid-span-11 { grid-column: span 11 !important; }
.sis-grid-span-12 { grid-column: span 12 !important; }
}
@@ -191,6 +191,13 @@ class SISRawHtmlTool {
stretched: this.stretchCheck ? this.stretchCheck.checked : !!(this.data && this.data.stretched)
};
}
destroy() {
if (this.editorInstance) {
this.editorInstance.dispose();
this.editorInstance = null;
}
}
}
class SISAccordionTool {
@@ -525,189 +532,7 @@ class SISYouTubeTool {
}
}
class SISHeroBannerTool {
static get toolbox() {
return {
title: 'Hero Banner',
icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg>'
};
}
constructor({ data, api, readOnly }) {
this.api = api;
this.readOnly = readOnly;
this.data = {
title: (data && data.title) ? data.title : '',
subtitle: (data && data.subtitle) ? data.subtitle : '',
bgImage: (data && data.bgImage) ? data.bgImage : '',
btnText: (data && data.btnText) ? data.btnText : '',
btnLink: (data && data.btnLink) ? data.btnLink : '',
height: (data && data.height) ? data.height : '350px',
textAlign: (data && data.textAlign) ? data.textAlign : 'center',
overlayOpacity: (data && data.overlayOpacity !== undefined) ? data.overlayOpacity : '0.4',
stretched: (data && data.stretched !== undefined) ? !!data.stretched : true
};
}
render() {
const container = document.createElement('div');
container.style.border = '1px solid #e3e6f0';
container.style.borderRadius = '8px';
container.style.padding = '14px';
container.style.background = '#fff';
container.style.marginBottom = '12px';
const headerLabel = document.createElement('label');
headerLabel.className = 'font-weight-bold text-primary small mb-2 d-block';
headerLabel.innerHTML = '<i class="fas fa-image"></i> Hero Banner Block Settings';
container.appendChild(headerLabel);
// Stretch toggle
const stretchWrapper = document.createElement('div');
stretchWrapper.className = 'custom-control custom-switch mb-3';
const stretchCheck = document.createElement('input');
stretchCheck.type = 'checkbox';
stretchCheck.className = 'custom-control-input';
stretchCheck.id = 'hero_stretch_' + Math.random().toString(36).substring(7);
stretchCheck.checked = !!this.data.stretched;
const stretchLabel = document.createElement('label');
stretchLabel.className = 'custom-control-label small font-weight-bold text-secondary';
stretchLabel.htmlFor = stretchCheck.id;
stretchLabel.innerHTML = '<i class="fas fa-arrows-alt-h"></i> Stretch Banner to Full Screen Width';
stretchCheck.addEventListener('change', () => {
this.data.stretched = stretchCheck.checked;
});
stretchWrapper.appendChild(stretchCheck);
stretchWrapper.appendChild(stretchLabel);
container.appendChild(stretchWrapper);
// Inputs
this.titleInput = this._createInput('Banner Title', 'e.g. Bệnh Viện Đa Khoa Quốc Tế S.I.S Cần Thơ', this.data.title);
this.subtitleInput = this._createInput('Subtitle / Description', 'e.g. Trao niềm tin - Nhận sức khỏe...', this.data.subtitle);
this.bgImageInput = this._createInput('Background Image URL', 'https://example.com/hero-banner.jpg', this.data.bgImage);
// Row for Height & Button Link
const configRow = document.createElement('div');
configRow.className = 'form-row';
const heightCol = document.createElement('div');
heightCol.className = 'col-md-4 mb-2';
this.heightInput = this._createInput('Banner Height (e.g. 350px, 500px, 60vh)', 'e.g. 350px', this.data.height);
heightCol.appendChild(this.heightInput);
const btnCol1 = document.createElement('div');
btnCol1.className = 'col-md-4 mb-2';
this.btnTextInput = this._createInput('Button Text (Optional)', 'e.g. Đặt Lịch Khám', this.data.btnText);
btnCol1.appendChild(this.btnTextInput);
const btnCol2 = document.createElement('div');
btnCol2.className = 'col-md-4 mb-2';
this.btnLinkInput = this._createInput('Button Link URL (Optional)', 'e.g. /dat-lich', this.data.btnLink);
btnCol2.appendChild(this.btnLinkInput);
configRow.appendChild(heightCol);
configRow.appendChild(btnCol1);
configRow.appendChild(btnCol2);
// Live Preview Box
const previewBox = document.createElement('div');
previewBox.className = 'hero-banner-preview p-4 rounded text-white my-2';
previewBox.style.position = 'relative';
previewBox.style.minHeight = this.data.height || '350px';
previewBox.style.display = 'flex';
previewBox.style.flexDirection = 'column';
previewBox.style.justifyContent = 'center';
previewBox.style.alignItems = 'center';
previewBox.style.textAlign = 'center';
previewBox.style.backgroundSize = 'cover';
previewBox.style.backgroundPosition = 'center';
previewBox.style.overflow = 'hidden';
const overlay = document.createElement('div');
overlay.style.position = 'absolute';
overlay.style.top = '0';
overlay.style.left = '0';
overlay.style.right = '0';
overlay.style.bottom = '0';
overlay.style.background = '#000';
overlay.style.zIndex = '1';
previewBox.appendChild(overlay);
const contentBox = document.createElement('div');
contentBox.style.position = 'relative';
contentBox.style.zIndex = '2';
previewBox.appendChild(contentBox);
const updatePreview = () => {
const bg = this.bgImageInput.querySelector('input').value.trim();
const title = this.titleInput.querySelector('input').value.trim() || 'Hero Banner Title';
const sub = this.subtitleInput.querySelector('input').value.trim();
const btnT = this.btnTextInput.querySelector('input').value.trim();
const bannerH = this.heightInput.querySelector('input').value.trim() || '350px';
previewBox.style.minHeight = bannerH;
previewBox.style.backgroundImage = bg ? 'url("' + bg + '")' : 'linear-gradient(135deg, #002554, #881C1C)';
overlay.style.opacity = this.data.overlayOpacity || '0.4';
let html = '<h4 class="font-weight-bold mb-1 text-white">' + title + '</h4>';
if (sub) html += '<p class="small mb-2 text-light">' + sub + '</p>';
if (btnT) html += '<span class="btn btn-sm btn-danger font-weight-bold px-3">' + btnT + '</span>';
contentBox.innerHTML = html;
};
[this.titleInput, this.subtitleInput, this.bgImageInput].forEach(wrapper => {
const input = wrapper.querySelector('input');
input.addEventListener('input', updatePreview);
if (this.readOnly) input.disabled = true;
container.appendChild(wrapper);
});
[this.heightInput, this.btnTextInput, this.btnLinkInput].forEach(wrapper => {
const input = wrapper.querySelector('input');
input.addEventListener('input', updatePreview);
if (this.readOnly) input.disabled = true;
});
container.appendChild(configRow);
container.appendChild(previewBox);
updatePreview();
return container;
}
_createInput(labelText, placeholder, value) {
const wrapper = document.createElement('div');
wrapper.className = 'form-group mb-2';
const lbl = document.createElement('label');
lbl.className = 'small font-weight-bold text-secondary mb-1 d-block';
lbl.innerText = labelText;
const inp = document.createElement('input');
inp.type = 'text';
inp.className = 'form-control form-control-sm';
inp.placeholder = placeholder;
inp.value = value || '';
wrapper.appendChild(lbl);
wrapper.appendChild(inp);
return wrapper;
}
save(blockContent) {
const stretchCheck = blockContent.querySelector('.custom-control-input');
return {
title: this.titleInput ? this.titleInput.querySelector('input').value : this.data.title,
subtitle: this.subtitleInput ? this.subtitleInput.querySelector('input').value : this.data.subtitle,
bgImage: this.bgImageInput ? this.bgImageInput.querySelector('input').value : this.data.bgImage,
btnText: this.btnTextInput ? this.btnTextInput.querySelector('input').value : this.data.btnText,
btnLink: this.btnLinkInput ? this.btnLinkInput.querySelector('input').value : this.data.btnLink,
height: this.heightInput ? this.heightInput.querySelector('input').value : this.data.height,
textAlign: this.data.textAlign || 'center',
overlayOpacity: this.data.overlayOpacity || '0.4',
stretched: stretchCheck ? stretchCheck.checked : !!this.data.stretched
};
}
}
// SISHeroBannerTool extracted to /js/manage/editor-plugins/hero-banner.js
@@ -733,6 +558,7 @@ function initSISEditor(holderId, hiddenInputId, initialData, skipSubmitHandler =
builtInTools.header = {
class: Header,
inlineToolbar: ['link'],
tunes: ['textStyling'],
config: {
placeholder: 'Header text...',
levels: [1, 2, 3, 4, 5, 6],
@@ -741,16 +567,22 @@ function initSISEditor(holderId, hiddenInputId, initialData, skipSubmitHandler =
};
}
builtInTools.paragraph = {
tunes: ['textStyling']
};
if (typeof NestedList !== 'undefined') {
builtInTools.list = {
class: NestedList,
inlineToolbar: true,
tunes: ['textStyling'],
config: { defaultStyle: 'unordered' }
};
} else if (typeof List !== 'undefined') {
builtInTools.list = {
class: List,
inlineToolbar: true,
tunes: ['textStyling'],
config: { defaultStyle: 'unordered' }
};
}
@@ -759,6 +591,7 @@ function initSISEditor(holderId, hiddenInputId, initialData, skipSubmitHandler =
builtInTools.quote = {
class: Quote,
inlineToolbar: true,
tunes: ['textStyling'],
config: {
quotePlaceholder: 'Enter a quote',
captionPlaceholder: 'Quote\'s author'
@@ -767,31 +600,135 @@ function initSISEditor(holderId, hiddenInputId, initialData, skipSubmitHandler =
}
if (typeof Delimiter !== 'undefined') builtInTools.delimiter = { class: Delimiter };
if (typeof Table !== 'undefined') builtInTools.table = { class: Table, inlineToolbar: true, config: { rows: 2, cols: 3 } };
if (typeof Table !== 'undefined') {
builtInTools.table = {
class: Table,
inlineToolbar: true,
tunes: ['textStyling'],
config: { rows: 2, cols: 3 }
};
}
if (typeof CodeTool !== 'undefined') builtInTools.code = { class: CodeTool };
if (typeof Warning !== 'undefined') builtInTools.warning = { class: Warning, inlineToolbar: true, config: { titlePlaceholder: 'Title', messagePlaceholder: 'Message' } };
if (typeof Marker !== 'undefined') builtInTools.marker = { class: Marker };
if (typeof InlineCode !== 'undefined') builtInTools.inlineCode = { class: InlineCode };
if (typeof Underline !== 'undefined') builtInTools.underline = { class: Underline };
if (typeof ImageTool !== 'undefined') {
builtInTools.image = {
class: ImageTool,
config: {
endpoints: {
byFile: '/api/manage/media/upload',
byUrl: '/api/manage/media/fetchUrl'
},
uploader: {
uploadByUrl(url) {
return Promise.resolve({
success: 1,
file: { url: url }
});
}
},
field: 'file',
types: 'image/*'
// Custom Image Block that bypasses default ImageTool uploader and triggers SISMediaPicker directly
class SISCustomImageTool {
static get toolbox() {
return {
title: 'Image',
icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><polyline points="21 15 16 10 5 21"></polyline></svg>'
};
}
constructor({ data, api, readOnly }) {
this.api = api;
this.readOnly = readOnly;
this.data = {
file: data.file || { url: '' },
caption: data.caption || '',
withBorder: !!data.withBorder,
stretched: !!data.stretched,
withBackground: !!data.withBackground
};
this.container = null;
}
render() {
this.container = document.createElement('div');
this.container.className = 'sis-custom-image-block-wrapper position-relative py-2';
if (!this.data.file.url) {
// Render placeholder / picker launcher
const trigger = document.createElement('div');
trigger.className = 'border rounded p-4 text-center cursor-pointer text-muted bg-light d-flex flex-column align-items-center justify-content-center';
trigger.style.minHeight = '150px';
trigger.innerHTML = '<i class="fas fa-images fa-2x mb-2 text-primary"></i><span>Chọn ảnh từ Thư viện (Media Library)</span>';
trigger.addEventListener('click', () => this._openPicker());
this.container.appendChild(trigger);
} else {
// Render Image preview along with quick settings button
this._renderPreview();
}
return this.container;
}
_openPicker() {
if (window.SISMediaPicker) {
SISMediaPicker.open((url, mediaObj, config) => {
if (url) {
this.data.file = {
url: url,
style: config ? config.style : '',
cssClass: config ? config.cssClass : '',
alt: config ? config.alt : '',
customAttributes: config ? config.customAttributes : '',
aspectRatio: config ? config.aspectRatio : ''
};
this.data.caption = (config && config.alt) || '';
this._renderPreview();
}
});
} else {
alert('SISMediaPicker is not loaded.');
}
}
_renderPreview() {
if (!this.container) return;
this.container.innerHTML = '';
const imgWrapper = document.createElement('div');
imgWrapper.className = 'position-relative text-center d-inline-block w-100';
const img = document.createElement('img');
img.src = this.data.file.url;
img.className = 'img-fluid rounded shadow-sm';
if (this.data.file.style) img.style.cssText = this.data.file.style;
if (this.data.file.cssClass) img.className = this.data.file.cssClass;
if (this.data.file.alt) img.alt = this.data.file.alt;
// Re-configure button
if (!this.readOnly) {
const settingsBtn = document.createElement('button');
settingsBtn.type = 'button';
settingsBtn.className = 'btn btn-xs btn-primary shadow-sm position-absolute';
settingsBtn.style.cssText = 'top: 10px; right: 10px; z-index: 100; font-size: 11px; padding: 2px 8px; border-radius: 4px; opacity: 0.85;';
settingsBtn.innerHTML = '<i class="fas fa-cog mr-1"></i> Quick settings';
settingsBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
this._openPicker();
});
imgWrapper.appendChild(settingsBtn);
}
imgWrapper.appendChild(img);
this.container.appendChild(imgWrapper);
// Caption input underneath image
const captionInput = document.createElement('input');
captionInput.type = 'text';
captionInput.className = 'form-control form-control-sm text-center border-0 text-muted mt-2';
captionInput.placeholder = 'Nhập chú thích hình ảnh (Caption)...';
captionInput.value = this.data.caption || '';
captionInput.disabled = this.readOnly;
captionInput.addEventListener('input', (e) => {
this.data.caption = e.target.value;
});
this.container.appendChild(captionInput);
}
save(block) {
return this.data;
}
}
if (typeof ImageTool !== 'undefined' || true) {
builtInTools.image = {
class: SISCustomImageTool
};
window.SISEditorPlugins.image = builtInTools.image;
}
@@ -806,14 +743,30 @@ function initSISEditor(holderId, hiddenInputId, initialData, skipSubmitHandler =
builtInTools.raw = { class: SISRawHtmlTool };
builtInTools.accordion = { class: SISAccordionTool };
builtInTools.youtube = { class: SISYouTubeTool };
builtInTools.hero = { class: SISHeroBannerTool };
if (typeof SISHeroBannerTool !== 'undefined') {
builtInTools.hero = { class: SISHeroBannerTool };
}
if (typeof SISStickyNavTool !== 'undefined') {
builtInTools.stickyNav = { class: SISStickyNavTool };
}
if (typeof TextStylingTune !== 'undefined') {
builtInTools.textStyling = { class: TextStylingTune };
}
// === Merge built-in tools with any registered plugins ===
var allTools = Object.assign({}, builtInTools, window.SISEditorPlugins);
// Apply the textStyling tune to all block tools dynamically
for (var toolName in allTools) {
if (toolName !== 'textStyling') {
if (!allTools[toolName].tunes) {
allTools[toolName].tunes = ['textStyling'];
} else if (!allTools[toolName].tunes.includes('textStyling')) {
allTools[toolName].tunes.push('textStyling');
}
}
}
// === Parse initial data ===
var editorData = null;
if (initialData && typeof initialData === 'string') {
@@ -38,6 +38,7 @@ class FlexTool {
this.data[`id${i}`] = data[`id${i}`] || '';
this.data[`attr${i}`] = data[`attr${i}`] || data[`attributes${i}`] || '';
this.data[`width${i}`] = parseInt(data[`width${i}`]) || 0; // 0 means auto
this.data[`caption${i}`] = data[`caption${i}`] || '';
}
this.wrapper = undefined;
}
@@ -337,15 +338,104 @@ class FlexTool {
headerRow.appendChild(controlsWrapper);
colDiv.appendChild(headerRow);
colDiv.appendChild(contentDiv);
// --- Sub-Plugin (Sub-Content) Section ---
const subWrapper = document.createElement('div');
if (this.data[`subType${i}`] !== 'none') {
subWrapper.className = 'mt-3 pt-2 border-top';
}
const subHeader = document.createElement('div');
subHeader.className = 'd-flex justify-content-between align-items-center mb-2';
const subLabel = document.createElement('label');
subLabel.className = 'small font-weight-bold text-muted mb-0';
subLabel.style.fontSize = '10px';
subLabel.innerText = 'SUB-CONTENT / INJECT PLUGIN';
subHeader.appendChild(subLabel);
const subTypeSelect = document.createElement('select');
subTypeSelect.className = 'custom-select custom-select-sm';
subTypeSelect.style.width = '130px';
subTypeSelect.style.fontSize = '10px';
if (this.readOnly) subTypeSelect.disabled = true;
const subTypes = [
{ value: 'none', label: 'None' },
{ value: 'caption', label: 'Text Caption' },
{ value: 'html', label: 'HTML/Text' },
{ value: 'image', label: 'Image' },
{ value: 'youtube', label: 'YouTube' },
{ value: 'accordion', label: 'Accordion' }
];
if (window.SISEditorPlugins) {
Object.keys(window.SISEditorPlugins).forEach(key => {
if (!subTypes.some(t => t.value === key)) {
subTypes.push({ value: key, label: `[Plugin] ${key}` });
}
});
}
subTypes.forEach(t => {
const opt = document.createElement('option');
opt.value = t.value;
opt.innerText = t.label;
opt.selected = this.data[`subType${i}`] === t.value;
subTypeSelect.appendChild(opt);
});
subHeader.appendChild(subTypeSelect);
subWrapper.appendChild(subHeader);
const subContentDiv = document.createElement('div');
subWrapper.appendChild(subContentDiv);
colDiv.appendChild(subWrapper);
const renderSubInputs = () => {
subContentDiv.innerHTML = '';
const subType = this.data[`subType${i}`];
if (subType === 'none') {
subWrapper.className = '';
} else {
subWrapper.className = 'mt-3 pt-2 border-top';
}
if (subType === 'none') {
// Empty
} else if (subType === 'caption') {
const captionGroup = document.createElement('div');
captionGroup.className = 'form-group mb-2';
const captionInput = document.createElement('input');
captionInput.type = 'text';
captionInput.className = 'form-control form-control-sm';
captionInput.style.fontSize = '11px';
captionInput.placeholder = 'Optional column caption / text...';
captionInput.value = this.data[`caption${i}`] || '';
if (this.readOnly) captionInput.disabled = true;
captionInput.addEventListener('input', (e) => {
this.data[`caption${i}`] = e.target.value;
});
captionGroup.appendChild(captionInput);
subContentDiv.appendChild(captionGroup);
} else {
this._renderTypeSpecificInput(`sub_${i}`, subContentDiv, subType);
}
};
subTypeSelect.addEventListener('change', (e) => {
this.data[`subType${i}`] = e.target.value;
renderSubInputs();
});
row.appendChild(colDiv);
this._renderTypeSpecificInput(i, contentDiv);
renderSubInputs();
}
this.inputsContainer.appendChild(row);
}
_renderTypeSpecificInput(i, container) {
_renderTypeSpecificInput(i, container, explicitType) {
container.innerHTML = '';
// Remove tracking of previous instance
@@ -357,7 +447,7 @@ class FlexTool {
typeContainer.className = 'mb-3';
container.appendChild(typeContainer);
const type = this.data[`type${i}`];
const type = explicitType || this.data[`type${i}`] || 'html';
// Check if type is a dynamic editor plugin registered in window.SISEditorPlugins
if (window.SISEditorPlugins && window.SISEditorPlugins[type]) {
@@ -411,11 +501,13 @@ class FlexTool {
controls.style.gap = '8px';
controls.innerHTML = `
<div class="text-muted small text-center mb-1"><i class="fas fa-cog"></i> Image Controls</div>
<button type="button" class="btn btn-outline-primary btn-sm w-100" id="btn-upload-img-${i}"><i class="fas fa-upload"></i> Upload Image</button>
<button type="button" class="btn btn-outline-info btn-sm w-100" id="btn-fetch-url-${i}"><i class="fas fa-link"></i> Fetch URL</button>
<button type="button" class="btn btn-outline-secondary btn-sm w-100" id="btn-media-lib-${i}"><i class="fas fa-images"></i> Media Library</button>
<button type="button" class="btn btn-outline-danger btn-sm w-100 mt-2" id="btn-clear-img-${i}"><i class="fas fa-trash"></i> Remove Image</button>
<div class="d-flex justify-content-between align-items-center mb-1">
<span class="text-muted small font-weight-bold"><i class="fas fa-image"></i> Image Settings</span>
<button type="button" class="btn btn-link btn-xs text-danger p-0" id="btn-clear-img-${i}" title="Remove Image"><i class="fas fa-trash-alt"></i></button>
</div>
<button type="button" class="btn btn-primary btn-sm w-100 shadow-sm" id="btn-media-lib-${i}">
<i class="fas fa-sliders-h mr-1"></i> Quick Settings / Select Image
</button>
`;
const uploadBtn = controls.querySelector(`#btn-upload-img-${i}`);
@@ -450,16 +542,23 @@ class FlexTool {
mediaBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
if (typeof openMediaPickerModal === 'function') {
openMediaPickerModal((url) => {
if (window.SISMediaPicker) {
SISMediaPicker.open((url, mediaObj, config) => {
if (url) {
this.data[`col${i}`] = {
file: { url: url },
caption: '',
caption: (config && config.alt) || '',
withBorder: false,
withBackground: false,
stretched: false
};
if (config) {
this.data[`imgStyle${i}`] = config.style || '';
this.data[`imgClass${i}`] = config.cssClass || '';
this.data[`imgAlt${i}`] = config.alt || '';
this.data[`imgAttrs${i}`] = config.customAttributes || '';
this.data[`imgAspectRatio${i}`] = config.aspectRatio || '';
}
this._renderTypeSpecificInput(i, container);
}
});
@@ -544,10 +643,17 @@ class FlexTool {
chooseBtn.innerText = 'Choose';
if (this.readOnly) chooseBtn.disabled = true;
chooseBtn.addEventListener('click', () => {
if (typeof openMediaPickerModal === 'function') {
openMediaPickerModal((selectedUrl) => {
if (window.SISMediaPicker) {
SISMediaPicker.open((selectedUrl, mediaObj, config) => {
input.value = selectedUrl;
this.data[`imgUrl${i}`] = selectedUrl;
if (config) {
this.data[`imgStyle${i}`] = config.style || '';
this.data[`imgClass${i}`] = config.cssClass || '';
this.data[`imgAlt${i}`] = config.alt || '';
this.data[`imgAttrs${i}`] = config.customAttributes || '';
this.data[`imgAspectRatio${i}`] = config.aspectRatio || '';
}
previewImg.src = selectedUrl;
previewBox.style.display = 'block';
});
@@ -681,6 +787,8 @@ class FlexTool {
typeContainer.appendChild(contentTextarea);
}
// Advanced Settings (ID and Class)
const advancedHeader = document.createElement('div');
advancedHeader.className = 'font-weight-bold text-muted small mb-2';
@@ -760,6 +868,31 @@ class FlexTool {
savedData[`id${i}`] = this.data[`id${i}`] || '';
savedData[`attr${i}`] = this.data[`attr${i}`] || '';
savedData[`width${i}`] = this.data[`width${i}`] || 0;
savedData[`caption${i}`] = this.data[`caption${i}`] || '';
// Sub-plugin serialization
const subType = this.data[`subType${i}`] || 'none';
savedData[`subType${i}`] = subType;
if (this.activeInstances[`sub_${i}`]) {
try {
const subPluginData = this.activeInstances[`sub_${i}`].save();
savedData[`colsub_${i}`] = subPluginData;
} catch (e) {
console.error(`Failed to save sub-plugin ${subType} inside FlexTool:`, e);
savedData[`colsub_${i}`] = this.data[`colsub_${i}`];
}
} else {
savedData[`colsub_${i}`] = this.data[`colsub_${i}`] || '';
}
savedData[`imgUrlsub_${i}`] = this.data[`imgUrlsub_${i}`] || '';
savedData[`ytUrlsub_${i}`] = this.data[`ytUrlsub_${i}`] || '';
savedData[`accTitlesub_${i}`] = this.data[`accTitlesub_${i}`] || '';
savedData[`accContentsub_${i}`] = this.data[`accContentsub_${i}`] || '';
savedData[`classsub_${i}`] = this.data[`classsub_${i}`] || '';
savedData[`idsub_${i}`] = this.data[`idsub_${i}`] || '';
savedData[`attrsub_${i}`] = this.data[`attrsub_${i}`] || '';
}
return savedData;
}
@@ -16,6 +16,8 @@ class GridTool {
this.readOnly = readOnly;
this.data = {
cols: parseInt(data.cols) || 2,
itemAlignment: data.itemAlignment || data.alignment || (data.centerItems ? 'center' : 'left'),
globalStyle: data.globalStyle || '',
globalClass: data.globalClass || '',
globalId: data.globalId || '',
globalAttributes: data.globalAttributes || data.attributes || ''
@@ -34,8 +36,20 @@ class GridTool {
this.data[`id${i}`] = data[`id${i}`] || '';
this.data[`attr${i}`] = data[`attr${i}`] || data[`attributes${i}`] || '';
this.data[`width${i}`] = parseInt(data[`width${i}`]) || 0; // 0 means auto
this.data[`caption${i}`] = data[`caption${i}`] || '';
// Sub-plugin properties
this.data[`subType${i}`] = data[`subType${i}`] || 'none';
this.data[`colsub_${i}`] = data[`colsub_${i}`] || '';
this.data[`imgUrlsub_${i}`] = data[`imgUrlsub_${i}`] || '';
this.data[`ytUrlsub_${i}`] = data[`ytUrlsub_${i}`] || '';
this.data[`accTitlesub_${i}`] = data[`accTitlesub_${i}`] || '';
this.data[`accContentsub_${i}`] = data[`accContentsub_${i}`] || '';
this.data[`classsub_${i}`] = data[`classsub_${i}`] || '';
this.data[`idsub_${i}`] = data[`idsub_${i}`] || '';
this.data[`attrsub_${i}`] = data[`attrsub_${i}`] || '';
}
this.wrapper = undefined;
this.wrapper = undefined;
}
render() {
@@ -73,14 +87,66 @@ class GridTool {
colDiv.appendChild(colInput);
settingsRow.appendChild(colDiv);
// 2. Global CSS Class
// 2. Alignment Dropdown option (Left, Center, Right, Space Between, Space Around)
const alignDiv = document.createElement('div');
alignDiv.className = 'col-md-2 mb-2';
const alignLabel = document.createElement('label');
alignLabel.className = 'small font-weight-bold text-secondary mb-1';
alignLabel.innerText = 'Item Alignment';
alignDiv.appendChild(alignLabel);
const alignSelect = document.createElement('select');
alignSelect.className = 'form-control form-control-sm';
if (this.readOnly) alignSelect.disabled = true;
const alignOptions = [
{ value: 'left', label: 'Left (default)' },
{ value: 'center', label: 'Center' },
{ value: 'right', label: 'Right' },
{ value: 'between', label: 'Space Between' },
{ value: 'around', label: 'Space Around' }
];
alignOptions.forEach(opt => {
const optionEl = document.createElement('option');
optionEl.value = opt.value;
optionEl.innerText = opt.label;
if (this.data.itemAlignment === opt.value) {
optionEl.selected = true;
}
alignSelect.appendChild(optionEl);
});
alignSelect.addEventListener('change', (e) => {
this.data.itemAlignment = e.target.value;
});
alignDiv.appendChild(alignSelect);
settingsRow.appendChild(alignDiv);
// 3. Dedicated Style Attribute
const styleDiv = document.createElement('div');
styleDiv.className = 'col-md-3 mb-2';
styleDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1"><i class="fas fa-paint-brush"></i> Dedicated Style</label>';
const styleInput = document.createElement('input');
styleInput.type = 'text';
styleInput.className = 'form-control form-control-sm';
styleInput.placeholder = 'e.g. background: #f8f9fa; padding: 20px;';
styleInput.value = this.data.globalStyle || '';
if (this.readOnly) styleInput.disabled = true;
styleInput.addEventListener('input', (e) => this.data.globalStyle = e.target.value.trim());
this.globalStyleInput = styleInput;
styleDiv.appendChild(styleInput);
settingsRow.appendChild(styleDiv);
// 4. Global CSS Class
const classDiv = document.createElement('div');
classDiv.className = 'col-md-3 mb-2';
classDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Grid CSS Class(es)</label>';
classDiv.className = 'col-md-2 mb-2';
classDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Grid CSS Class</label>';
const classInput = document.createElement('input');
classInput.type = 'text';
classInput.className = 'form-control form-control-sm';
classInput.placeholder = 'e.g. my-custom-grid';
classInput.placeholder = 'e.g. my-grid';
classInput.value = this.data.globalClass || '';
if (this.readOnly) classInput.disabled = true;
classInput.addEventListener('input', (e) => this.data.globalClass = e.target.value.trim());
@@ -88,14 +154,14 @@ class GridTool {
classDiv.appendChild(classInput);
settingsRow.appendChild(classDiv);
// 3. Global HTML ID
// 5. Global HTML ID
const idDiv = document.createElement('div');
idDiv.className = 'col-md-3 mb-2';
idDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Grid HTML ID</label>';
const idInput = document.createElement('input');
idInput.type = 'text';
idInput.className = 'form-control form-control-sm';
idInput.placeholder = 'e.g. grid-section-1';
idInput.placeholder = 'e.g. grid-1';
idInput.value = this.data.globalId || '';
if (this.readOnly) idInput.disabled = true;
idInput.addEventListener('input', (e) => this.data.globalId = e.target.value.trim());
@@ -103,14 +169,14 @@ class GridTool {
idDiv.appendChild(idInput);
settingsRow.appendChild(idDiv);
// 4. Global Custom Attributes
// 6. Global Custom Attributes
const attrDiv = document.createElement('div');
attrDiv.className = 'col-md-4 mb-2';
attrDiv.className = 'col-md-12 mb-2';
attrDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1"><i class="fas fa-sliders-h"></i> Custom Attributes</label>';
const attrInput = document.createElement('input');
attrInput.type = 'text';
attrInput.className = 'form-control form-control-sm';
attrInput.placeholder = 'e.g. data-aos="fade-up" style="background:#fff"';
attrInput.placeholder = 'e.g. data-aos="fade-up"';
attrInput.value = this.data.globalAttributes || '';
if (this.readOnly) attrInput.disabled = true;
attrInput.addEventListener('input', (e) => this.data.globalAttributes = e.target.value.trim());
@@ -133,7 +199,7 @@ class GridTool {
this.inputsContainer.innerHTML = '';
const row = document.createElement('div');
row.className = 'row';
row.className = 'row d-flex flex-wrap';
const count = this.data.cols;
const colWidthClass = count === 1 ? 'col-12' : (count === 2 ? 'col-md-6' : (count === 3 ? 'col-md-4' : (count === 4 ? 'col-md-3' : 'col-md')));
@@ -233,15 +299,104 @@ class GridTool {
headerRow.appendChild(controlsWrapper);
colDiv.appendChild(headerRow);
colDiv.appendChild(contentDiv);
// --- Sub-Plugin (Sub-Content) Section ---
const subWrapper = document.createElement('div');
if (this.data[`subType${i}`] !== 'none') {
subWrapper.className = 'mt-3 pt-2 border-top';
}
const subHeader = document.createElement('div');
subHeader.className = 'd-flex justify-content-between align-items-center mb-2';
const subLabel = document.createElement('label');
subLabel.className = 'small font-weight-bold text-muted mb-0';
subLabel.style.fontSize = '10px';
subLabel.innerText = 'SUB-CONTENT / INJECT PLUGIN';
subHeader.appendChild(subLabel);
const subTypeSelect = document.createElement('select');
subTypeSelect.className = 'custom-select custom-select-sm';
subTypeSelect.style.width = '130px';
subTypeSelect.style.fontSize = '10px';
if (this.readOnly) subTypeSelect.disabled = true;
const subTypes = [
{ value: 'none', label: 'None' },
{ value: 'caption', label: 'Text Caption' },
{ value: 'html', label: 'HTML/Text' },
{ value: 'image', label: 'Image' },
{ value: 'youtube', label: 'YouTube' },
{ value: 'accordion', label: 'Accordion' }
];
if (window.SISEditorPlugins) {
Object.keys(window.SISEditorPlugins).forEach(key => {
if (!subTypes.some(t => t.value === key)) {
subTypes.push({ value: key, label: `[Plugin] ${key}` });
}
});
}
subTypes.forEach(t => {
const opt = document.createElement('option');
opt.value = t.value;
opt.innerText = t.label;
opt.selected = this.data[`subType${i}`] === t.value;
subTypeSelect.appendChild(opt);
});
subHeader.appendChild(subTypeSelect);
subWrapper.appendChild(subHeader);
const subContentDiv = document.createElement('div');
subWrapper.appendChild(subContentDiv);
colDiv.appendChild(subWrapper);
const renderSubInputs = () => {
subContentDiv.innerHTML = '';
const subType = this.data[`subType${i}`];
if (subType === 'none') {
subWrapper.className = '';
} else {
subWrapper.className = 'mt-3 pt-2 border-top';
}
if (subType === 'none') {
// Empty
} else if (subType === 'caption') {
const captionGroup = document.createElement('div');
captionGroup.className = 'form-group mb-2';
const captionInput = document.createElement('input');
captionInput.type = 'text';
captionInput.className = 'form-control form-control-sm';
captionInput.style.fontSize = '11px';
captionInput.placeholder = 'Optional column caption / text...';
captionInput.value = this.data[`caption${i}`] || '';
if (this.readOnly) captionInput.disabled = true;
captionInput.addEventListener('input', (e) => {
this.data[`caption${i}`] = e.target.value;
});
captionGroup.appendChild(captionInput);
subContentDiv.appendChild(captionGroup);
} else {
this._renderTypeSpecificInput(`sub_${i}`, subContentDiv, subType);
}
};
subTypeSelect.addEventListener('change', (e) => {
this.data[`subType${i}`] = e.target.value;
renderSubInputs();
});
row.appendChild(colDiv);
this._renderTypeSpecificInput(i, contentDiv);
renderSubInputs();
}
this.inputsContainer.appendChild(row);
}
_renderTypeSpecificInput(i, container) {
_renderTypeSpecificInput(i, container, explicitType) {
container.innerHTML = '';
// Remove tracking of previous instance
@@ -253,7 +408,7 @@ class GridTool {
typeContainer.className = 'mb-3';
container.appendChild(typeContainer);
const type = this.data[`type${i}`];
const type = explicitType || this.data[`type${i}`] || 'html';
// Check if type is a dynamic editor plugin registered in window.SISEditorPlugins
if (window.SISEditorPlugins && window.SISEditorPlugins[type]) {
@@ -307,11 +462,13 @@ class GridTool {
controls.style.gap = '8px';
controls.innerHTML = `
<div class="text-muted small text-center mb-1"><i class="fas fa-cog"></i> Image Controls</div>
<button type="button" class="btn btn-outline-primary btn-sm w-100" id="btn-upload-img-${i}"><i class="fas fa-upload"></i> Upload Image</button>
<button type="button" class="btn btn-outline-info btn-sm w-100" id="btn-fetch-url-${i}"><i class="fas fa-link"></i> Fetch URL</button>
<button type="button" class="btn btn-outline-secondary btn-sm w-100" id="btn-media-lib-${i}"><i class="fas fa-images"></i> Media Library</button>
<button type="button" class="btn btn-outline-danger btn-sm w-100 mt-2" id="btn-clear-img-${i}"><i class="fas fa-trash"></i> Remove Image</button>
<div class="d-flex justify-content-between align-items-center mb-1">
<span class="text-muted small font-weight-bold"><i class="fas fa-image"></i> Image Settings</span>
<button type="button" class="btn btn-link btn-xs text-danger p-0" id="btn-clear-img-${i}" title="Remove Image"><i class="fas fa-trash-alt"></i></button>
</div>
<button type="button" class="btn btn-primary btn-sm w-100 shadow-sm" id="btn-media-lib-${i}">
<i class="fas fa-sliders-h mr-1"></i> Quick Settings / Select Image
</button>
`;
const uploadBtn = controls.querySelector(`#btn-upload-img-${i}`);
@@ -346,16 +503,23 @@ class GridTool {
mediaBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
if (typeof openMediaPickerModal === 'function') {
openMediaPickerModal((url) => {
if (window.SISMediaPicker) {
SISMediaPicker.open((url, mediaObj, config) => {
if (url) {
this.data[`col${i}`] = {
file: { url: url },
caption: '',
caption: (config && config.alt) || '',
withBorder: false,
withBackground: false,
stretched: false
};
if (config) {
this.data[`imgStyle${i}`] = config.style || '';
this.data[`imgClass${i}`] = config.cssClass || '';
this.data[`imgAlt${i}`] = config.alt || '';
this.data[`imgAttrs${i}`] = config.customAttributes || '';
this.data[`imgAspectRatio${i}`] = config.aspectRatio || '';
}
this._renderTypeSpecificInput(i, container);
}
});
@@ -440,10 +604,17 @@ class GridTool {
chooseBtn.innerText = 'Choose';
if (this.readOnly) chooseBtn.disabled = true;
chooseBtn.addEventListener('click', () => {
if (typeof openMediaPickerModal === 'function') {
openMediaPickerModal((selectedUrl) => {
if (window.SISMediaPicker) {
SISMediaPicker.open((selectedUrl, mediaObj, config) => {
input.value = selectedUrl;
this.data[`imgUrl${i}`] = selectedUrl;
if (config) {
this.data[`imgStyle${i}`] = config.style || '';
this.data[`imgClass${i}`] = config.cssClass || '';
this.data[`imgAlt${i}`] = config.alt || '';
this.data[`imgAttrs${i}`] = config.customAttributes || '';
this.data[`imgAspectRatio${i}`] = config.aspectRatio || '';
}
previewImg.src = selectedUrl;
previewBox.style.display = 'block';
});
@@ -577,6 +748,8 @@ class GridTool {
typeContainer.appendChild(contentTextarea);
}
// Advanced Settings (ID and Class)
const advancedHeader = document.createElement('div');
advancedHeader.className = 'font-weight-bold text-muted small mb-2';
@@ -623,6 +796,8 @@ class GridTool {
save(blockContent) {
const savedData = {
cols: this.data.cols,
itemAlignment: this.data.itemAlignment || 'left',
globalStyle: this.globalStyleInput ? this.globalStyleInput.value.trim() : (this.data.globalStyle || ''),
globalClass: this.globalClassInput ? this.globalClassInput.value.trim() : (this.data.globalClass || ''),
globalId: this.globalIdInput ? this.globalIdInput.value.trim() : (this.data.globalId || ''),
globalAttributes: this.globalAttrInput ? this.globalAttrInput.value.trim() : (this.data.globalAttributes || '')
@@ -652,6 +827,31 @@ class GridTool {
savedData[`id${i}`] = this.data[`id${i}`] || '';
savedData[`attr${i}`] = this.data[`attr${i}`] || '';
savedData[`width${i}`] = this.data[`width${i}`] || 0;
savedData[`caption${i}`] = this.data[`caption${i}`] || '';
// Sub-plugin serialization
const subType = this.data[`subType${i}`] || 'none';
savedData[`subType${i}`] = subType;
if (this.activeInstances[`sub_${i}`]) {
try {
const subPluginData = this.activeInstances[`sub_${i}`].save();
savedData[`colsub_${i}`] = subPluginData;
} catch (e) {
console.error(`Failed to save sub-plugin ${subType} inside GridTool:`, e);
savedData[`colsub_${i}`] = this.data[`colsub_${i}`];
}
} else {
savedData[`colsub_${i}`] = this.data[`colsub_${i}`] || '';
}
savedData[`imgUrlsub_${i}`] = this.data[`imgUrlsub_${i}`] || '';
savedData[`ytUrlsub_${i}`] = this.data[`ytUrlsub_${i}`] || '';
savedData[`accTitlesub_${i}`] = this.data[`accTitlesub_${i}`] || '';
savedData[`accContentsub_${i}`] = this.data[`accContentsub_${i}`] || '';
savedData[`classsub_${i}`] = this.data[`classsub_${i}`] || '';
savedData[`idsub_${i}`] = this.data[`idsub_${i}`] || '';
savedData[`attrsub_${i}`] = this.data[`attrsub_${i}`] || '';
}
return savedData;
}
@@ -0,0 +1,246 @@
/**
* SISHeroBannerTool — Hero Banner Plugin for Editor.js
* Allows content managers to build dynamic hero banners with live preview.
*/
class SISHeroBannerTool {
static get toolbox() {
return {
title: 'Hero Banner',
icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg>'
};
}
constructor({ data, api, readOnly }) {
this.api = api;
this.readOnly = readOnly;
this.data = {
title: (data && data.title) ? data.title : '',
subtitle: (data && data.subtitle) ? data.subtitle : '',
bgImage: (data && data.bgImage) ? data.bgImage : '',
btnText: (data && data.btnText) ? data.btnText : '',
btnLink: (data && data.btnLink) ? data.btnLink : '',
height: (data && data.height) ? data.height : '350px',
textAlign: (data && data.textAlign) ? data.textAlign : 'center',
overlayOpacity: (data && data.overlayOpacity !== undefined) ? data.overlayOpacity : '0.4',
stretched: (data && data.stretched !== undefined) ? !!data.stretched : true
};
}
render() {
const container = document.createElement('div');
container.style.border = '1px solid #e3e6f0';
container.style.borderRadius = '8px';
container.style.padding = '14px';
container.style.background = '#fff';
container.style.marginBottom = '12px';
const headerLabel = document.createElement('label');
headerLabel.className = 'font-weight-bold text-primary small mb-2 d-block';
headerLabel.innerHTML = '<i class="fas fa-image"></i> Hero Banner Block Settings';
container.appendChild(headerLabel);
// Stretch toggle
const stretchWrapper = document.createElement('div');
stretchWrapper.className = 'custom-control custom-switch mb-3';
const stretchCheck = document.createElement('input');
stretchCheck.type = 'checkbox';
stretchCheck.className = 'custom-control-input';
stretchCheck.id = 'hero_stretch_' + Math.random().toString(36).substring(7);
stretchCheck.checked = !!this.data.stretched;
const stretchLabel = document.createElement('label');
stretchLabel.className = 'custom-control-label small font-weight-bold text-secondary';
stretchLabel.htmlFor = stretchCheck.id;
stretchLabel.innerHTML = '<i class="fas fa-arrows-alt-h"></i> Stretch Banner to Full Screen Width';
stretchCheck.addEventListener('change', () => {
this.data.stretched = stretchCheck.checked;
});
stretchWrapper.appendChild(stretchCheck);
stretchWrapper.appendChild(stretchLabel);
container.appendChild(stretchWrapper);
// Inputs
this.titleInput = this._createInput('Banner Title', 'e.g. Bệnh Viện Đa Khoa Quốc Tế S.I.S Cần Thơ', this.data.title);
this.subtitleInput = this._createInput('Subtitle / Description', 'e.g. Trao niềm tin - Nhận sức khỏe...', this.data.subtitle);
this.bgImageInput = this._createImageInput('Background Image URL', 'https://example.com/hero-banner.jpg', this.data.bgImage);
// Row for Height & Button Link
const configRow = document.createElement('div');
configRow.className = 'form-row';
const heightCol = document.createElement('div');
heightCol.className = 'col-md-4 mb-2';
this.heightInput = this._createInput('Banner Height (e.g. 350px, 500px, 60vh)', 'e.g. 350px', this.data.height);
heightCol.appendChild(this.heightInput);
const btnCol1 = document.createElement('div');
btnCol1.className = 'col-md-4 mb-2';
this.btnTextInput = this._createInput('Button Text (Optional)', 'e.g. Đặt Lịch Khám', this.data.btnText);
btnCol1.appendChild(this.btnTextInput);
const btnCol2 = document.createElement('div');
btnCol2.className = 'col-md-4 mb-2';
this.btnLinkInput = this._createInput('Button Link URL (Optional)', 'e.g. /dat-lich', this.data.btnLink);
btnCol2.appendChild(this.btnLinkInput);
configRow.appendChild(heightCol);
configRow.appendChild(btnCol1);
configRow.appendChild(btnCol2);
// Live Preview Box
const previewBox = document.createElement('div');
previewBox.className = 'hero-banner-preview p-4 rounded text-white my-2';
previewBox.style.position = 'relative';
previewBox.style.minHeight = this.data.height || '350px';
previewBox.style.display = 'flex';
previewBox.style.flexDirection = 'column';
previewBox.style.justifyContent = 'center';
previewBox.style.alignItems = 'center';
previewBox.style.textAlign = 'center';
previewBox.style.backgroundSize = 'cover';
previewBox.style.backgroundPosition = 'center';
previewBox.style.overflow = 'hidden';
const overlay = document.createElement('div');
overlay.style.position = 'absolute';
overlay.style.top = '0';
overlay.style.left = '0';
overlay.style.right = '0';
overlay.style.bottom = '0';
overlay.style.background = '#000';
overlay.style.zIndex = '1';
previewBox.appendChild(overlay);
const contentBox = document.createElement('div');
contentBox.style.position = 'relative';
contentBox.style.zIndex = '2';
previewBox.appendChild(contentBox);
const updatePreview = () => {
const bg = this.bgImageInput.querySelector('input').value.trim();
const title = this.titleInput.querySelector('input').value.trim() || 'Hero Banner Title';
const sub = this.subtitleInput.querySelector('input').value.trim();
const btnT = this.btnTextInput.querySelector('input').value.trim();
const bannerH = this.heightInput.querySelector('input').value.trim() || '350px';
previewBox.style.minHeight = bannerH;
previewBox.style.backgroundImage = bg ? 'url("' + bg + '")' : 'linear-gradient(135deg, #002554, #881C1C)';
overlay.style.opacity = this.data.overlayOpacity || '0.4';
let html = '<h4 class="font-weight-bold mb-1 text-white">' + title + '</h4>';
if (sub) html += '<p class="small mb-2 text-light">' + sub + '</p>';
if (btnT) html += '<span class="btn btn-sm btn-danger font-weight-bold px-3">' + btnT + '</span>';
contentBox.innerHTML = html;
};
[this.titleInput, this.subtitleInput, this.bgImageInput].forEach(wrapper => {
const input = wrapper.querySelector('input');
input.addEventListener('input', updatePreview);
if (this.readOnly) input.disabled = true;
container.appendChild(wrapper);
});
[this.heightInput, this.btnTextInput, this.btnLinkInput].forEach(wrapper => {
const input = wrapper.querySelector('input');
input.addEventListener('input', updatePreview);
if (this.readOnly) input.disabled = true;
});
container.appendChild(configRow);
container.appendChild(previewBox);
updatePreview();
return container;
}
_createInput(labelText, placeholder, value) {
const wrapper = document.createElement('div');
wrapper.className = 'form-group mb-2';
const lbl = document.createElement('label');
lbl.className = 'small font-weight-bold text-secondary mb-1 d-block';
lbl.innerText = labelText;
const inp = document.createElement('input');
inp.type = 'text';
inp.className = 'form-control form-control-sm';
inp.placeholder = placeholder;
inp.value = value || '';
wrapper.appendChild(lbl);
wrapper.appendChild(inp);
return wrapper;
}
_createImageInput(labelText, placeholder, value) {
const wrapper = document.createElement('div');
wrapper.className = 'form-group mb-2';
const lbl = document.createElement('label');
lbl.className = 'small font-weight-bold text-secondary mb-1 d-block';
lbl.innerText = labelText;
wrapper.appendChild(lbl);
const inputGroup = document.createElement('div');
inputGroup.className = 'input-group input-group-sm';
const inp = document.createElement('input');
inp.type = 'text';
inp.className = 'form-control';
inp.placeholder = placeholder;
inp.value = value || '';
if (this.readOnly) inp.disabled = true;
inputGroup.appendChild(inp);
if (!this.readOnly) {
const appendDiv = document.createElement('div');
appendDiv.className = 'input-group-append';
const mediaBtn = document.createElement('button');
mediaBtn.type = 'button';
mediaBtn.className = 'btn btn-outline-info';
mediaBtn.innerHTML = '<i class="fas fa-images"></i> Media Library';
mediaBtn.addEventListener('click', () => {
if (window.SISMediaPicker) {
SISMediaPicker.open((selectedUrl, mediaObj, config) => {
inp.value = selectedUrl;
if (config) {
this._bgImageConfig = config;
}
// Dispatch input event to trigger preview update
inp.dispatchEvent(new Event('input', { bubbles: true }));
});
} else {
alert('Media modal function openSISMediaModal is not available.');
}
});
appendDiv.appendChild(mediaBtn);
inputGroup.appendChild(appendDiv);
}
wrapper.appendChild(inputGroup);
return wrapper;
}
save(blockContent) {
const stretchCheck = blockContent.querySelector('.custom-control-input');
return {
title: this.titleInput ? this.titleInput.querySelector('input').value : this.data.title,
subtitle: this.subtitleInput ? this.subtitleInput.querySelector('input').value : this.data.subtitle,
bgImage: this.bgImageInput ? this.bgImageInput.querySelector('input').value : this.data.bgImage,
bgImageStyle: (this._bgImageConfig && this._bgImageConfig.style) || this.data.bgImageStyle || '',
bgImageClass: (this._bgImageConfig && this._bgImageConfig.cssClass) || this.data.bgImageClass || '',
bgImageAlt: (this._bgImageConfig && this._bgImageConfig.alt) || this.data.bgImageAlt || '',
bgImageAttrs: (this._bgImageConfig && this._bgImageConfig.customAttributes) || this.data.bgImageAttrs || '',
bgImageAspectRatio: (this._bgImageConfig && this._bgImageConfig.aspectRatio) || this.data.bgImageAspectRatio || '',
btnText: this.btnTextInput ? this.btnTextInput.querySelector('input').value : this.data.btnText,
btnLink: this.btnLinkInput ? this.btnLinkInput.querySelector('input').value : this.data.btnLink,
height: this.heightInput ? this.heightInput.querySelector('input').value : this.data.height,
textAlign: this.data.textAlign || 'center',
overlayOpacity: this.data.overlayOpacity || '0.4',
stretched: stretchCheck ? stretchCheck.checked : !!this.data.stretched
};
}
}
// Register plugin globally
window.SISEditorPlugins = window.SISEditorPlugins || {};
window.SISEditorPlugins['hero'] = { class: SISHeroBannerTool };
@@ -315,10 +315,17 @@ class SISHoverCardTool {
mediaBtn.className = 'btn btn-outline-info';
mediaBtn.innerHTML = '<i class="fas fa-images"></i> Media Library';
mediaBtn.addEventListener('click', () => {
if (typeof window.openSISMediaModal === 'function') {
window.openSISMediaModal((selectedUrl) => {
if (window.SISMediaPicker) {
SISMediaPicker.open((selectedUrl, mediaObj, config) => {
imgInput.value = selectedUrl;
item.imageUrl = selectedUrl;
if (config) {
item.imageStyle = config.style || '';
item.imageClass = config.cssClass || '';
item.imageAlt = config.alt || '';
item.imageAttrs = config.customAttributes || '';
item.imageAspectRatio = config.aspectRatio || '';
}
updatePreview(selectedUrl);
});
} else {
@@ -380,10 +387,17 @@ class SISHoverCardTool {
mediaBtn.className = 'btn btn-outline-info';
mediaBtn.innerHTML = '<i class="fas fa-images"></i> Media Library';
mediaBtn.addEventListener('click', () => {
if (typeof window.openSISMediaModal === 'function') {
window.openSISMediaModal((selectedUrl) => {
if (window.SISMediaPicker) {
SISMediaPicker.open((selectedUrl, mediaObj, config) => {
hoverImgInput.value = selectedUrl;
item.hoverImageUrl = selectedUrl;
if (config) {
item.hoverImageStyle = config.style || '';
item.hoverImageClass = config.cssClass || '';
item.hoverImageAlt = config.alt || '';
item.hoverImageAttrs = config.customAttributes || '';
item.hoverImageAspectRatio = config.aspectRatio || '';
}
updateHoverPreview(selectedUrl);
});
} else {
@@ -0,0 +1,182 @@
/**
* TextStylingTune — Block Tune for Editor.js
* Adds alignment, custom CSS class, custom CSS style, and HTML ID settings to blocks.
*/
class TextStylingTune {
constructor({ api, block, data }) {
this.api = api;
this.block = block;
this.data = data || {
alignment: '',
cssClass: '',
customStyle: '',
elementId: '',
customAttrs: ''
};
}
static get isTune() {
return true;
}
render() {
const wrapper = document.createElement('div');
wrapper.className = 'cdx-settings-text-styling p-2 border-top';
wrapper.style.fontFamily = 'inherit';
const title = document.createElement('div');
title.className = 'small font-weight-bold text-primary mb-2';
title.innerHTML = '<i class="fas fa-palette"></i> Style & Alignment';
wrapper.appendChild(title);
// Alignment row
const alignWrapper = document.createElement('div');
alignWrapper.className = 'd-flex mb-2 align-items-center justify-content-start';
const alignments = [
{ name: 'left', icon: '<i class="fas fa-align-left"></i>', title: 'Align Left' },
{ name: 'center', icon: '<i class="fas fa-align-center"></i>', title: 'Align Center' },
{ name: 'right', icon: '<i class="fas fa-align-right"></i>', title: 'Align Right' },
{ name: 'justify', icon: '<i class="fas fa-align-justify"></i>', title: 'Align Justify' }
];
alignments.forEach(align => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-xs btn-light mr-1 border';
btn.style.width = '30px';
btn.style.height = '30px';
btn.style.padding = '0';
btn.title = align.title;
if (this.data.alignment === align.name) {
btn.className = 'btn btn-xs btn-info mr-1 border';
}
btn.innerHTML = align.icon;
btn.addEventListener('click', () => {
this.data.alignment = this.data.alignment === align.name ? '' : align.name;
alignWrapper.querySelectorAll('button').forEach((b, idx) => {
b.className = (this.data.alignment === alignments[idx].name) ? 'btn btn-xs btn-info mr-1 border' : 'btn btn-xs btn-light mr-1 border';
});
this.applyTuneToBlock();
});
alignWrapper.appendChild(btn);
});
wrapper.appendChild(alignWrapper);
// CSS Class Group
const classGroup = document.createElement('div');
classGroup.className = 'form-group mb-1';
const classLabel = document.createElement('label');
classLabel.className = 'small font-weight-bold text-secondary mb-0 d-block';
classLabel.innerText = 'CSS Class';
const classInput = document.createElement('input');
classInput.type = 'text';
classInput.className = 'form-control form-control-sm py-0';
classInput.style.height = '24px';
classInput.placeholder = 'e.g. lead text-primary font-weight-bold';
classInput.value = this.data.cssClass || '';
classInput.addEventListener('input', (e) => {
this.data.cssClass = e.target.value.trim();
this.applyTuneToBlock();
});
classGroup.appendChild(classLabel);
classGroup.appendChild(classInput);
wrapper.appendChild(classGroup);
// Custom CSS Style Group
const styleGroup = document.createElement('div');
styleGroup.className = 'form-group mb-1';
const styleLabel = document.createElement('label');
styleLabel.className = 'small font-weight-bold text-secondary mb-0 d-block';
styleLabel.innerText = 'CSS Style';
const styleInput = document.createElement('input');
styleInput.type = 'text';
styleInput.className = 'form-control form-control-sm py-0';
styleInput.style.height = '24px';
styleInput.placeholder = 'e.g. color: #dc3545; font-size: 1.5rem;';
styleInput.value = this.data.customStyle || '';
styleInput.addEventListener('input', (e) => {
this.data.customStyle = e.target.value.trim();
this.applyTuneToBlock();
});
styleGroup.appendChild(styleLabel);
styleGroup.appendChild(styleInput);
wrapper.appendChild(styleGroup);
// HTML ID Group
const idGroup = document.createElement('div');
idGroup.className = 'form-group mb-1';
const idLabel = document.createElement('label');
idLabel.className = 'small font-weight-bold text-secondary mb-0 d-block';
idLabel.innerText = 'Element ID';
const idInput = document.createElement('input');
idInput.type = 'text';
idInput.className = 'form-control form-control-sm py-0';
idInput.style.height = '24px';
idInput.placeholder = 'e.g. section-main-title';
idInput.value = this.data.elementId || '';
idInput.addEventListener('input', (e) => {
this.data.elementId = e.target.value.trim();
this.applyTuneToBlock();
});
idGroup.appendChild(idLabel);
idGroup.appendChild(idInput);
wrapper.appendChild(idGroup);
// Custom HTML Attributes Group
const attrsGroup = document.createElement('div');
attrsGroup.className = 'form-group mb-0';
const attrsLabel = document.createElement('label');
attrsLabel.className = 'small font-weight-bold text-secondary mb-0 d-block';
attrsLabel.innerText = 'Custom Attributes';
const attrsInput = document.createElement('input');
attrsInput.type = 'text';
attrsInput.className = 'form-control form-control-sm py-0';
attrsInput.style.height = '24px';
attrsInput.placeholder = 'e.g. data-aos="fade-up" target="_blank"';
attrsInput.value = this.data.customAttrs || '';
attrsInput.addEventListener('input', (e) => {
this.data.customAttrs = e.target.value.trim();
});
attrsGroup.appendChild(attrsLabel);
attrsGroup.appendChild(attrsInput);
wrapper.appendChild(attrsGroup);
return wrapper;
}
applyTuneToBlock() {
if (!this.block || !this.block.holder) return;
const blockContent = this.block.holder.querySelector('.ce-block__content');
if (blockContent) {
// Live alignment preview
blockContent.style.textAlign = this.data.alignment || '';
// Live styling preview
const currentElement = blockContent.firstElementChild;
if (currentElement) {
// Apply custom inline styles on top of defaults
currentElement.style.cssText = this.data.customStyle || '';
}
}
}
save() {
return this.data;
}
wrap(blockContent) {
// Applies styling during initial render / loading
if (this.data.alignment) {
blockContent.style.textAlign = this.data.alignment;
}
const currentElement = blockContent.firstElementChild;
if (currentElement && this.data.customStyle) {
currentElement.style.cssText = this.data.customStyle;
}
return blockContent;
}
}
// Register globally
window.SISEditorPlugins = window.SISEditorPlugins || {};
window.SISEditorPlugins['textStyling'] = { class: TextStylingTune };
@@ -122,10 +122,47 @@ class SISTimelineTool {
fileInput.style.display = 'none';
chooseBtn.addEventListener('click', () => {
openMediaPickerModal((url) => {
imageInput.value = url;
updatePreview();
});
if (window.SISMediaPicker) {
SISMediaPicker.open((url, mediaObj, config) => {
imageInput.value = url;
// Store config in hidden inputs on the item box
let styleInp = itemBox.querySelector('.ce-timeline-input-imgstyle');
if (!styleInp) {
styleInp = document.createElement('input'); styleInp.type = 'hidden';
styleInp.className = 'ce-timeline-input-imgstyle'; itemBox.appendChild(styleInp);
}
let classInp = itemBox.querySelector('.ce-timeline-input-imgclass');
if (!classInp) {
classInp = document.createElement('input'); classInp.type = 'hidden';
classInp.className = 'ce-timeline-input-imgclass'; itemBox.appendChild(classInp);
}
let altInp = itemBox.querySelector('.ce-timeline-input-imgalt');
if (!altInp) {
altInp = document.createElement('input'); altInp.type = 'hidden';
altInp.className = 'ce-timeline-input-imgalt'; itemBox.appendChild(altInp);
}
let attrsInp = itemBox.querySelector('.ce-timeline-input-imgattrs');
if (!attrsInp) {
attrsInp = document.createElement('input'); attrsInp.type = 'hidden';
attrsInp.className = 'ce-timeline-input-imgattrs'; itemBox.appendChild(attrsInp);
}
let arInp = itemBox.querySelector('.ce-timeline-input-imgar');
if (!arInp) {
arInp = document.createElement('input'); arInp.type = 'hidden';
arInp.className = 'ce-timeline-input-imgar'; itemBox.appendChild(arInp);
}
if (config) {
styleInp.value = config.style || '';
classInp.value = config.cssClass || '';
altInp.value = config.alt || '';
attrsInp.value = config.customAttributes || '';
arInp.value = config.aspectRatio || '';
}
updatePreview();
});
} else {
alert('SISMediaPicker is not loaded.');
}
});
uploadBtn.addEventListener('click', () => {
@@ -263,8 +300,17 @@ class SISTimelineTool {
const img = box.querySelector('.ce-timeline-input-image').value.trim();
const attrEl = box.querySelector('.ce-timeline-input-attr');
const attr = attrEl ? attrEl.value.trim() : '';
const imgStyle = (box.querySelector('.ce-timeline-input-imgstyle') || {}).value || '';
const imgClass = (box.querySelector('.ce-timeline-input-imgclass') || {}).value || '';
const imgAlt = (box.querySelector('.ce-timeline-input-imgalt') || {}).value || '';
const imgAttrs = (box.querySelector('.ce-timeline-input-imgattrs') || {}).value || '';
const imgAspectRatio = (box.querySelector('.ce-timeline-input-imgar') || {}).value || '';
if (date || desc || img || attr) {
items.push({ date: date, description: desc, imageUrl: img, attributes: attr });
items.push({
date: date, description: desc, imageUrl: img, attributes: attr,
imageStyle: imgStyle, imageClass: imgClass, imageAlt: imgAlt,
imageAttrs: imgAttrs, imageAspectRatio: imgAspectRatio
});
}
});
const globalAttrEl = this.wrapper.querySelector('.ce-timeline-input-global-attr');
@@ -281,111 +327,12 @@ window.SISEditorPlugins['timeline'] = {
class: SISTimelineTool
};
// Global Media Picker Modal logic
// openMediaPickerModal — legacy stub, replaced by SISMediaPicker
function openMediaPickerModal(onSelectCallback) {
let modalEl = document.getElementById('globalMediaPickerModal');
if (!modalEl) {
// Create modal HTML
const modalHtml = `
<div class="modal fade" id="globalMediaPickerModal" tabindex="-1" role="dialog" aria-hidden="true" style="z-index: 10000;">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fas fa-photo-video"></i> Select Media from Library</h5>
<button class="close" type="button" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="modal-body">
<div id="mediaPickerLoading" class="text-center py-4">
<i class="fas fa-spinner fa-spin fa-2x"></i>
<p class="mt-2">Loading media...</p>
</div>
<div id="mediaPickerGrid" class="row" style="display:none; max-height: 400px; overflow-y: auto;">
<!-- media items -->
</div>
</div>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
modalEl = document.getElementById('globalMediaPickerModal');
// Add styles
const style = document.createElement('style');
style.innerHTML = `
.media-picker-item { cursor: pointer; border: 2px solid transparent; border-radius: 4px; overflow: hidden; margin-bottom: 15px; position: relative; }
.media-picker-item:hover { border-color: #4e73df; }
.media-picker-item img { width: 100%; height: 120px; object-fit: cover; }
.media-picker-item .media-name { font-size: 11px; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; padding: 4px; text-align: center; background: #f8f9fc; }
`;
document.head.appendChild(style);
}
// Store callback
window._mediaPickerCallback = onSelectCallback;
// Show modal using jQuery/Bootstrap
if (window.jQuery) {
$(modalEl).modal('show');
if (window.SISMediaPicker) {
SISMediaPicker.open(function(url) { onSelectCallback(url); });
} else {
alert("Bootstrap/jQuery not loaded!");
return;
var url = prompt('Enter image URL:');
if (url) onSelectCallback(url);
}
// Fetch media
const loading = document.getElementById('mediaPickerLoading');
const grid = document.getElementById('mediaPickerGrid');
loading.style.display = 'block';
grid.style.display = 'none';
fetch('/api/manage/media/list')
.then(res => res.json())
.then(data => {
loading.style.display = 'none';
grid.style.display = 'flex';
grid.innerHTML = '';
if (data.length === 0) {
grid.innerHTML = '<div class="col-12 text-center text-muted py-4">No media found.</div>';
return;
}
data.forEach(item => {
const col = document.createElement('div');
col.className = 'col-md-3 col-sm-4 col-6';
const itemDiv = document.createElement('div');
itemDiv.className = 'media-picker-item shadow-sm';
const img = document.createElement('img');
// Use a default image if not an image type or missing URL
img.src = item.fileUrl ? item.fileUrl : 'https://via.placeholder.com/150';
const name = document.createElement('div');
name.className = 'media-name';
name.title = item.originalFilename || 'Unknown';
name.textContent = item.originalFilename || 'Unknown';
itemDiv.appendChild(img);
itemDiv.appendChild(name);
itemDiv.addEventListener('click', () => {
if (window._mediaPickerCallback) {
window._mediaPickerCallback(item.fileUrl);
}
$(modalEl).modal('hide');
});
col.appendChild(itemDiv);
grid.appendChild(col);
});
})
.catch(err => {
console.error('Error fetching media:', err);
loading.style.display = 'none';
grid.style.display = 'block';
grid.innerHTML = '<div class="col-12 text-center text-danger py-4">Failed to load media list.</div>';
});
}
@@ -0,0 +1,138 @@
(function() {
'use strict';
class SISTinyMceTool {
static get toolbox() {
return {
title: 'TinyMCE Editor',
icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16v16H4z"></path><path d="M8 8h8"></path><path d="M12 8v8"></path><path d="M10 16h4"></path></svg>'
};
}
constructor({ data, api, readOnly }) {
this.api = api;
this.readOnly = readOnly;
this.data = {
html: data.html || ''
};
this.editorId = 'tinymce-editor-' + Math.random().toString(36).substring(7);
this.editorInstance = null;
}
render() {
const wrapper = document.createElement('div');
wrapper.className = 'sis-tinymce-block-wrapper border rounded p-2 bg-white';
wrapper.style.minHeight = '280px';
const textarea = document.createElement('textarea');
textarea.id = this.editorId;
textarea.value = this.data.html;
textarea.style.width = '100%';
textarea.style.minHeight = '250px';
wrapper.appendChild(textarea);
this.textareaElement = textarea;
this._initTinyMCE();
return wrapper;
}
_initTinyMCE() {
const self = this;
if (typeof tinymce !== 'undefined') {
self._createEditor();
return;
}
// Global loading queue to prevent duplicate script tags
window.SISTinyMceLoading = window.SISTinyMceLoading || {
loaded: false,
callbacks: []
};
if (window.SISTinyMceLoading.loaded) {
self._createEditor();
return;
}
window.SISTinyMceLoading.callbacks.push(() => {
self._createEditor();
});
if (window.SISTinyMceLoading.callbacks.length === 1) {
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/tinymce@6/tinymce.min.js';
script.referrerPolicy = 'origin';
script.onload = () => {
window.SISTinyMceLoading.loaded = true;
while (window.SISTinyMceLoading.callbacks.length > 0) {
const cb = window.SISTinyMceLoading.callbacks.shift();
try { cb(); } catch (e) { console.error(e); }
}
};
document.head.appendChild(script);
}
}
_createEditor() {
const self = this;
const textarea = this.textareaElement || document.getElementById(this.editorId);
if (!textarea) return;
// Wait until the element is actually attached to the document DOM
const checkAndInit = () => {
if (!document.body.contains(textarea)) {
requestAnimationFrame(checkAndInit);
return;
}
tinymce.init({
target: textarea,
height: 250,
menubar: false,
readonly: !!self.readOnly,
plugins: 'advlist autolink lists link image charmap preview anchor searchreplace visualblocks code fullscreen insertdatetime media table code help wordcount',
toolbar: 'undo redo | blocks | bold italic forecolor backcolor | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | table link image | code removeformat',
setup: function(editor) {
self.editorInstance = editor;
editor.on('change keyup undo redo', function() {
self.data.html = editor.getContent();
});
}
});
};
checkAndInit();
}
save(blockContent) {
if (this.editorInstance) {
this.data.html = this.editorInstance.getContent();
}
return {
html: this.data.html
};
}
destroy() {
if (this.editorInstance) {
try {
tinymce.remove(this.editorInstance);
} catch (e) {
console.warn('[SIS TinyMCE Tool] Cleanup error:', e);
}
this.editorInstance = null;
}
if (this.textareaElement) {
this.textareaElement.remove();
this.textareaElement = null;
}
}
}
// Register globally
window.SISEditorPlugins = window.SISEditorPlugins || {};
window.SISEditorPlugins['tinymce'] = {
class: SISTinyMceTool
};
})();
@@ -1,36 +1,71 @@
/**
* SIS Media Picker & Library Modal Utility
* Standalone reusable module to select and upload media files anywhere in the application.
*
*
* Features:
* - Server-side paginated media list (default 24 per page)
* - Infinite-scroll lazy loading inside the grid (loads next page when scrolled near bottom)
* - Debounced keyword search (server-side, 350 ms delay)
* - Image config panel (size, aspect ratio, object-fit, class, style, alt, custom attrs)
*
* Usage:
* window.openSISMediaModal(function(selectedUrl, selectedMediaObj) {
* console.log("Selected Image URL:", selectedUrl);
* });
*
* Or via object API:
* SISMediaPicker.open(function(selectedUrl, selectedMediaObj) { ... });
* SISMediaPicker.open(function(url, mediaObj, config) { ... });
* window.openSISMediaModal(callback); // legacy compat
*/
(function (window, document) {
'use strict';
var SISMediaPicker = {
callback: null,
mediaData: [],
modalId: 'sisMediaLibraryModal',
var PAGE_SIZE = 24;
var PREDEFINED_SIZES = [
{ label: 'Auto', width: '', height: '' },
{ label: '25%', width: '25%', height: '' },
{ label: '50%', width: '50%', height: '' },
{ label: '75%', width: '75%', height: '' },
{ label: '100%', width: '100%', height: '' },
{ label: '100px', width: '100px', height: '100px' },
{ label: '150px', width: '150px', height: '150px' },
{ label: '200px', width: '200px', height: '200px' },
{ label: '300px', width: '300px', height: 'auto' },
{ label: '400px', width: '400px', height: 'auto' },
{ label: '500px', width: '500px', height: 'auto' },
{ label: 'Full Width', width: '100%', height: 'auto' },
{ label: 'Thumb (80×80)', width: '80px', height: '80px' },
{ label: 'Avatar (120×120)',width: '120px', height: '120px' },
{ label: 'Banner (1200×400)',width:'1200px', height: '400px' },
];
var SISMediaPicker = {
/* ── state ─────────────────────────────────────────── */
callback: null,
modalId: 'sisMediaLibraryModal',
configPanelId: 'sisMediaConfigPanel',
selectedUrl: null,
selectedMediaObj:null,
// Pagination / lazy-load state
currentPage: 0,
currentKeyword: '',
totalPages: 1,
loading: false,
searchTimer: null,
/* ── initModal ─────────────────────────────────────── */
initModal: function () {
var modalEl = document.getElementById(this.modalId);
if (modalEl) return modalEl;
modalEl = document.createElement('div');
modalEl.id = this.modalId;
modalEl.id = this.modalId;
modalEl.className = 'modal fade';
modalEl.setAttribute('tabindex', '-1');
modalEl.setAttribute('role', 'dialog');
modalEl.setAttribute('aria-hidden', 'true');
modalEl.innerHTML =
modalEl.innerHTML =
'<div class="modal-dialog modal-xl" role="document">' +
'<div class="modal-content border-0 shadow-lg">' +
/* ── Header ─── */
'<div class="modal-header bg-primary text-white p-3">' +
'<h5 class="modal-title font-weight-bold" id="' + this.modalId + 'Label">' +
'<i class="fas fa-images mr-2"></i> Thư viện Media / Chọn Hình ảnh' +
@@ -39,81 +74,212 @@
'<span aria-hidden="true">&times;</span>' +
'</button>' +
'</div>' +
'<div class="modal-body bg-light p-3">' +
/* ── Body ─── */
'<div class="modal-body bg-light p-3" style="max-height: 85vh; overflow-y: auto;">' +
/* Toolbar */
'<div class="d-flex flex-wrap justify-content-between align-items-center mb-3 bg-white p-2 border rounded shadow-sm">' +
'<div class="input-group input-group-sm mb-2 mb-md-0" style="max-width: 380px;">' +
'<div class="input-group-prepend"><span class="input-group-text bg-light"><i class="fas fa-search text-muted"></i></span></div>' +
'<input type="text" class="form-control" id="sisMediaSearchInput" placeholder="Tìm kiếm theo tên file..." />' +
'<input type="text" class="form-control" id="sisMediaSearchInput" placeholder="Tìm kiếm theo tên file..." autocomplete="off" />' +
'</div>' +
'<div>' +
'<label class="btn btn-sm btn-success mb-0 cursor-pointer shadow-sm" style="cursor: pointer;">' +
'<div class="d-flex align-items-center">' +
'<button type="button" class="btn btn-sm btn-outline-secondary mr-2" id="sisMediaRefreshBtn" title="Tải lại danh sách">' +
'<i class="fas fa-sync-alt"></i>' +
'</button>' +
'<label class="btn btn-sm btn-success mb-0 shadow-sm" style="cursor: pointer;">' +
'<i class="fas fa-cloud-upload-alt mr-1"></i> Tải ảnh mới lên' +
'<input type="file" id="sisMediaUploadInput" accept="image/*" style="display: none;" />' +
'</label>' +
'</div>' +
'</div>' +
'<div id="sisMediaGrid" class="row" style="max-height: 480px; overflow-y: auto; align-content: start;">' +
'<div class="col-12 text-center text-muted py-5"><i class="fas fa-spinner fa-spin fa-2x"></i><br/><span class="mt-2 d-block">Đang tải danh sách hình ảnh...</span></div>' +
/* Info bar */
'<div id="sisMediaInfoBar" class="small text-muted mb-2 px-1" style="min-height: 18px;"></div>' +
/* Grid */
'<div id="sisMediaGrid" class="row" style="max-height: 340px; overflow-y: auto; align-content: start;">' +
'<div class="col-12 text-center text-muted py-5"><i class="fas fa-spinner fa-spin fa-2x"></i><br/><span class="mt-2 d-block">Đang tải...</span></div>' +
'</div>' +
/* Lazy-load sentinel */
'<div id="sisMediaSentinel" style="height: 1px;"></div>' +
/* Config Panel */
'<div id="' + this.configPanelId + '" class="mt-3 p-3 bg-white border rounded shadow-sm" style="display: none;">' +
'<h6 class="font-weight-bold text-primary mb-3"><i class="fas fa-sliders-h mr-1"></i> Cấu hình hình ảnh đã chọn</h6>' +
'<div class="row">' +
/* Preview */
'<div class="col-md-3 text-center mb-3">' +
'<p class="small text-muted mb-1 font-weight-bold">Xem trước</p>' +
'<div class="border rounded p-2 bg-light" style="min-height: 100px; display: flex; align-items: center; justify-content: center;">' +
'<img id="sisMediaConfigPreview" src="" alt="Preview" style="max-width: 100%; max-height: 150px; object-fit: contain;" />' +
'</div>' +
'<p id="sisMediaConfigName" class="small text-truncate mt-1 text-muted mb-0"></p>' +
'</div>' +
/* Config fields */
'<div class="col-md-9">' +
'<div class="row">' +
/* Quick sizes */
'<div class="col-12 mb-2">' +
'<label class="small font-weight-bold text-muted d-block mb-1" style="font-size:10px;text-transform:uppercase;">Kích thước nhanh</label>' +
'<div id="sisMediaSizeButtons" class="d-flex flex-wrap" style="gap:4px;"></div>' +
'</div>' +
/* Width */
'<div class="col-md-4 mb-2">' +
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">Chiều rộng (width)</label>' +
'<input type="text" id="sisMediaConfigWidth" class="form-control form-control-sm" placeholder="e.g. 100%, 300px, auto" />' +
'</div>' +
/* Height */
'<div class="col-md-4 mb-2">' +
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">Chiều cao (height)</label>' +
'<input type="text" id="sisMediaConfigHeight" class="form-control form-control-sm" placeholder="e.g. auto, 200px" />' +
'</div>' +
/* Object Fit */
'<div class="col-md-4 mb-2">' +
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">Object Fit</label>' +
'<select id="sisMediaConfigObjectFit" class="form-control form-control-sm">' +
'<option value="">-- default --</option>' +
'<option value="cover">cover</option>' +
'<option value="contain">contain</option>' +
'<option value="fill">fill</option>' +
'<option value="none">none</option>' +
'<option value="scale-down">scale-down</option>' +
'</select>' +
'</div>' +
/* Aspect Ratio */
'<div class="col-md-4 mb-2">' +
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">Aspect Ratio</label>' +
'<select id="sisMediaConfigAspectRatio" class="form-control form-control-sm">' +
'<option value="">-- none --</option>' +
'<option value="1/1">1:1 (Square)</option>' +
'<option value="4/3">4:3 (Standard)</option>' +
'<option value="16/9">16:9 (Widescreen)</option>' +
'<option value="21/9">21:9 (Ultrawide)</option>' +
'<option value="3/4">3:4 (Portrait)</option>' +
'<option value="9/16">9:16 (Vertical Video)</option>' +
'<option value="2/1">2:1 (Panorama)</option>' +
'<option value="3/2">3:2 (Photo)</option>' +
'<option value="5/4">5:4</option>' +
'</select>' +
'</div>' +
/* CSS Class */
'<div class="col-md-4 mb-2">' +
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">CSS Class</label>' +
'<input type="text" id="sisMediaConfigClass" class="form-control form-control-sm" placeholder="e.g. img-fluid rounded shadow" />' +
'</div>' +
/* Inline Style */
'<div class="col-md-6 mb-2">' +
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">Inline Style (CSS)</label>' +
'<input type="text" id="sisMediaConfigStyle" class="form-control form-control-sm" placeholder="e.g. border-radius: 8px; opacity: 0.9;" />' +
'</div>' +
/* Alt Text */
'<div class="col-md-6 mb-2">' +
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">Alt Text</label>' +
'<input type="text" id="sisMediaConfigAlt" class="form-control form-control-sm" placeholder="Image description..." />' +
'</div>' +
/* Custom Attributes */
'<div class="col-md-6 mb-2">' +
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">Custom Attributes (HTML)</label>' +
'<input type="text" id="sisMediaConfigAttrs" class="form-control form-control-sm" placeholder=\'e.g. data-id="5" loading="lazy"\' />' +
'</div>' +
'</div>' +
'<div class="text-right mt-2">' +
'<button type="button" class="btn btn-sm btn-secondary mr-2" id="sisMediaConfigCancel">Hủy</button>' +
'<button type="button" class="btn btn-sm btn-primary" id="sisMediaConfigConfirm"><i class="fas fa-check mr-1"></i> Chọn ảnh này</button>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
/* Footer */
'<div class="modal-footer bg-white p-2">' +
'<button type="button" class="btn btn-sm btn-secondary px-3" data-dismiss="modal">Đóng</button>' +
'</div>' +
'</div>' +
'</div>';
document.body.appendChild(modalEl);
// Bind Search Input Handler
// Build quick-size buttons
var sizeContainer = document.getElementById('sisMediaSizeButtons');
if (sizeContainer) {
PREDEFINED_SIZES.forEach(function (size) {
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-xs btn-outline-secondary mb-1';
btn.style.cssText = 'font-size:10px;padding:1px 6px;margin-right:3px;';
btn.innerText = size.label;
btn.addEventListener('click', function () {
var wEl = document.getElementById('sisMediaConfigWidth');
var hEl = document.getElementById('sisMediaConfigHeight');
if (wEl) wEl.value = size.width;
if (hEl) hEl.value = size.height;
sizeContainer.querySelectorAll('button').forEach(function (b) {
b.classList.replace('btn-secondary', 'btn-outline-secondary');
});
btn.classList.replace('btn-outline-secondary', 'btn-secondary');
});
sizeContainer.appendChild(btn);
});
}
/* ── Event delegation (attached once on modal, never on document) ── */
var self = this;
document.addEventListener('input', function (e) {
// Debounced search
modalEl.addEventListener('input', function (e) {
if (e.target && e.target.id === 'sisMediaSearchInput') {
var term = e.target.value.toLowerCase().trim();
var filtered = self.mediaData.filter(function (item) {
var name = (item.originalFilename || item.name || '').toLowerCase();
return name.includes(term);
});
self.renderGrid(filtered);
}
});
// Bind Upload Input Handler
document.addEventListener('change', function (e) {
if (e.target && e.target.id === 'sisMediaUploadInput') {
var file = e.target.files[0];
if (!file) return;
var formData = new FormData();
formData.append('file', file);
var grid = document.getElementById('sisMediaGrid');
if (grid) {
grid.innerHTML = '<div class="col-12 text-center text-info py-5"><i class="fas fa-spinner fa-spin fa-2x"></i><br/><span class="mt-2 d-block">Đang tải tệp lên server...</span></div>';
}
fetch('/api/manage/media/upload', {
method: 'POST',
body: formData
})
.then(function (res) { return res.json(); })
.then(function (data) {
if (data && data.success === 1 && data.file && data.file.url) {
self.selectItem(data.file.url, data.file);
} else {
alert('Tải tệp lên thất bại. Vui lòng thử lại!');
self.fetchMediaList();
clearTimeout(self.searchTimer);
self.searchTimer = setTimeout(function () {
var kw = e.target.value.trim();
if (kw !== self.currentKeyword) {
self.currentKeyword = kw;
self._resetAndFetch();
}
})
.catch(function (err) {
alert('Lỗi tải tệp: ' + err.message);
self.fetchMediaList();
});
}, 350);
}
});
// Upload
modalEl.addEventListener('change', function (e) {
if (e.target && e.target.id === 'sisMediaUploadInput') {
var file = e.target.files && e.target.files[0];
if (!file) return;
self._doUpload(file);
// reset so same file can be uploaded again
e.target.value = '';
}
});
// Button clicks
modalEl.addEventListener('click', function (e) {
var id = e.target && e.target.id;
var refreshBtn = e.target && e.target.closest && e.target.closest('#sisMediaRefreshBtn');
if (refreshBtn || id === 'sisMediaRefreshBtn') {
self._resetAndFetch(); return;
}
if (id === 'sisMediaConfigCancel') { self.hideConfigPanel(); return; }
if (id === 'sisMediaConfigConfirm') { self.confirmWithConfig(); return; }
});
/* ── Infinite scroll on the grid ── */
var grid = document.getElementById('sisMediaGrid');
if (grid) {
grid.addEventListener('scroll', function () {
// trigger when within 80 px of bottom
if (grid.scrollHeight - grid.scrollTop - grid.clientHeight < 80) {
self._maybeLoadMore();
}
});
}
return modalEl;
},
/* ── open ────────────────────────────────────────────── */
open: function (callback) {
this.callback = callback;
var modalEl = this.initModal();
@@ -124,66 +290,301 @@
modalEl.style.display = 'block';
modalEl.classList.add('show');
}
this.fetchMediaList();
// Reset search input
var searchEl = document.getElementById('sisMediaSearchInput');
if (searchEl) searchEl.value = '';
this.currentKeyword = '';
this._resetAndFetch();
},
fetchMediaList: function () {
/* ── internal helpers ───────────────────────────────── */
/** Reset pagination and fetch page 0 fresh */
_resetAndFetch: function () {
this.currentPage = 0;
this.totalPages = 1;
this.loading = false;
this.hideConfigPanel();
var grid = document.getElementById('sisMediaGrid');
if (grid) {
grid.innerHTML = '<div class="col-12 text-center text-muted py-5"><i class="fas fa-spinner fa-spin fa-2x text-primary"></i><br/><span class="mt-2 d-block small">Đang nạp thư viện...</span></div>';
grid.innerHTML = '<div class="col-12 text-center text-muted py-5"><i class="fas fa-spinner fa-spin fa-2x text-primary"></i><br/><span class="mt-2 d-block small">Đang tải...</span></div>';
grid.scrollTop = 0;
}
this._updateInfoBar('');
this._fetchPage(0, true);
},
/** Load the next page if available and not already loading */
_maybeLoadMore: function () {
if (this.loading) return;
if (this.currentPage + 1 >= this.totalPages) return;
this._fetchPage(this.currentPage + 1, false);
},
/** Fetch a single page from the server
* @param {number} page - 0-based page index
* @param {boolean} reset - true → replace grid contents; false → append
*/
_fetchPage: function (page, reset) {
if (this.loading) return;
this.loading = true;
var self = this;
fetch('/api/manage/media/list')
.then(function (res) { return res.json(); })
.then(function (data) {
self.mediaData = data || [];
self.renderGrid(self.mediaData);
var url = '/api/manage/media/list?page=' + page + '&size=' + PAGE_SIZE;
if (this.currentKeyword) {
url += '&keyword=' + encodeURIComponent(this.currentKeyword);
}
// Append a loading row at the bottom when not resetting
if (!reset) {
var grid = document.getElementById('sisMediaGrid');
if (grid) {
var loaderRow = document.createElement('div');
loaderRow.className = 'col-12 text-center py-2 text-muted';
loaderRow.id = 'sisMediaPageLoader';
loaderRow.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Đang tải thêm...';
grid.appendChild(loaderRow);
}
}
fetch(url)
.then(function (res) {
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
})
.catch(function (err) {
if (grid) {
grid.innerHTML = '<div class="col-12 text-center text-danger py-4"><i class="fas fa-exclamation-triangle fa-2x mb-2"></i><br/>Không thể tải danh sách tệp. Bạn có thể dán đường dẫn URL trực tiếp.</div>';
.then(function (data) {
self.loading = false;
self.currentPage = data.number || page;
self.totalPages = data.totalPages || 1;
var items = data.content || [];
var total = data.totalElements || 0;
// Remove loader row
var existingLoader = document.getElementById('sisMediaPageLoader');
if (existingLoader) existingLoader.remove();
if (reset) {
self._renderGrid(items, true, total);
} else {
self._appendItems(items);
}
// Update info bar
var loaded = Math.min((self.currentPage + 1) * PAGE_SIZE, total);
var kw = self.currentKeyword;
var info = kw
? 'Tìm "' + kw + '": ' + total + ' ảnh — đang hiển thị ' + loaded
: total + ' ảnh — đang hiển thị ' + loaded;
if (self.currentPage + 1 >= self.totalPages) {
info += ' (đã tải tất cả)';
}
self._updateInfoBar(info);
})
.catch(function () {
self.loading = false;
var existingLoader = document.getElementById('sisMediaPageLoader');
if (existingLoader) existingLoader.remove();
if (reset) {
var grid = document.getElementById('sisMediaGrid');
if (grid) {
grid.innerHTML = '<div class="col-12 text-center text-danger py-4"><i class="fas fa-exclamation-triangle fa-2x mb-2"></i><br/>Không thể tải danh sách. Vui lòng thử lại.</div>';
}
}
self._updateInfoBar('');
});
},
renderGrid: function (list) {
_updateInfoBar: function (text) {
var bar = document.getElementById('sisMediaInfoBar');
if (bar) bar.textContent = text;
},
/** Replace entire grid with a fresh list of items */
_renderGrid: function (list, clear, total) {
var grid = document.getElementById('sisMediaGrid');
if (!grid) return;
if (clear) grid.innerHTML = '';
if (!list || list.length === 0) {
grid.innerHTML = '<div class="col-12 text-center text-muted py-5"><i class="far fa-folder-open fa-2x mb-2"></i><br/>Chưa có tệp hình ảnh nào. Bấm nút "Tải ảnh mới lên" để thêm.</div>';
grid.innerHTML = '<div class="col-12 text-center text-muted py-5"><i class="far fa-folder-open fa-2x mb-2"></i><br/>Không tìm thấy hình ảnh nào.</div>';
return;
}
var self = this;
grid.innerHTML = list.map(function (item) {
var url = item.fileUrl || item.url || '';
var name = item.originalFilename || item.name || 'Image';
return '<div class="col-lg-2 col-md-3 col-sm-4 col-6 mb-3">' +
'<div class="card h-100 border shadow-sm media-select-card" style="cursor: pointer; transition: all 0.2s;" data-url="' + url + '">' +
'<div style="height: 110px; background: #f8f9fc; display: flex; align-items: center; justify-content: center; overflow: hidden;" class="p-1">' +
'<img src="' + url + '" style="max-height: 100%; max-width: 100%; object-fit: contain;" alt="' + name + '" />' +
'</div>' +
'<div class="card-body p-2 text-center bg-white border-top">' +
'<p class="card-text small text-truncate m-0 font-weight-bold text-dark" title="' + name + '">' + name + '</p>' +
'</div>' +
'</div>' +
'</div>';
}).join('');
this._appendItems(list);
},
/** Append card elements for each item in the list */
_appendItems: function (list) {
var grid = document.getElementById('sisMediaGrid');
if (!grid || !list || list.length === 0) return;
var self = this;
list.forEach(function (item) {
var url = item.fileUrl || item.url || '';
var name = item.originalFilename || item.name || 'Image';
var col = document.createElement('div');
col.className = 'col-lg-2 col-md-3 col-sm-4 col-6 mb-3';
var card = document.createElement('div');
card.className = 'card h-100 border shadow-sm media-select-card';
card.style.cssText = 'cursor:pointer;transition:all 0.2s;';
card.setAttribute('data-url', url);
card.setAttribute('data-name', name);
card.innerHTML =
'<div style="height:110px;background:#f8f9fc;display:flex;align-items:center;justify-content:center;overflow:hidden;" class="p-1">' +
'<img src="' + url + '" loading="lazy" style="max-height:100%;max-width:100%;object-fit:contain;" alt="' + self._esc(name) + '" />' +
'</div>' +
'<div class="card-body p-2 text-center bg-white border-top">' +
'<p class="card-text small text-truncate m-0 font-weight-bold text-dark" title="' + self._esc(name) + '">' + self._esc(name) + '</p>' +
'</div>';
// Add click listeners to items
grid.querySelectorAll('.media-select-card').forEach(function (card) {
card.addEventListener('click', function () {
var selectedUrl = card.getAttribute('data-url');
self.selectItem(selectedUrl);
grid.querySelectorAll('.media-select-card').forEach(function (c) {
c.style.outline = '';
});
card.style.outline = '3px solid #007bff';
self.showConfigPanel(url, { name: name, url: url });
});
col.appendChild(card);
grid.appendChild(col);
});
},
/** Minimal HTML-escape helper */
_esc: function (str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
},
_doUpload: function (file) {
var self = this;
var formData = new FormData();
formData.append('file', file);
var grid = document.getElementById('sisMediaGrid');
if (grid) {
grid.innerHTML = '<div class="col-12 text-center text-info py-5"><i class="fas fa-spinner fa-spin fa-2x"></i><br/><span class="mt-2 d-block">Đang tải tệp lên server...</span></div>';
}
fetch('/api/manage/media/upload', { method: 'POST', body: formData })
.then(function (res) { return res.json(); })
.then(function (data) {
if (data && data.success === 1 && data.file && data.file.url) {
// Re-fetch from page 0 so new item appears, then show config
self._resetAndFetch();
// Show config after a short delay to let grid load
setTimeout(function () {
self.showConfigPanel(data.file.url, data.file);
}, 500);
} else {
alert('Tải tệp lên thất bại. Vui lòng thử lại!');
self._resetAndFetch();
}
})
.catch(function (err) {
alert('Lỗi tải tệp: ' + err.message);
self._resetAndFetch();
});
},
/* ── Config Panel ────────────────────────────────────── */
showConfigPanel: function (url, mediaObj) {
this.selectedUrl = url;
this.selectedMediaObj = mediaObj || {};
var panel = document.getElementById(this.configPanelId);
if (!panel) return;
var previewImg = document.getElementById('sisMediaConfigPreview');
if (previewImg) previewImg.src = url;
var nameEl = document.getElementById('sisMediaConfigName');
if (nameEl) nameEl.textContent = (mediaObj && (mediaObj.originalFilename || mediaObj.name)) || '';
// Reset all fields
['sisMediaConfigWidth','sisMediaConfigHeight','sisMediaConfigStyle','sisMediaConfigClass','sisMediaConfigAlt','sisMediaConfigAttrs'].forEach(function (id) {
var el = document.getElementById(id); if (el) el.value = '';
});
['sisMediaConfigObjectFit','sisMediaConfigAspectRatio'].forEach(function (id) {
var el = document.getElementById(id); if (el) el.value = '';
});
var sizeContainer = document.getElementById('sisMediaSizeButtons');
if (sizeContainer) {
sizeContainer.querySelectorAll('button').forEach(function (b) {
b.classList.replace('btn-secondary', 'btn-outline-secondary');
});
}
panel.style.display = 'block';
setTimeout(function () {
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, 50);
},
hideConfigPanel: function () {
var panel = document.getElementById(this.configPanelId);
if (panel) panel.style.display = 'none';
this.selectedUrl = null;
this.selectedMediaObj = null;
},
confirmWithConfig: function () {
if (!this.selectedUrl) return;
var g = function (id) { return (document.getElementById(id) || {}).value || ''; };
var width = g('sisMediaConfigWidth');
var height = g('sisMediaConfigHeight');
var objectFit = g('sisMediaConfigObjectFit');
var aspectRatio= g('sisMediaConfigAspectRatio');
var style = g('sisMediaConfigStyle');
var cssClass = g('sisMediaConfigClass');
var alt = g('sisMediaConfigAlt');
var customAttrs= g('sisMediaConfigAttrs');
var computedStyle = '';
if (width) computedStyle += 'width: ' + width + '; ';
if (height) computedStyle += 'height: ' + height + '; ';
if (aspectRatio) computedStyle += 'aspect-ratio: ' + aspectRatio + '; ';
if (objectFit) computedStyle += 'object-fit: ' + objectFit + '; ';
if (style) computedStyle += style;
computedStyle = computedStyle.trim();
var config = {
style: computedStyle, cssClass: cssClass, alt: alt,
customAttributes: customAttrs, width: width, height: height,
objectFit: objectFit, aspectRatio: aspectRatio
};
if (typeof this.callback === 'function') {
this.callback(this.selectedUrl, this.selectedMediaObj, config);
}
var modalEl = document.getElementById(this.modalId);
if (modalEl) {
if (typeof $ !== 'undefined' && $.fn && $.fn.modal) {
$(modalEl).modal('hide');
} else {
modalEl.style.display = 'none';
modalEl.classList.remove('show');
}
}
this.hideConfigPanel();
},
/** Legacy: immediate select without config panel */
selectItem: function (url, mediaObj) {
if (typeof this.callback === 'function') {
this.callback(url, mediaObj);
this.callback(url, mediaObj, {});
}
var modalEl = document.getElementById(this.modalId);
if (modalEl) {
@@ -197,12 +598,7 @@
}
};
// Expose standalone module globally
window.SISMediaPicker = SISMediaPicker;
// Backward-compatible global helper function
window.openSISMediaModal = function (callback) {
SISMediaPicker.open(callback);
};
window.openSISMediaModal = function (cb) { SISMediaPicker.open(cb); };
})(window, document);
@@ -75,9 +75,9 @@
<option value="">-- None (Top Level) --</option>
<th:block th:each="topOpt : ${menu.items}" th:if="${topOpt.parent == null}">
<option th:value="${topOpt.id}" th:text="${topOpt.label}"></option>
<th:block th:each="childOpt : ${menu.items}" th:if="${childOpt.parent != null and childOpt.parent.id == topOpt.id}">
<th:block th:each="childOpt : ${topOpt.children}">
<option th:value="${childOpt.id}" th:text="'-- ' + ${childOpt.label}"></option>
<th:block th:each="grandChildOpt : ${menu.items}" th:if="${grandChildOpt.parent != null and grandChildOpt.parent.id == childOpt.id}">
<th:block th:each="grandChildOpt : ${childOpt.children}">
<option th:value="${grandChildOpt.id}" th:text="'---- ' + ${grandChildOpt.label}"></option>
</th:block>
</th:block>
@@ -108,15 +108,22 @@
</div>
<div>
<span class="badge badge-primary badge-pill mr-2" th:text="'Order: ' + ${topItem.displayOrder}">0</span>
<button class="btn btn-sm btn-info" data-toggle="modal" th:data-target="'#editModal-' + ${topItem.id}">Edit</button>
<form th:action="@{/manage/menus/{menuId}/items/{itemId}/delete(menuId=${menu.id}, itemId=${topItem.id})}" method="post" class="d-inline" onsubmit="return confirm('Are you sure you want to delete this menu item?');">
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form>
<button type="button" class="btn btn-sm btn-info edit-item-btn"
data-toggle="modal" data-target="#editItemModal"
th:data-id="${topItem.id}"
th:data-label="${topItem.label}"
th:data-url="${topItem.url}"
th:data-title="${topItem.title}"
th:data-image-url="${topItem.imageUrl}"
th:data-parent-id="${topItem.parent != null ? topItem.parent.id : ''}"
th:data-display-order="${topItem.displayOrder}">Edit</button>
<button type="button" class="btn btn-sm btn-danger delete-item-btn"
th:data-action="@{/manage/menus/{menuId}/items/{itemId}/delete(menuId=${menu.id}, itemId=${topItem.id})}">Delete</button>
</div>
</li>
<!-- Children Items -->
<th:block th:each="child : ${menu.items}" th:if="${child.parent != null and child.parent.id == topItem.id}">
<th:block th:each="child : ${topItem.children}">
<li class="list-group-item d-flex justify-content-between align-items-center bg-light" style="margin-left: 2rem; border-left: 4px solid #4e73df;">
<div>
<strong th:text="${child.label}">Link Text</strong>
@@ -126,14 +133,21 @@
</div>
<div>
<span class="badge badge-primary badge-pill mr-2" th:text="'Order: ' + ${child.displayOrder}">0</span>
<button class="btn btn-sm btn-info" data-toggle="modal" th:data-target="'#editModal-' + ${child.id}">Edit</button>
<form th:action="@{/manage/menus/{menuId}/items/{itemId}/delete(menuId=${menu.id}, itemId=${child.id})}" method="post" class="d-inline" onsubmit="return confirm('Are you sure you want to delete this menu item?');">
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form>
<button type="button" class="btn btn-sm btn-info edit-item-btn"
data-toggle="modal" data-target="#editItemModal"
th:data-id="${child.id}"
th:data-label="${child.label}"
th:data-url="${child.url}"
th:data-title="${child.title}"
th:data-image-url="${child.imageUrl}"
th:data-parent-id="${child.parent != null ? child.parent.id : ''}"
th:data-display-order="${child.displayOrder}">Edit</button>
<button type="button" class="btn btn-sm btn-danger delete-item-btn"
th:data-action="@{/manage/menus/{menuId}/items/{itemId}/delete(menuId=${menu.id}, itemId=${child.id})}">Delete</button>
</div>
</li>
<!-- Grandchildren Items -->
<th:block th:each="grandchild : ${menu.items}" th:if="${grandchild.parent != null and grandchild.parent.id == child.id}">
<th:block th:each="grandchild : ${child.children}">
<li class="list-group-item d-flex justify-content-between align-items-center bg-light" style="margin-left: 4rem; border-left: 4px solid #36b9cc;">
<div>
<strong th:text="${grandchild.label}">Link Text</strong>
@@ -143,10 +157,17 @@
</div>
<div>
<span class="badge badge-primary badge-pill mr-2" th:text="'Order: ' + ${grandchild.displayOrder}">0</span>
<button class="btn btn-sm btn-info" data-toggle="modal" th:data-target="'#editModal-' + ${grandchild.id}">Edit</button>
<form th:action="@{/manage/menus/{menuId}/items/{itemId}/delete(menuId=${menu.id}, itemId=${grandchild.id})}" method="post" class="d-inline" onsubmit="return confirm('Are you sure you want to delete this menu item?');">
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form>
<button type="button" class="btn btn-sm btn-info edit-item-btn"
data-toggle="modal" data-target="#editItemModal"
th:data-id="${grandchild.id}"
th:data-label="${grandchild.label}"
th:data-url="${grandchild.url}"
th:data-title="${grandchild.title}"
th:data-image-url="${grandchild.imageUrl}"
th:data-parent-id="${grandchild.parent != null ? grandchild.parent.id : ''}"
th:data-display-order="${grandchild.displayOrder}">Edit</button>
<button type="button" class="btn btn-sm btn-danger delete-item-btn"
th:data-action="@{/manage/menus/{menuId}/items/{itemId}/delete(menuId=${menu.id}, itemId=${grandchild.id})}">Delete</button>
</div>
</li>
</th:block>
@@ -160,67 +181,134 @@
</div>
</div>
</div>
<!-- Edit Modals -->
<th:block th:each="item : ${menu.items}">
<div class="modal fade" th:id="'editModal-' + ${item.id}" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<form th:action="@{/manage/menus/{menuId}/items/{itemId}/update(menuId=${menu.id}, itemId=${item.id})}" method="post">
<div class="modal-header">
<h5 class="modal-title">Edit Menu Item</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>Link Text</label>
<input type="text" class="form-control" name="label" th:value="${item.label}" required>
</div>
<div class="form-group">
<label>URL</label>
<input type="text" class="form-control" name="url" th:value="${item.url}" required>
</div>
<div class="form-group">
<label>Title (Tùy chọn)</label>
<input type="text" class="form-control" name="title" th:value="${item.title}">
</div>
<div class="form-group">
<label>Image URL (Tùy chọn)</label>
<input type="text" class="form-control" name="imageUrl" th:value="${item.imageUrl}">
</div>
<div class="form-group">
<label>Parent Item (Optional)</label>
<select class="form-control" name="parentId">
<option value="">-- None (Top Level) --</option>
<th:block th:each="topOpt : ${menu.items}" th:if="${topOpt.parent == null and topOpt.id != item.id}">
<option th:value="${topOpt.id}" th:text="${topOpt.label}" th:selected="${item.parent != null and item.parent.id == topOpt.id}"></option>
<th:block th:each="childOpt : ${menu.items}" th:if="${childOpt.parent != null and childOpt.parent.id == topOpt.id and childOpt.id != item.id}">
<option th:value="${childOpt.id}" th:text="'-- ' + ${childOpt.label}" th:selected="${item.parent != null and item.parent.id == childOpt.id}"></option>
<th:block th:each="grandChildOpt : ${menu.items}" th:if="${grandChildOpt.parent != null and grandChildOpt.parent.id == childOpt.id and grandChildOpt.id != item.id}">
<option th:value="${grandChildOpt.id}" th:text="'---- ' + ${grandChildOpt.label}" th:selected="${item.parent != null and item.parent.id == grandChildOpt.id}"></option>
</th:block>
</th:block>
</th:block>
</select>
</div>
<div class="form-group">
<label>Display Order</label>
<input type="number" class="form-control" name="displayOrder" th:value="${item.displayOrder}">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</form>
</div>
<!-- Single Dynamic Edit Modal -->
<div class="modal fade" id="editItemModal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<form id="editItemForm" method="post" th:action="@{/manage/menus/{menuId}/items/0/update(menuId=${menu.id})}">
<div class="modal-header">
<h5 class="modal-title">Edit Menu Item</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>Link Text</label>
<input type="text" class="form-control" id="modalLabel" name="label" required>
</div>
<div class="form-group">
<label>URL</label>
<input type="text" class="form-control" id="modalUrl" name="url" required>
</div>
<div class="form-group">
<label>Title (Tùy chọn)</label>
<input type="text" class="form-control" id="modalTitle" name="title">
</div>
<div class="form-group">
<label>Image URL (Tùy chọn)</label>
<input type="text" class="form-control" id="modalImageUrl" name="imageUrl">
</div>
<div class="form-group">
<label>Parent Item (Optional)</label>
<select class="form-control" id="modalParentId" name="parentId">
<option value="">-- None (Top Level) --</option>
<th:block th:each="topOpt : ${menu.items}" th:if="${topOpt.parent == null}">
<option th:value="${topOpt.id}" th:text="${topOpt.label}"></option>
<th:block th:each="childOpt : ${topOpt.children}">
<option th:value="${childOpt.id}" th:text="'-- ' + ${childOpt.label}"></option>
<th:block th:each="grandChildOpt : ${childOpt.children}">
<option th:value="${grandChildOpt.id}" th:text="'---- ' + ${grandChildOpt.label}"></option>
</th:block>
</th:block>
</th:block>
</select>
</div>
<div class="form-group">
<label>Display Order</label>
<input type="number" class="form-control" id="modalDisplayOrder" name="displayOrder">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</form>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteItemModal" tabindex="-1" role="dialog" aria-labelledby="deleteModalLabel" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteModalLabel">Xác nhận xóa</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
Bạn có chắc chắn muốn xóa menu item này không?
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Hủy</button>
<form id="deleteItemForm" method="post" class="d-inline" th:action="@{/manage/menus/{menuId}/items/0/delete(menuId=${menu.id})}">
<button type="submit" class="btn btn-danger">Xóa</button>
</form>
</div>
</div>
</th:block>
</div>
</div>
</div>
<th:block layout:fragment="scripts">
<script th:inline="javascript">
$(document).ready(function () {
var menuId = /*[[${menu.id}]]*/ '';
$(document).on('click', '.edit-item-btn', function (e) {
e.preventDefault();
var $btn = $(this);
var id = $btn.data('id');
var label = $btn.data('label') || '';
var url = $btn.data('url') || '';
var title = $btn.data('title') || '';
var imageUrl = $btn.data('image-url') || '';
var parentId = $btn.data('parent-id') || '';
var displayOrder = $btn.data('display-order') || '0';
$('#editItemForm').attr('action', '/manage/menus/' + menuId + '/items/' + id + '/update');
$('#modalLabel').val(label);
$('#modalUrl').val(url);
$('#modalTitle').val(title);
$('#modalImageUrl').val(imageUrl);
$('#modalParentId').val(parentId);
$('#modalDisplayOrder').val(displayOrder);
$('#modalParentId option').each(function () {
if ($(this).val() == id) {
$(this).prop('disabled', true);
} else {
$(this).prop('disabled', false);
}
});
$('#editItemModal').modal('show');
});
// jQuery Delete Confirmation Handler
$(document).on('click', '.delete-item-btn', function (e) {
e.preventDefault();
var actionUrl = $(this).data('action');
$('#deleteItemForm').attr('action', actionUrl);
$('#deleteItemModal').modal('show');
});
});
</script>
</th:block>
</body>
</html>
@@ -442,6 +442,9 @@
<script th:src="@{/js/manage/editor-plugins/hover-card.js}"></script>
<script th:src="@{/js/manage/editor-plugins/tag.js}"></script>
<script th:src="@{/js/manage/editor-plugins/nav.js}"></script>
<script th:src="@{/js/manage/editor-plugins/hero-banner.js}"></script>
<script th:src="@{/js/manage/editor-plugins/text-styling.js}"></script>
<script th:src="@{/js/manage/editor-plugins/tiny-mce.js}"></script>
<!-- SIS Standalone Media Picker Library -->
<script th:src="@{/js/manage/sis-media-picker.js}"></script>
@@ -38,33 +38,35 @@
>
<!-- 1. HTML Snippet Block (Custom) -->
<th:block th:if="${block['type'] == 'snippet'}">
<div th:utext="${block.data['htmlContent']}" th:remove="tag"></div>
<div th:classappend="${block['cssClass']}" th:id="${block['elementId']}" th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}" th:attr="data-custom-attrs=${block['customAttrs']}">
<div th:utext="${block.data['htmlContent']}" th:remove="tag"></div>
</div>
</th:block>
<!-- 2. Header Block -->
<th:block th:if="${block['type'] == 'header'}">
<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>
<h1 th:case="1" th:utext="${block.data['text']}" th:classappend="${block['cssClass']}" th:id="${block['elementId']}" th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}" th:attr="data-custom-attrs=${block['customAttrs']}"></h1>
<h2 th:case="2" th:utext="${block.data['text']}" th:classappend="${block['cssClass']}" th:id="${block['elementId']}" th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}" th:attr="data-custom-attrs=${block['customAttrs']}"></h2>
<h3 th:case="3" th:utext="${block.data['text']}" th:classappend="${block['cssClass']}" th:id="${block['elementId']}" th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}" th:attr="data-custom-attrs=${block['customAttrs']}"></h3>
<h4 th:case="4" th:utext="${block.data['text']}" th:classappend="${block['cssClass']}" th:id="${block['elementId']}" th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}" th:attr="data-custom-attrs=${block['customAttrs']}"></h4>
<h5 th:case="5" th:utext="${block.data['text']}" th:classappend="${block['cssClass']}" th:id="${block['elementId']}" th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}" th:attr="data-custom-attrs=${block['customAttrs']}"></h5>
<h6 th:case="6" th:utext="${block.data['text']}" th:classappend="${block['cssClass']}" th:id="${block['elementId']}" th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}" th:attr="data-custom-attrs=${block['customAttrs']}"></h6>
<h2 th:case="*" th:utext="${block.data['text']}" th:classappend="${block['cssClass']}" th:id="${block['elementId']}" th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}" th:attr="data-custom-attrs=${block['customAttrs']}"></h2>
</th:block>
</th:block>
<!-- 3. Paragraph Block -->
<th:block th:if="${block['type'] == 'paragraph'}">
<p th:utext="${block.data['text']}"></p>
<p th:utext="${block.data['text']}" th:classappend="${block['cssClass']}" th:id="${block['elementId']}" th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}" th:attr="data-custom-attrs=${block['customAttrs']}"></p>
</th:block>
<!-- 4. List Block -->
<th:block th:if="${block['type'] == 'list'}">
<ul th:if="${block.data['style'] == 'unordered'}">
<ul th:if="${block.data['style'] == 'unordered'}" th:classappend="${block['cssClass']}" th:id="${block['elementId']}" th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}" th:attr="data-custom-attrs=${block['customAttrs']}">
<li th:each="item : ${block.data['items']}" th:utext="${item}"></li>
</ul>
<ol th:if="${block.data['style'] == 'ordered'}">
<ol th:if="${block.data['style'] == 'ordered'}" th:classappend="${block['cssClass']}" th:id="${block['elementId']}" th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}" th:attr="data-custom-attrs=${block['customAttrs']}">
<li th:each="item : ${block.data['items']}" th:utext="${item}"></li>
</ol>
</th:block>
@@ -77,7 +79,7 @@
<!-- 6. Quote Block -->
<th:block th:if="${block['type'] == 'quote'}">
<blockquote>
<blockquote th:classappend="${block['cssClass']}" th:id="${block['elementId']}" th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}" th:attr="data-custom-attrs=${block['customAttrs']}">
<p th:utext="${block.data['text']}"></p>
<footer th:if="${block.data['caption']}" th:utext="${block.data['caption']}"></footer>
</blockquote>
@@ -90,7 +92,7 @@
<!-- 8. Table Block -->
<th:block th:if="${block['type'] == 'table'}">
<table>
<table th:classappend="${block['cssClass']}" th:id="${block['elementId']}" th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}" th:attr="data-custom-attrs=${block['customAttrs']}">
<tbody>
<tr th:each="row, rowStat : ${block.data['content']}">
<th:block th:if="${block.data['withHeadings'] == true and rowStat.index == 0}">
@@ -109,9 +111,14 @@
<div th:utext="${block.data['html']}" th:remove="tag"></div>
</th:block>
<!-- TinyMCE Rich Editor Block -->
<th:block th:if="${block['type'] == 'tinymce'}">
<div th:utext="${block.data['html']}" th:classappend="${block['cssClass']}" th:id="${block['elementId']}" th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}" th:attr="data-custom-attrs=${block['customAttrs']}"></div>
</th:block>
<!-- 10. Accordion Block -->
<th:block th:if="${block['type'] == 'accordion'}">
<details class="sis-accordion-item mb-3" th:open="${block.data['isOpen'] == true}">
<details class="sis-accordion-item mb-3" th:open="${block.data['isOpen'] == true}" th:classappend="${block['cssClass']}" th:id="${block['elementId']}" th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}" th:attr="data-custom-attrs=${block['customAttrs']}">
<summary class="sis-accordion-title font-weight-bold p-3 bg-light border rounded" style="cursor: pointer; user-select: none">
<span th:utext="${block.data['title']}">Accordion Title</span>
</summary>
@@ -123,7 +130,7 @@
<!-- 11. YouTube Video Block -->
<th:block th:if="${block['type'] == 'youtube'}">
<div class="sis-youtube-container my-4 text-center">
<div class="sis-youtube-container my-4 text-center" th:classappend="${block['cssClass']}" th:id="${block['elementId']}" th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}" th:attr="data-custom-attrs=${block['customAttrs']}">
<div
class="sis-yt-thumb-wrapper"
th:data-embed-url="${block.data['url']}"
@@ -169,6 +176,8 @@
<th:block th:if="${block['type'] == 'hero'}">
<div
class="sis-hero-banner text-white py-5 px-3 rounded shadow-sm text-center"
th:classappend="${block['cssClass']}"
th:id="${block['elementId'] != null ? block['elementId'] : null}"
style="
position: relative;
overflow: hidden;
@@ -179,7 +188,8 @@
background-position: center;
background-color: #002554;
"
th:styleappend="${'min-height: ' + (block.data['height'] != null and !#strings.isEmpty(block.data['height']) ? block.data['height'] : '350px') + ';' + ((block.data['bgImage'] != null and !#strings.isEmpty(block.data['bgImage'])) ? 'background-image: url(' + block.data['bgImage'] + ');' : 'background: linear-gradient(135deg, #002554 0%, #881C1C 100%);')}"
th:styleappend="${'min-height: ' + (block.data['height'] != null and !#strings.isEmpty(block.data['height']) ? block.data['height'] : '350px') + ';' + ((block.data['bgImage'] != null and !#strings.isEmpty(block.data['bgImage'])) ? 'background-image: url(' + block.data['bgImage'] + ');' : 'background: linear-gradient(135deg, #002554 0%, #881C1C 100%);') + (block['customStyle'] != null ? '; ' + block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] : '')}"
th:attr="data-custom-attrs=${block['customAttrs']}"
>
<div style="position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.4); z-index: 1"></div>
<div class="container" style="position: relative; z-index: 2">
@@ -210,11 +220,13 @@
<!-- 13. Sticky Nav / Sub-menu Block (UMass Theme mc--info-menu style) -->
<th:block th:if="${block['type'] == 'stickyNav' or block['type'] == 'nav'}">
<nav
th:id="${block.data['globalId'] != null and !#strings.isEmpty(block.data['globalId']) ? block.data['globalId'] : null}"
th:id="${block.data['globalId'] != null and !#strings.isEmpty(block.data['globalId']) ? block.data['globalId'] : (block['elementId'] != null ? block['elementId'] : null)}"
class="mc--menu mc--info-menu sis-sticky-nav my-3"
th:classappend="${(block.data['globalClass'] != null and !#strings.isEmpty(block.data['globalClass']) ? (block.data['globalClass'] + ' ') : '') + (block.data['stretched'] == true ? 'sis-fullwidth-breakout ' : '') + (block.data['sticky'] == true or block.data['sticky'] == null ? 'is-sticky ' : '') + (block.data['bgColor'] == '#ffffff' ? 'sis-nav-light ' : '')}"
th:classappend="${(block.data['globalClass'] != null and !#strings.isEmpty(block.data['globalClass']) ? (block.data['globalClass'] + ' ') : '') + (block.data['stretched'] == true ? 'sis-fullwidth-breakout ' : '') + (block.data['sticky'] == true or block.data['sticky'] == null ? 'is-sticky ' : '') + (block.data['bgColor'] == '#ffffff' ? 'sis-nav-light ' : '') + (block['cssClass'] != null ? block['cssClass'] : '')}"
th:style="${'background-color: ' + (block.data['bgColor'] != null ? block.data['bgColor'] : '#002554') + ' !important;'}"
th:data-custom-attrs="${block.data['globalAttributes'] != null and !#strings.isEmpty(block.data['globalAttributes']) ? block.data['globalAttributes'] : (block.data['attributes'] != null and !#strings.isEmpty(block.data['attributes']) ? block.data['attributes'] : null)}"
th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}"
th:attr="data-custom-attrs=${block['customAttrs']}"
th:data-custom-attrs-original="${block.data['globalAttributes'] != null and !#strings.isEmpty(block.data['globalAttributes']) ? block.data['globalAttributes'] : (block.data['attributes'] != null and !#strings.isEmpty(block.data['attributes']) ? block.data['attributes'] : null)}"
>
<ul class="menu m--menu m--info-menu">
<li class="menu-item menu-item--expanded">
@@ -268,7 +280,7 @@
<!-- 14. Timeline Block (Group) -->
<th:block th:if="${block['type'] == 'timeline' and block.data['items'] != null}">
<section class="sis-timeline" th:id="'timeline-' + ${stat != null ? stat.index : 0}">
<section class="sis-timeline" th:id="${block['elementId'] != null ? block['elementId'] : ('timeline-' + (stat != null ? stat.index : 0))}" th:classappend="${block['cssClass']}" th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}" th:attr="data-custom-attrs=${block['customAttrs']}">
<div class="sis-timeline__grid">
<!-- Text Column (Left) -->
<div class="sis-timeline__text-col">
@@ -350,15 +362,18 @@
<!-- 15. Grid / Columns Block -->
<th:block th:if="${block['type'] == 'grid'}">
<div th:id="${block.data['globalId'] != null and !#strings.isEmpty(block.data['globalId']) ? block.data['globalId'] : null}"
th:class="${block.data['globalClass'] != null and !#strings.isEmpty(block.data['globalClass']) ? block.data['globalClass'] : null}"
th:data-custom-attrs="${block.data['globalAttributes'] != null and !#strings.isEmpty(block.data['globalAttributes']) ? block.data['globalAttributes'] : (block.data['attributes'] != null and !#strings.isEmpty(block.data['attributes']) ? block.data['attributes'] : null)}">
<div th:id="${block.data['globalId'] != null and !#strings.isEmpty(block.data['globalId']) ? block.data['globalId'] : (block['elementId'] != null ? block['elementId'] : null)}"
th:class="${block.data['globalClass'] != null and !#strings.isEmpty(block.data['globalClass']) ? block.data['globalClass'] : ''}"
th:classappend="${block['cssClass']}"
th:styleappend="${(block.data['globalStyle'] != null ? block.data['globalStyle'] + ';' : '') + (block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}"
th:attr="data-custom-attrs=${block['customAttrs']}"
th:data-custom-attrs-original="${block.data['globalAttributes'] != null and !#strings.isEmpty(block.data['globalAttributes']) ? block.data['globalAttributes'] : (block.data['attributes'] != null and !#strings.isEmpty(block.data['attributes']) ? block.data['attributes'] : null)}">
<div class="container py-4">
<div class="row">
<div class="sis-grid-layout" th:classappend="${block.data['itemAlignment'] == 'center' or block.data['centerItems'] == true ? 'justify-content-center align-items-center' : (block.data['itemAlignment'] == 'right' ? 'justify-content-end' : (block.data['itemAlignment'] == 'between' ? 'justify-content-between' : (block.data['itemAlignment'] == 'around' ? 'justify-content-around' : '')))}">
<th:block th:each="i : ${#numbers.sequence(1, block.data['cols'])}">
<div
th:id="${block.data['id' + i] != null and !#strings.isEmpty(block.data['id' + i]) ? block.data['id' + i] : null}"
th:class="${(block.data['width' + i] != null and block.data['width' + i] gt 0) ? ('col-lg-' + block.data['width' + i] + ' col-md-6 col-12 mb-4') : (block.data['cols'] == 1 ? 'col-12 mb-4' : (block.data['cols'] == 2 ? 'col-md-6 col-12 mb-4' : (block.data['cols'] == 3 ? 'col-lg-4 col-md-6 col-12 mb-4' : (block.data['cols'] == 4 ? 'col-lg-3 col-md-6 col-12 mb-4' : 'col-lg col-md-6 col-12 mb-4'))))}"
th:class="${(block.data['width' + i] != null and block.data['width' + i] gt 0) ? ('col-md-' + block.data['width' + i] + ' sis-grid-span-' + block.data['width' + i]) : (block.data['cols'] == 1 ? 'col-md-12 sis-grid-span-12' : (block.data['cols'] == 2 ? 'col-md-6 sis-grid-span-6' : (block.data['cols'] == 3 ? 'col-md-4 sis-grid-span-4' : (block.data['cols'] == 4 ? 'col-md-3 sis-grid-span-3' : (block.data['cols'] == 6 ? 'col-md-2 sis-grid-span-2' : 'col-md-3 sis-grid-span-3')))))}"
th:classappend="${block.data['class' + i] != null and !#strings.isEmpty(block.data['class' + i]) ? block.data['class' + i] : ''}"
th:data-custom-attrs="${block.data['attr' + i] != null and !#strings.isEmpty(block.data['attr' + i]) ? block.data['attr' + i] : null}"
>
@@ -372,9 +387,12 @@
<!-- 16. Flex Layout Block -->
<th:block th:if="${block['type'] == 'flex'}">
<div th:id="${block.data['globalId'] != null and !#strings.isEmpty(block.data['globalId']) ? block.data['globalId'] : null}"
th:class="${block.data['globalClass'] != null and !#strings.isEmpty(block.data['globalClass']) ? block.data['globalClass'] : null}"
th:data-custom-attrs="${block.data['globalAttributes'] != null and !#strings.isEmpty(block.data['globalAttributes']) ? block.data['globalAttributes'] : (block.data['attributes'] != null and !#strings.isEmpty(block.data['attributes']) ? block.data['attributes'] : null)}">
<div th:id="${block.data['globalId'] != null and !#strings.isEmpty(block.data['globalId']) ? block.data['globalId'] : (block['elementId'] != null ? block['elementId'] : null)}"
th:class="${block.data['globalClass'] != null and !#strings.isEmpty(block.data['globalClass']) ? block.data['globalClass'] : ''}"
th:classappend="${block['cssClass']}"
th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}"
th:attr="data-custom-attrs=${block['customAttrs']}"
th:data-custom-attrs-original="${block.data['globalAttributes'] != null and !#strings.isEmpty(block.data['globalAttributes']) ? block.data['globalAttributes'] : (block.data['attributes'] != null and !#strings.isEmpty(block.data['attributes']) ? block.data['attributes'] : null)}">
<div class="container py-4">
<div
style="display: flex; flex-wrap: wrap;"
@@ -397,9 +415,12 @@
<!-- 17. Dedicated Hover Cards Block -->
<th:block th:if="${block['type'] == 'hovercard' or block['type'] == 'hover-card' or block['type'] == 'hoverCard'}">
<div th:id="${block.data['globalId'] != null and !#strings.isEmpty(block.data['globalId']) ? block.data['globalId'] : null}"
<div th:id="${block.data['globalId'] != null and !#strings.isEmpty(block.data['globalId']) ? block.data['globalId'] : (block['elementId'] != null ? block['elementId'] : null)}"
th:class="${(block.data['styleVariant'] == 'v2' or (block.data['globalClass'] != null and block.data['globalClass'].contains('v2'))) ? ('sis-hover-card-v2-section ' + (block.data['globalClass'] != null ? block.data['globalClass'] : '')) : ('sis-hover-card-section ' + (block.data['globalClass'] != null ? block.data['globalClass'] : ''))}"
th:data-custom-attrs="${block.data['globalAttributes'] != null and !#strings.isEmpty(block.data['globalAttributes']) ? block.data['globalAttributes'] : (block.data['attributes'] != null and !#strings.isEmpty(block.data['attributes']) ? block.data['attributes'] : null)}">
th:classappend="${block['cssClass']}"
th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}"
th:attr="data-custom-attrs=${block['customAttrs']}"
th:data-custom-attrs-original="${block.data['globalAttributes'] != null and !#strings.isEmpty(block.data['globalAttributes']) ? block.data['globalAttributes'] : (block.data['attributes'] != null and !#strings.isEmpty(block.data['attributes']) ? block.data['attributes'] : null)}">
<div class="container py-4">
<div class="row align-items-center">
<th:block th:each="item, iterStat : ${block.data['items']}">
@@ -433,16 +454,28 @@
<!-- Type 1: HTML / Text -->
<th:block th:if="${type == null or type == 'html'}" th:utext="${blockData != null ? blockData['col' + i] : ''}"></th:block>
<!-- Type: TinyMCE -->
<th:block th:if="${type == 'tinymce' and blockData != null and blockData['col' + i] != null}" th:utext="${blockData['col' + i]['html']}"></th:block>
<!-- Type 2: Image -->
<th:block th:if="${type == 'image'}">
<div class="sis-grid-image-wrapper text-center">
<!-- Native Image Plugin structure -->
<th:block th:if="${blockData != null and blockData['col' + i] != null and blockData['col' + i] != '' and blockData['col' + i]['file'] != null}">
<img th:src="${blockData['col' + i]['file']['url']}" class="img-fluid rounded shadow-sm" th:alt="${blockData['col' + i]['caption']}" />
<img th:src="${blockData['col' + i]['file']['url']}"
th:class="${(blockData['imgClass' + i] != null and !#strings.isEmpty(blockData['imgClass' + i])) ? blockData['imgClass' + i] : 'img-fluid rounded shadow-sm'}"
th:style="${(blockData['imgStyle' + i] != null and !#strings.isEmpty(blockData['imgStyle' + i])) ? blockData['imgStyle' + i] : null}"
th:alt="${(blockData['imgAlt' + i] != null and !#strings.isEmpty(blockData['imgAlt' + i])) ? blockData['imgAlt' + i] : (blockData['col' + i]['caption'] != null ? blockData['col' + i]['caption'] : 'Image')}"
th:data-attrs="${(blockData['imgAttrs' + i] != null and !#strings.isEmpty(blockData['imgAttrs' + i])) ? blockData['imgAttrs' + i] : null}" />
</th:block>
<!-- Custom imgUrl fallback -->
<th:block th:unless="${blockData != null and blockData['col' + i] != null and blockData['col' + i] != '' and blockData['col' + i]['file'] != null}">
<img th:if="${blockData != null and blockData['imgUrl' + i] != null and !#strings.isEmpty(blockData['imgUrl' + i])}" th:src="${blockData['imgUrl' + i]}" class="img-fluid rounded shadow-sm" alt="Grid Image" />
<img th:if="${blockData != null and blockData['imgUrl' + i] != null and !#strings.isEmpty(blockData['imgUrl' + i])}"
th:src="${blockData['imgUrl' + i]}"
th:class="${(blockData['imgClass' + i] != null and !#strings.isEmpty(blockData['imgClass' + i])) ? blockData['imgClass' + i] : 'img-fluid rounded shadow-sm'}"
th:style="${(blockData['imgStyle' + i] != null and !#strings.isEmpty(blockData['imgStyle' + i])) ? blockData['imgStyle' + i] : null}"
th:alt="${(blockData['imgAlt' + i] != null and !#strings.isEmpty(blockData['imgAlt' + i])) ? blockData['imgAlt' + i] : 'Grid Image'}"
th:data-attrs="${(blockData['imgAttrs' + i] != null and !#strings.isEmpty(blockData['imgAttrs' + i])) ? blockData['imgAttrs' + i] : null}" />
</th:block>
</div>
</th:block>
@@ -590,6 +623,24 @@
</div>
</div>
</th:block>
<!-- Column Caption (Rendered underneath any type) -->
<th:block th:with="
hasCustomCaption=${blockData != null and blockData['caption' + i] != null and !#strings.isEmpty(blockData['caption' + i])},
hasNativeImageCaption=${type == 'image' and blockData != null and blockData['col' + i] != null and blockData['col' + i] != '' and blockData['col' + i]['caption'] != null and !#strings.isEmpty(blockData['col' + i]['caption'])},
captionText=${hasCustomCaption ? blockData['caption' + i] : (hasNativeImageCaption ? blockData['col' + i]['caption'] : null)}
">
<div th:if="${captionText != null and !#strings.isEmpty(#strings.trim(captionText))}"
class="sis-grid-cell-caption text-center mt-2 small text-muted font-weight-bold"
th:utext="${captionText}">
</div>
</th:block>
<!-- Optional Sub-Plugin Content (Rendered underneath the main cell content) -->
<th:block th:if="${blockData != null and blockData['subType' + i] != null and blockData['subType' + i] != 'none' and blockData['subType' + i] != 'caption'}">
<div class="sis-grid-sub-content mt-2">
<div th:replace=":: renderCell(type=${blockData['subType' + i]}, i=${'sub_' + i}, blockData=${blockData})"></div>
</div>
</th:block>
</th:block>
<style>
html {
@@ -49,11 +49,9 @@
</style>
<div class="image-video-container has-video">
<img
src="https://www.umass.edu/sites/default/files/styles/1_1_1920x1920/public/2025-09/250616_UMASS_4515.jpg"
src="/uploads/2026/07/dotquy201.webp"
alt="Students collaborate using a driving simulator in the UMass Center for Transportation."
srcset="
https://www.umass.edu/sites/default/files/styles/1_1_1920x1920/public/2025-09/250616_UMASS_4515.jpg?h=9855f42d&amp;itok=d27r8GaT 1920w
"
srcset="/uploads/2026/07/dotquy201.webp?h=9855f42d&amp;itok=d27r8GaT 1920w"
/>
<div class="f--ambient-video" id="hero-ambient-video">
<video
@@ -48,11 +48,9 @@
</style>
<div class="image-video-container has-video">
<img
src="https://www.umass.edu/sites/default/files/styles/1_1_1920x1920/public/2025-09/250616_UMASS_4515.jpg"
src="/uploads/2026/07/dotquy201.webp"
alt="Students collaborate using a driving simulator in the UMass Center for Transportation."
srcset="
https://www.umass.edu/sites/default/files/styles/1_1_1920x1920/public/2025-09/250616_UMASS_4515.jpg?h=9855f42d&amp;itok=d27r8GaT 1920w
"
srcset="/uploads/2026/07/dotquy201.webp?h=9855f42d&amp;itok=d27r8GaT 1920w"
/>
<div class="f--ambient-video" id="hero-ambient-video">
<video
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 570 KiB

Some files were not shown because too many files have changed in this diff Show More