feat: enhance page block system with custom styling support and add TinyMCE rich editor integration
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 274 KiB |
|
After Width: | Height: | Size: 166 KiB |
@@ -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
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
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">×</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);
|
||||||
@@ -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;"><div></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});
|
||||||
@@ -1744,10 +1744,10 @@ Date: Wed, 29 Jul 2026 15:57:14 GMT
|
|||||||
<div class="slides-container">
|
<div class="slides-container">
|
||||||
<div class="image-video-container has-video">
|
<div class="image-video-container has-video">
|
||||||
<img
|
<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."
|
alt="Students collaborate using a driving simulator in the UMass Center for Transportation."
|
||||||
srcset="
|
srcset="
|
||||||
https://www.umass.edu/sites/default/files/styles/1_1_1920x1920/public/2025-09/250616_UMASS_4515.jpg?h=9855f42d&itok=d27r8GaT 1920w
|
/uploads/2026/07/dotquy201.webp?h=9855f42d&itok=d27r8GaT 1920w
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
<div class="f--ambient-video" id="hero-ambient-video">
|
<div class="f--ambient-video" id="hero-ambient-video">
|
||||||
|
|||||||
@@ -7,11 +7,9 @@
|
|||||||
<div class="slides-container">
|
<div class="slides-container">
|
||||||
<div class="image-video-container has-video">
|
<div class="image-video-container has-video">
|
||||||
<img
|
<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."
|
alt="Students collaborate using a driving simulator in the UMass Center for Transportation."
|
||||||
srcset="
|
srcset="/uploads/2026/07/dotquy201.webp 1920w"
|
||||||
https://www.umass.edu/sites/default/files/styles/1_1_1920x1920/public/2025-09/250616_UMASS_4515.jpg?h=9855f42d&itok=d27r8GaT 1920w
|
|
||||||
"
|
|
||||||
/>
|
/>
|
||||||
<div class="f--ambient-video" id="hero-ambient-video">
|
<div class="f--ambient-video" id="hero-ambient-video">
|
||||||
<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;
|
package com.sisvietnamvn.web.controller;
|
||||||
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
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.core.type.TypeReference;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.sisvietnamvn.web.domain.Page;
|
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.hook.HookManager;
|
||||||
|
import com.sisvietnamvn.web.repository.PostRepository;
|
||||||
import com.sisvietnamvn.web.security.AuthoritiesConstants;
|
import com.sisvietnamvn.web.security.AuthoritiesConstants;
|
||||||
import com.sisvietnamvn.web.security.SecurityUtils;
|
import com.sisvietnamvn.web.security.SecurityUtils;
|
||||||
import com.sisvietnamvn.web.service.PageService;
|
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.
|
* Controller for rendering public-facing pages dynamically.
|
||||||
@@ -424,6 +423,19 @@ public class PageController {
|
|||||||
data.put("htmlContent", snippetService.getSnippetContent(snippetId));
|
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) {
|
} catch (JsonProcessingException e) {
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import com.sisvietnamvn.web.domain.Media;
|
|||||||
import com.sisvietnamvn.web.service.MediaService;
|
import com.sisvietnamvn.web.service.MediaService;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
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.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
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")
|
@GetMapping("/list")
|
||||||
public ResponseEntity<List<Media>> getMediaList() {
|
public ResponseEntity<Map<String, Object>> getMediaList(
|
||||||
return ResponseEntity.ok(mediaService.findAll());
|
@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;
|
display: none !important;
|
||||||
}
|
}
|
||||||
.desktop-only {
|
.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;
|
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)
|
stretched: this.stretchCheck ? this.stretchCheck.checked : !!(this.data && this.data.stretched)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
if (this.editorInstance) {
|
||||||
|
this.editorInstance.dispose();
|
||||||
|
this.editorInstance = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class SISAccordionTool {
|
class SISAccordionTool {
|
||||||
@@ -525,189 +532,7 @@ class SISYouTubeTool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class SISHeroBannerTool {
|
// SISHeroBannerTool extracted to /js/manage/editor-plugins/hero-banner.js
|
||||||
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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -733,6 +558,7 @@ function initSISEditor(holderId, hiddenInputId, initialData, skipSubmitHandler =
|
|||||||
builtInTools.header = {
|
builtInTools.header = {
|
||||||
class: Header,
|
class: Header,
|
||||||
inlineToolbar: ['link'],
|
inlineToolbar: ['link'],
|
||||||
|
tunes: ['textStyling'],
|
||||||
config: {
|
config: {
|
||||||
placeholder: 'Header text...',
|
placeholder: 'Header text...',
|
||||||
levels: [1, 2, 3, 4, 5, 6],
|
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') {
|
if (typeof NestedList !== 'undefined') {
|
||||||
builtInTools.list = {
|
builtInTools.list = {
|
||||||
class: NestedList,
|
class: NestedList,
|
||||||
inlineToolbar: true,
|
inlineToolbar: true,
|
||||||
|
tunes: ['textStyling'],
|
||||||
config: { defaultStyle: 'unordered' }
|
config: { defaultStyle: 'unordered' }
|
||||||
};
|
};
|
||||||
} else if (typeof List !== 'undefined') {
|
} else if (typeof List !== 'undefined') {
|
||||||
builtInTools.list = {
|
builtInTools.list = {
|
||||||
class: List,
|
class: List,
|
||||||
inlineToolbar: true,
|
inlineToolbar: true,
|
||||||
|
tunes: ['textStyling'],
|
||||||
config: { defaultStyle: 'unordered' }
|
config: { defaultStyle: 'unordered' }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -759,6 +591,7 @@ function initSISEditor(holderId, hiddenInputId, initialData, skipSubmitHandler =
|
|||||||
builtInTools.quote = {
|
builtInTools.quote = {
|
||||||
class: Quote,
|
class: Quote,
|
||||||
inlineToolbar: true,
|
inlineToolbar: true,
|
||||||
|
tunes: ['textStyling'],
|
||||||
config: {
|
config: {
|
||||||
quotePlaceholder: 'Enter a quote',
|
quotePlaceholder: 'Enter a quote',
|
||||||
captionPlaceholder: 'Quote\'s author'
|
captionPlaceholder: 'Quote\'s author'
|
||||||
@@ -767,31 +600,135 @@ function initSISEditor(holderId, hiddenInputId, initialData, skipSubmitHandler =
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (typeof Delimiter !== 'undefined') builtInTools.delimiter = { class: Delimiter };
|
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 CodeTool !== 'undefined') builtInTools.code = { class: CodeTool };
|
||||||
if (typeof Warning !== 'undefined') builtInTools.warning = { class: Warning, inlineToolbar: true, config: { titlePlaceholder: 'Title', messagePlaceholder: 'Message' } };
|
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 Marker !== 'undefined') builtInTools.marker = { class: Marker };
|
||||||
if (typeof InlineCode !== 'undefined') builtInTools.inlineCode = { class: InlineCode };
|
if (typeof InlineCode !== 'undefined') builtInTools.inlineCode = { class: InlineCode };
|
||||||
if (typeof Underline !== 'undefined') builtInTools.underline = { class: Underline };
|
if (typeof Underline !== 'undefined') builtInTools.underline = { class: Underline };
|
||||||
if (typeof ImageTool !== 'undefined') {
|
// Custom Image Block that bypasses default ImageTool uploader and triggers SISMediaPicker directly
|
||||||
builtInTools.image = {
|
class SISCustomImageTool {
|
||||||
class: ImageTool,
|
static get toolbox() {
|
||||||
config: {
|
return {
|
||||||
endpoints: {
|
title: 'Image',
|
||||||
byFile: '/api/manage/media/upload',
|
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>'
|
||||||
byUrl: '/api/manage/media/fetchUrl'
|
};
|
||||||
},
|
}
|
||||||
uploader: {
|
|
||||||
uploadByUrl(url) {
|
constructor({ data, api, readOnly }) {
|
||||||
return Promise.resolve({
|
this.api = api;
|
||||||
success: 1,
|
this.readOnly = readOnly;
|
||||||
file: { url: url }
|
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.');
|
||||||
}
|
}
|
||||||
},
|
|
||||||
field: 'file',
|
|
||||||
types: 'image/*'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_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;
|
window.SISEditorPlugins.image = builtInTools.image;
|
||||||
}
|
}
|
||||||
@@ -806,14 +743,30 @@ function initSISEditor(holderId, hiddenInputId, initialData, skipSubmitHandler =
|
|||||||
builtInTools.raw = { class: SISRawHtmlTool };
|
builtInTools.raw = { class: SISRawHtmlTool };
|
||||||
builtInTools.accordion = { class: SISAccordionTool };
|
builtInTools.accordion = { class: SISAccordionTool };
|
||||||
builtInTools.youtube = { class: SISYouTubeTool };
|
builtInTools.youtube = { class: SISYouTubeTool };
|
||||||
|
if (typeof SISHeroBannerTool !== 'undefined') {
|
||||||
builtInTools.hero = { class: SISHeroBannerTool };
|
builtInTools.hero = { class: SISHeroBannerTool };
|
||||||
|
}
|
||||||
if (typeof SISStickyNavTool !== 'undefined') {
|
if (typeof SISStickyNavTool !== 'undefined') {
|
||||||
builtInTools.stickyNav = { class: SISStickyNavTool };
|
builtInTools.stickyNav = { class: SISStickyNavTool };
|
||||||
}
|
}
|
||||||
|
if (typeof TextStylingTune !== 'undefined') {
|
||||||
|
builtInTools.textStyling = { class: TextStylingTune };
|
||||||
|
}
|
||||||
|
|
||||||
// === Merge built-in tools with any registered plugins ===
|
// === Merge built-in tools with any registered plugins ===
|
||||||
var allTools = Object.assign({}, builtInTools, window.SISEditorPlugins);
|
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 ===
|
// === Parse initial data ===
|
||||||
var editorData = null;
|
var editorData = null;
|
||||||
if (initialData && typeof initialData === 'string') {
|
if (initialData && typeof initialData === 'string') {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class FlexTool {
|
|||||||
this.data[`id${i}`] = data[`id${i}`] || '';
|
this.data[`id${i}`] = data[`id${i}`] || '';
|
||||||
this.data[`attr${i}`] = data[`attr${i}`] || data[`attributes${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[`width${i}`] = parseInt(data[`width${i}`]) || 0; // 0 means auto
|
||||||
|
this.data[`caption${i}`] = data[`caption${i}`] || '';
|
||||||
}
|
}
|
||||||
this.wrapper = undefined;
|
this.wrapper = undefined;
|
||||||
}
|
}
|
||||||
@@ -337,15 +338,104 @@ class FlexTool {
|
|||||||
headerRow.appendChild(controlsWrapper);
|
headerRow.appendChild(controlsWrapper);
|
||||||
colDiv.appendChild(headerRow);
|
colDiv.appendChild(headerRow);
|
||||||
colDiv.appendChild(contentDiv);
|
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);
|
row.appendChild(colDiv);
|
||||||
|
|
||||||
this._renderTypeSpecificInput(i, contentDiv);
|
this._renderTypeSpecificInput(i, contentDiv);
|
||||||
|
renderSubInputs();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.inputsContainer.appendChild(row);
|
this.inputsContainer.appendChild(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
_renderTypeSpecificInput(i, container) {
|
_renderTypeSpecificInput(i, container, explicitType) {
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
|
|
||||||
// Remove tracking of previous instance
|
// Remove tracking of previous instance
|
||||||
@@ -357,7 +447,7 @@ class FlexTool {
|
|||||||
typeContainer.className = 'mb-3';
|
typeContainer.className = 'mb-3';
|
||||||
container.appendChild(typeContainer);
|
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
|
// Check if type is a dynamic editor plugin registered in window.SISEditorPlugins
|
||||||
if (window.SISEditorPlugins && window.SISEditorPlugins[type]) {
|
if (window.SISEditorPlugins && window.SISEditorPlugins[type]) {
|
||||||
@@ -411,11 +501,13 @@ class FlexTool {
|
|||||||
controls.style.gap = '8px';
|
controls.style.gap = '8px';
|
||||||
|
|
||||||
controls.innerHTML = `
|
controls.innerHTML = `
|
||||||
<div class="text-muted small text-center mb-1"><i class="fas fa-cog"></i> Image Controls</div>
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||||
<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>
|
<span class="text-muted small font-weight-bold"><i class="fas fa-image"></i> Image Settings</span>
|
||||||
<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-link btn-xs text-danger p-0" id="btn-clear-img-${i}" title="Remove Image"><i class="fas fa-trash-alt"></i></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>
|
</div>
|
||||||
<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>
|
<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}`);
|
const uploadBtn = controls.querySelector(`#btn-upload-img-${i}`);
|
||||||
@@ -450,16 +542,23 @@ class FlexTool {
|
|||||||
mediaBtn.addEventListener('click', (e) => {
|
mediaBtn.addEventListener('click', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (typeof openMediaPickerModal === 'function') {
|
if (window.SISMediaPicker) {
|
||||||
openMediaPickerModal((url) => {
|
SISMediaPicker.open((url, mediaObj, config) => {
|
||||||
if (url) {
|
if (url) {
|
||||||
this.data[`col${i}`] = {
|
this.data[`col${i}`] = {
|
||||||
file: { url: url },
|
file: { url: url },
|
||||||
caption: '',
|
caption: (config && config.alt) || '',
|
||||||
withBorder: false,
|
withBorder: false,
|
||||||
withBackground: false,
|
withBackground: false,
|
||||||
stretched: 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);
|
this._renderTypeSpecificInput(i, container);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -544,10 +643,17 @@ class FlexTool {
|
|||||||
chooseBtn.innerText = 'Choose';
|
chooseBtn.innerText = 'Choose';
|
||||||
if (this.readOnly) chooseBtn.disabled = true;
|
if (this.readOnly) chooseBtn.disabled = true;
|
||||||
chooseBtn.addEventListener('click', () => {
|
chooseBtn.addEventListener('click', () => {
|
||||||
if (typeof openMediaPickerModal === 'function') {
|
if (window.SISMediaPicker) {
|
||||||
openMediaPickerModal((selectedUrl) => {
|
SISMediaPicker.open((selectedUrl, mediaObj, config) => {
|
||||||
input.value = selectedUrl;
|
input.value = selectedUrl;
|
||||||
this.data[`imgUrl${i}`] = 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;
|
previewImg.src = selectedUrl;
|
||||||
previewBox.style.display = 'block';
|
previewBox.style.display = 'block';
|
||||||
});
|
});
|
||||||
@@ -681,6 +787,8 @@ class FlexTool {
|
|||||||
typeContainer.appendChild(contentTextarea);
|
typeContainer.appendChild(contentTextarea);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Advanced Settings (ID and Class)
|
// Advanced Settings (ID and Class)
|
||||||
const advancedHeader = document.createElement('div');
|
const advancedHeader = document.createElement('div');
|
||||||
advancedHeader.className = 'font-weight-bold text-muted small mb-2';
|
advancedHeader.className = 'font-weight-bold text-muted small mb-2';
|
||||||
@@ -760,6 +868,31 @@ class FlexTool {
|
|||||||
savedData[`id${i}`] = this.data[`id${i}`] || '';
|
savedData[`id${i}`] = this.data[`id${i}`] || '';
|
||||||
savedData[`attr${i}`] = this.data[`attr${i}`] || '';
|
savedData[`attr${i}`] = this.data[`attr${i}`] || '';
|
||||||
savedData[`width${i}`] = this.data[`width${i}`] || 0;
|
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;
|
return savedData;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ class GridTool {
|
|||||||
this.readOnly = readOnly;
|
this.readOnly = readOnly;
|
||||||
this.data = {
|
this.data = {
|
||||||
cols: parseInt(data.cols) || 2,
|
cols: parseInt(data.cols) || 2,
|
||||||
|
itemAlignment: data.itemAlignment || data.alignment || (data.centerItems ? 'center' : 'left'),
|
||||||
|
globalStyle: data.globalStyle || '',
|
||||||
globalClass: data.globalClass || '',
|
globalClass: data.globalClass || '',
|
||||||
globalId: data.globalId || '',
|
globalId: data.globalId || '',
|
||||||
globalAttributes: data.globalAttributes || data.attributes || ''
|
globalAttributes: data.globalAttributes || data.attributes || ''
|
||||||
@@ -34,6 +36,18 @@ class GridTool {
|
|||||||
this.data[`id${i}`] = data[`id${i}`] || '';
|
this.data[`id${i}`] = data[`id${i}`] || '';
|
||||||
this.data[`attr${i}`] = data[`attr${i}`] || data[`attributes${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[`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;
|
||||||
}
|
}
|
||||||
@@ -73,14 +87,66 @@ class GridTool {
|
|||||||
colDiv.appendChild(colInput);
|
colDiv.appendChild(colInput);
|
||||||
settingsRow.appendChild(colDiv);
|
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');
|
const classDiv = document.createElement('div');
|
||||||
classDiv.className = 'col-md-3 mb-2';
|
classDiv.className = 'col-md-2 mb-2';
|
||||||
classDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Grid CSS Class(es)</label>';
|
classDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Grid CSS Class</label>';
|
||||||
const classInput = document.createElement('input');
|
const classInput = document.createElement('input');
|
||||||
classInput.type = 'text';
|
classInput.type = 'text';
|
||||||
classInput.className = 'form-control form-control-sm';
|
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 || '';
|
classInput.value = this.data.globalClass || '';
|
||||||
if (this.readOnly) classInput.disabled = true;
|
if (this.readOnly) classInput.disabled = true;
|
||||||
classInput.addEventListener('input', (e) => this.data.globalClass = e.target.value.trim());
|
classInput.addEventListener('input', (e) => this.data.globalClass = e.target.value.trim());
|
||||||
@@ -88,14 +154,14 @@ class GridTool {
|
|||||||
classDiv.appendChild(classInput);
|
classDiv.appendChild(classInput);
|
||||||
settingsRow.appendChild(classDiv);
|
settingsRow.appendChild(classDiv);
|
||||||
|
|
||||||
// 3. Global HTML ID
|
// 5. Global HTML ID
|
||||||
const idDiv = document.createElement('div');
|
const idDiv = document.createElement('div');
|
||||||
idDiv.className = 'col-md-3 mb-2';
|
idDiv.className = 'col-md-3 mb-2';
|
||||||
idDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Grid HTML ID</label>';
|
idDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Grid HTML ID</label>';
|
||||||
const idInput = document.createElement('input');
|
const idInput = document.createElement('input');
|
||||||
idInput.type = 'text';
|
idInput.type = 'text';
|
||||||
idInput.className = 'form-control form-control-sm';
|
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 || '';
|
idInput.value = this.data.globalId || '';
|
||||||
if (this.readOnly) idInput.disabled = true;
|
if (this.readOnly) idInput.disabled = true;
|
||||||
idInput.addEventListener('input', (e) => this.data.globalId = e.target.value.trim());
|
idInput.addEventListener('input', (e) => this.data.globalId = e.target.value.trim());
|
||||||
@@ -103,14 +169,14 @@ class GridTool {
|
|||||||
idDiv.appendChild(idInput);
|
idDiv.appendChild(idInput);
|
||||||
settingsRow.appendChild(idDiv);
|
settingsRow.appendChild(idDiv);
|
||||||
|
|
||||||
// 4. Global Custom Attributes
|
// 6. Global Custom Attributes
|
||||||
const attrDiv = document.createElement('div');
|
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>';
|
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');
|
const attrInput = document.createElement('input');
|
||||||
attrInput.type = 'text';
|
attrInput.type = 'text';
|
||||||
attrInput.className = 'form-control form-control-sm';
|
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 || '';
|
attrInput.value = this.data.globalAttributes || '';
|
||||||
if (this.readOnly) attrInput.disabled = true;
|
if (this.readOnly) attrInput.disabled = true;
|
||||||
attrInput.addEventListener('input', (e) => this.data.globalAttributes = e.target.value.trim());
|
attrInput.addEventListener('input', (e) => this.data.globalAttributes = e.target.value.trim());
|
||||||
@@ -133,7 +199,7 @@ class GridTool {
|
|||||||
this.inputsContainer.innerHTML = '';
|
this.inputsContainer.innerHTML = '';
|
||||||
|
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'row';
|
row.className = 'row d-flex flex-wrap';
|
||||||
|
|
||||||
const count = this.data.cols;
|
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')));
|
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);
|
headerRow.appendChild(controlsWrapper);
|
||||||
colDiv.appendChild(headerRow);
|
colDiv.appendChild(headerRow);
|
||||||
colDiv.appendChild(contentDiv);
|
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);
|
row.appendChild(colDiv);
|
||||||
|
|
||||||
this._renderTypeSpecificInput(i, contentDiv);
|
this._renderTypeSpecificInput(i, contentDiv);
|
||||||
|
renderSubInputs();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.inputsContainer.appendChild(row);
|
this.inputsContainer.appendChild(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
_renderTypeSpecificInput(i, container) {
|
_renderTypeSpecificInput(i, container, explicitType) {
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
|
|
||||||
// Remove tracking of previous instance
|
// Remove tracking of previous instance
|
||||||
@@ -253,7 +408,7 @@ class GridTool {
|
|||||||
typeContainer.className = 'mb-3';
|
typeContainer.className = 'mb-3';
|
||||||
container.appendChild(typeContainer);
|
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
|
// Check if type is a dynamic editor plugin registered in window.SISEditorPlugins
|
||||||
if (window.SISEditorPlugins && window.SISEditorPlugins[type]) {
|
if (window.SISEditorPlugins && window.SISEditorPlugins[type]) {
|
||||||
@@ -307,11 +462,13 @@ class GridTool {
|
|||||||
controls.style.gap = '8px';
|
controls.style.gap = '8px';
|
||||||
|
|
||||||
controls.innerHTML = `
|
controls.innerHTML = `
|
||||||
<div class="text-muted small text-center mb-1"><i class="fas fa-cog"></i> Image Controls</div>
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||||
<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>
|
<span class="text-muted small font-weight-bold"><i class="fas fa-image"></i> Image Settings</span>
|
||||||
<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-link btn-xs text-danger p-0" id="btn-clear-img-${i}" title="Remove Image"><i class="fas fa-trash-alt"></i></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>
|
</div>
|
||||||
<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>
|
<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}`);
|
const uploadBtn = controls.querySelector(`#btn-upload-img-${i}`);
|
||||||
@@ -346,16 +503,23 @@ class GridTool {
|
|||||||
mediaBtn.addEventListener('click', (e) => {
|
mediaBtn.addEventListener('click', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (typeof openMediaPickerModal === 'function') {
|
if (window.SISMediaPicker) {
|
||||||
openMediaPickerModal((url) => {
|
SISMediaPicker.open((url, mediaObj, config) => {
|
||||||
if (url) {
|
if (url) {
|
||||||
this.data[`col${i}`] = {
|
this.data[`col${i}`] = {
|
||||||
file: { url: url },
|
file: { url: url },
|
||||||
caption: '',
|
caption: (config && config.alt) || '',
|
||||||
withBorder: false,
|
withBorder: false,
|
||||||
withBackground: false,
|
withBackground: false,
|
||||||
stretched: 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);
|
this._renderTypeSpecificInput(i, container);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -440,10 +604,17 @@ class GridTool {
|
|||||||
chooseBtn.innerText = 'Choose';
|
chooseBtn.innerText = 'Choose';
|
||||||
if (this.readOnly) chooseBtn.disabled = true;
|
if (this.readOnly) chooseBtn.disabled = true;
|
||||||
chooseBtn.addEventListener('click', () => {
|
chooseBtn.addEventListener('click', () => {
|
||||||
if (typeof openMediaPickerModal === 'function') {
|
if (window.SISMediaPicker) {
|
||||||
openMediaPickerModal((selectedUrl) => {
|
SISMediaPicker.open((selectedUrl, mediaObj, config) => {
|
||||||
input.value = selectedUrl;
|
input.value = selectedUrl;
|
||||||
this.data[`imgUrl${i}`] = 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;
|
previewImg.src = selectedUrl;
|
||||||
previewBox.style.display = 'block';
|
previewBox.style.display = 'block';
|
||||||
});
|
});
|
||||||
@@ -577,6 +748,8 @@ class GridTool {
|
|||||||
typeContainer.appendChild(contentTextarea);
|
typeContainer.appendChild(contentTextarea);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Advanced Settings (ID and Class)
|
// Advanced Settings (ID and Class)
|
||||||
const advancedHeader = document.createElement('div');
|
const advancedHeader = document.createElement('div');
|
||||||
advancedHeader.className = 'font-weight-bold text-muted small mb-2';
|
advancedHeader.className = 'font-weight-bold text-muted small mb-2';
|
||||||
@@ -623,6 +796,8 @@ class GridTool {
|
|||||||
save(blockContent) {
|
save(blockContent) {
|
||||||
const savedData = {
|
const savedData = {
|
||||||
cols: this.data.cols,
|
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 || ''),
|
globalClass: this.globalClassInput ? this.globalClassInput.value.trim() : (this.data.globalClass || ''),
|
||||||
globalId: this.globalIdInput ? this.globalIdInput.value.trim() : (this.data.globalId || ''),
|
globalId: this.globalIdInput ? this.globalIdInput.value.trim() : (this.data.globalId || ''),
|
||||||
globalAttributes: this.globalAttrInput ? this.globalAttrInput.value.trim() : (this.data.globalAttributes || '')
|
globalAttributes: this.globalAttrInput ? this.globalAttrInput.value.trim() : (this.data.globalAttributes || '')
|
||||||
@@ -652,6 +827,31 @@ class GridTool {
|
|||||||
savedData[`id${i}`] = this.data[`id${i}`] || '';
|
savedData[`id${i}`] = this.data[`id${i}`] || '';
|
||||||
savedData[`attr${i}`] = this.data[`attr${i}`] || '';
|
savedData[`attr${i}`] = this.data[`attr${i}`] || '';
|
||||||
savedData[`width${i}`] = this.data[`width${i}`] || 0;
|
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;
|
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.className = 'btn btn-outline-info';
|
||||||
mediaBtn.innerHTML = '<i class="fas fa-images"></i> Media Library';
|
mediaBtn.innerHTML = '<i class="fas fa-images"></i> Media Library';
|
||||||
mediaBtn.addEventListener('click', () => {
|
mediaBtn.addEventListener('click', () => {
|
||||||
if (typeof window.openSISMediaModal === 'function') {
|
if (window.SISMediaPicker) {
|
||||||
window.openSISMediaModal((selectedUrl) => {
|
SISMediaPicker.open((selectedUrl, mediaObj, config) => {
|
||||||
imgInput.value = selectedUrl;
|
imgInput.value = selectedUrl;
|
||||||
item.imageUrl = 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);
|
updatePreview(selectedUrl);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -380,10 +387,17 @@ class SISHoverCardTool {
|
|||||||
mediaBtn.className = 'btn btn-outline-info';
|
mediaBtn.className = 'btn btn-outline-info';
|
||||||
mediaBtn.innerHTML = '<i class="fas fa-images"></i> Media Library';
|
mediaBtn.innerHTML = '<i class="fas fa-images"></i> Media Library';
|
||||||
mediaBtn.addEventListener('click', () => {
|
mediaBtn.addEventListener('click', () => {
|
||||||
if (typeof window.openSISMediaModal === 'function') {
|
if (window.SISMediaPicker) {
|
||||||
window.openSISMediaModal((selectedUrl) => {
|
SISMediaPicker.open((selectedUrl, mediaObj, config) => {
|
||||||
hoverImgInput.value = selectedUrl;
|
hoverImgInput.value = selectedUrl;
|
||||||
item.hoverImageUrl = 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);
|
updateHoverPreview(selectedUrl);
|
||||||
});
|
});
|
||||||
} else {
|
} 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';
|
fileInput.style.display = 'none';
|
||||||
|
|
||||||
chooseBtn.addEventListener('click', () => {
|
chooseBtn.addEventListener('click', () => {
|
||||||
openMediaPickerModal((url) => {
|
if (window.SISMediaPicker) {
|
||||||
|
SISMediaPicker.open((url, mediaObj, config) => {
|
||||||
imageInput.value = url;
|
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();
|
updatePreview();
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
alert('SISMediaPicker is not loaded.');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
uploadBtn.addEventListener('click', () => {
|
uploadBtn.addEventListener('click', () => {
|
||||||
@@ -263,8 +300,17 @@ class SISTimelineTool {
|
|||||||
const img = box.querySelector('.ce-timeline-input-image').value.trim();
|
const img = box.querySelector('.ce-timeline-input-image').value.trim();
|
||||||
const attrEl = box.querySelector('.ce-timeline-input-attr');
|
const attrEl = box.querySelector('.ce-timeline-input-attr');
|
||||||
const attr = attrEl ? attrEl.value.trim() : '';
|
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) {
|
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');
|
const globalAttrEl = this.wrapper.querySelector('.ce-timeline-input-global-attr');
|
||||||
@@ -281,111 +327,12 @@ window.SISEditorPlugins['timeline'] = {
|
|||||||
class: SISTimelineTool
|
class: SISTimelineTool
|
||||||
};
|
};
|
||||||
|
|
||||||
// Global Media Picker Modal logic
|
// openMediaPickerModal — legacy stub, replaced by SISMediaPicker
|
||||||
function openMediaPickerModal(onSelectCallback) {
|
function openMediaPickerModal(onSelectCallback) {
|
||||||
let modalEl = document.getElementById('globalMediaPickerModal');
|
if (window.SISMediaPicker) {
|
||||||
if (!modalEl) {
|
SISMediaPicker.open(function(url) { onSelectCallback(url); });
|
||||||
// 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');
|
|
||||||
} else {
|
} else {
|
||||||
alert("Bootstrap/jQuery not loaded!");
|
var url = prompt('Enter image URL:');
|
||||||
return;
|
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
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -2,22 +2,55 @@
|
|||||||
* SIS Media Picker & Library Modal Utility
|
* SIS Media Picker & Library Modal Utility
|
||||||
* Standalone reusable module to select and upload media files anywhere in the application.
|
* Standalone reusable module to select and upload media files anywhere in the application.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Features:
|
||||||
* window.openSISMediaModal(function(selectedUrl, selectedMediaObj) {
|
* - Server-side paginated media list (default 24 per page)
|
||||||
* console.log("Selected Image URL:", selectedUrl);
|
* - 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)
|
||||||
*
|
*
|
||||||
* Or via object API:
|
* Usage:
|
||||||
* SISMediaPicker.open(function(selectedUrl, selectedMediaObj) { ... });
|
* SISMediaPicker.open(function(url, mediaObj, config) { ... });
|
||||||
|
* window.openSISMediaModal(callback); // legacy compat
|
||||||
*/
|
*/
|
||||||
(function (window, document) {
|
(function (window, document) {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
var SISMediaPicker = {
|
var PAGE_SIZE = 24;
|
||||||
callback: null,
|
|
||||||
mediaData: [],
|
|
||||||
modalId: 'sisMediaLibraryModal',
|
|
||||||
|
|
||||||
|
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 () {
|
initModal: function () {
|
||||||
var modalEl = document.getElementById(this.modalId);
|
var modalEl = document.getElementById(this.modalId);
|
||||||
if (modalEl) return modalEl;
|
if (modalEl) return modalEl;
|
||||||
@@ -31,6 +64,8 @@
|
|||||||
modalEl.innerHTML =
|
modalEl.innerHTML =
|
||||||
'<div class="modal-dialog modal-xl" role="document">' +
|
'<div class="modal-dialog modal-xl" role="document">' +
|
||||||
'<div class="modal-content border-0 shadow-lg">' +
|
'<div class="modal-content border-0 shadow-lg">' +
|
||||||
|
|
||||||
|
/* ── Header ─── */
|
||||||
'<div class="modal-header bg-primary text-white p-3">' +
|
'<div class="modal-header bg-primary text-white p-3">' +
|
||||||
'<h5 class="modal-title font-weight-bold" id="' + this.modalId + 'Label">' +
|
'<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' +
|
'<i class="fas fa-images mr-2"></i> Thư viện Media / Chọn Hình ảnh' +
|
||||||
@@ -39,23 +74,127 @@
|
|||||||
'<span aria-hidden="true">×</span>' +
|
'<span aria-hidden="true">×</span>' +
|
||||||
'</button>' +
|
'</button>' +
|
||||||
'</div>' +
|
'</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="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 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>' +
|
'<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>' +
|
||||||
'<div>' +
|
'<div class="d-flex align-items-center">' +
|
||||||
'<label class="btn btn-sm btn-success mb-0 cursor-pointer shadow-sm" style="cursor: pointer;">' +
|
'<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' +
|
'<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;" />' +
|
'<input type="file" id="sisMediaUploadInput" accept="image/*" style="display: none;" />' +
|
||||||
'</label>' +
|
'</label>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</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>' +
|
'</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">' +
|
'<div class="modal-footer bg-white p-2">' +
|
||||||
'<button type="button" class="btn btn-sm btn-secondary px-3" data-dismiss="modal">Đóng</button>' +
|
'<button type="button" class="btn btn-sm btn-secondary px-3" data-dismiss="modal">Đóng</button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
@@ -64,56 +203,83 @@
|
|||||||
|
|
||||||
document.body.appendChild(modalEl);
|
document.body.appendChild(modalEl);
|
||||||
|
|
||||||
// Bind Search Input Handler
|
// Build quick-size buttons
|
||||||
var self = this;
|
var sizeContainer = document.getElementById('sisMediaSizeButtons');
|
||||||
document.addEventListener('input', function (e) {
|
if (sizeContainer) {
|
||||||
if (e.target && e.target.id === 'sisMediaSearchInput') {
|
PREDEFINED_SIZES.forEach(function (size) {
|
||||||
var term = e.target.value.toLowerCase().trim();
|
var btn = document.createElement('button');
|
||||||
var filtered = self.mediaData.filter(function (item) {
|
btn.type = 'button';
|
||||||
var name = (item.originalFilename || item.name || '').toLowerCase();
|
btn.className = 'btn btn-xs btn-outline-secondary mb-1';
|
||||||
return name.includes(term);
|
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');
|
||||||
});
|
});
|
||||||
self.renderGrid(filtered);
|
btn.classList.replace('btn-outline-secondary', 'btn-secondary');
|
||||||
|
});
|
||||||
|
sizeContainer.appendChild(btn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Event delegation (attached once on modal, never on document) ── */
|
||||||
|
var self = this;
|
||||||
|
|
||||||
|
// Debounced search
|
||||||
|
modalEl.addEventListener('input', function (e) {
|
||||||
|
if (e.target && e.target.id === 'sisMediaSearchInput') {
|
||||||
|
clearTimeout(self.searchTimer);
|
||||||
|
self.searchTimer = setTimeout(function () {
|
||||||
|
var kw = e.target.value.trim();
|
||||||
|
if (kw !== self.currentKeyword) {
|
||||||
|
self.currentKeyword = kw;
|
||||||
|
self._resetAndFetch();
|
||||||
|
}
|
||||||
|
}, 350);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Bind Upload Input Handler
|
// Upload
|
||||||
document.addEventListener('change', function (e) {
|
modalEl.addEventListener('change', function (e) {
|
||||||
if (e.target && e.target.id === 'sisMediaUploadInput') {
|
if (e.target && e.target.id === 'sisMediaUploadInput') {
|
||||||
var file = e.target.files[0];
|
var file = e.target.files && e.target.files[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
self._doUpload(file);
|
||||||
|
// reset so same file can be uploaded again
|
||||||
|
e.target.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
var formData = new FormData();
|
// Button clicks
|
||||||
formData.append('file', file);
|
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');
|
var grid = document.getElementById('sisMediaGrid');
|
||||||
if (grid) {
|
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>';
|
grid.addEventListener('scroll', function () {
|
||||||
}
|
// trigger when within 80 px of bottom
|
||||||
|
if (grid.scrollHeight - grid.scrollTop - grid.clientHeight < 80) {
|
||||||
fetch('/api/manage/media/upload', {
|
self._maybeLoadMore();
|
||||||
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();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(function (err) {
|
|
||||||
alert('Lỗi tải tệp: ' + err.message);
|
|
||||||
self.fetchMediaList();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return modalEl;
|
return modalEl;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/* ── open ────────────────────────────────────────────── */
|
||||||
open: function (callback) {
|
open: function (callback) {
|
||||||
this.callback = callback;
|
this.callback = callback;
|
||||||
var modalEl = this.initModal();
|
var modalEl = this.initModal();
|
||||||
@@ -124,66 +290,301 @@
|
|||||||
modalEl.style.display = 'block';
|
modalEl.style.display = 'block';
|
||||||
modalEl.classList.add('show');
|
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');
|
var grid = document.getElementById('sisMediaGrid');
|
||||||
if (grid) {
|
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;
|
var self = this;
|
||||||
fetch('/api/manage/media/list')
|
var url = '/api/manage/media/list?page=' + page + '&size=' + PAGE_SIZE;
|
||||||
.then(function (res) { return res.json(); })
|
if (this.currentKeyword) {
|
||||||
.then(function (data) {
|
url += '&keyword=' + encodeURIComponent(this.currentKeyword);
|
||||||
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>';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
})
|
||||||
|
.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');
|
var grid = document.getElementById('sisMediaGrid');
|
||||||
if (!grid) return;
|
if (!grid) return;
|
||||||
|
|
||||||
|
if (clear) grid.innerHTML = '';
|
||||||
|
|
||||||
if (!list || list.length === 0) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
var self = this;
|
||||||
grid.innerHTML = list.map(function (item) {
|
list.forEach(function (item) {
|
||||||
var url = item.fileUrl || item.url || '';
|
var url = item.fileUrl || item.url || '';
|
||||||
var name = item.originalFilename || item.name || 'Image';
|
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 + '">' +
|
var col = document.createElement('div');
|
||||||
'<div style="height: 110px; background: #f8f9fc; display: flex; align-items: center; justify-content: center; overflow: hidden;" class="p-1">' +
|
col.className = 'col-lg-2 col-md-3 col-sm-4 col-6 mb-3';
|
||||||
'<img src="' + url + '" style="max-height: 100%; max-width: 100%; object-fit: contain;" alt="' + name + '" />' +
|
|
||||||
|
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>' +
|
||||||
'<div class="card-body p-2 text-center bg-white border-top">' +
|
'<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>' +
|
'<p class="card-text small text-truncate m-0 font-weight-bold text-dark" title="' + self._esc(name) + '">' + self._esc(name) + '</p>' +
|
||||||
'</div>' +
|
|
||||||
'</div>' +
|
|
||||||
'</div>';
|
'</div>';
|
||||||
}).join('');
|
|
||||||
|
|
||||||
// Add click listeners to items
|
|
||||||
grid.querySelectorAll('.media-select-card').forEach(function (card) {
|
|
||||||
card.addEventListener('click', function () {
|
card.addEventListener('click', function () {
|
||||||
var selectedUrl = card.getAttribute('data-url');
|
grid.querySelectorAll('.media-select-card').forEach(function (c) {
|
||||||
self.selectItem(selectedUrl);
|
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, '&')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
},
|
||||||
|
|
||||||
|
_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) {
|
selectItem: function (url, mediaObj) {
|
||||||
if (typeof this.callback === 'function') {
|
if (typeof this.callback === 'function') {
|
||||||
this.callback(url, mediaObj);
|
this.callback(url, mediaObj, {});
|
||||||
}
|
}
|
||||||
var modalEl = document.getElementById(this.modalId);
|
var modalEl = document.getElementById(this.modalId);
|
||||||
if (modalEl) {
|
if (modalEl) {
|
||||||
@@ -197,12 +598,7 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Expose standalone module globally
|
|
||||||
window.SISMediaPicker = SISMediaPicker;
|
window.SISMediaPicker = SISMediaPicker;
|
||||||
|
window.openSISMediaModal = function (cb) { SISMediaPicker.open(cb); };
|
||||||
// Backward-compatible global helper function
|
|
||||||
window.openSISMediaModal = function (callback) {
|
|
||||||
SISMediaPicker.open(callback);
|
|
||||||
};
|
|
||||||
|
|
||||||
})(window, document);
|
})(window, document);
|
||||||
|
|||||||
@@ -75,9 +75,9 @@
|
|||||||
<option value="">-- None (Top Level) --</option>
|
<option value="">-- None (Top Level) --</option>
|
||||||
<th:block th:each="topOpt : ${menu.items}" th:if="${topOpt.parent == null}">
|
<th:block th:each="topOpt : ${menu.items}" th:if="${topOpt.parent == null}">
|
||||||
<option th:value="${topOpt.id}" th:text="${topOpt.label}"></option>
|
<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>
|
<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>
|
<option th:value="${grandChildOpt.id}" th:text="'---- ' + ${grandChildOpt.label}"></option>
|
||||||
</th:block>
|
</th:block>
|
||||||
</th:block>
|
</th:block>
|
||||||
@@ -108,15 +108,22 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="badge badge-primary badge-pill mr-2" th:text="'Order: ' + ${topItem.displayOrder}">0</span>
|
<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>
|
<button type="button" class="btn btn-sm btn-info edit-item-btn"
|
||||||
<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?');">
|
data-toggle="modal" data-target="#editItemModal"
|
||||||
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
th:data-id="${topItem.id}"
|
||||||
</form>
|
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>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<!-- Children Items -->
|
<!-- 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;">
|
<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>
|
<div>
|
||||||
<strong th:text="${child.label}">Link Text</strong>
|
<strong th:text="${child.label}">Link Text</strong>
|
||||||
@@ -126,14 +133,21 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="badge badge-primary badge-pill mr-2" th:text="'Order: ' + ${child.displayOrder}">0</span>
|
<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>
|
<button type="button" class="btn btn-sm btn-info edit-item-btn"
|
||||||
<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?');">
|
data-toggle="modal" data-target="#editItemModal"
|
||||||
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
th:data-id="${child.id}"
|
||||||
</form>
|
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>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<!-- Grandchildren Items -->
|
<!-- 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;">
|
<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>
|
<div>
|
||||||
<strong th:text="${grandchild.label}">Link Text</strong>
|
<strong th:text="${grandchild.label}">Link Text</strong>
|
||||||
@@ -143,10 +157,17 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="badge badge-primary badge-pill mr-2" th:text="'Order: ' + ${grandchild.displayOrder}">0</span>
|
<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>
|
<button type="button" class="btn btn-sm btn-info edit-item-btn"
|
||||||
<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?');">
|
data-toggle="modal" data-target="#editItemModal"
|
||||||
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
th:data-id="${grandchild.id}"
|
||||||
</form>
|
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>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</th:block>
|
</th:block>
|
||||||
@@ -160,13 +181,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Edit Modals -->
|
<!-- Single Dynamic Edit Modal -->
|
||||||
<th:block th:each="item : ${menu.items}">
|
<div class="modal fade" id="editItemModal" tabindex="-1" role="dialog" aria-hidden="true">
|
||||||
<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-dialog" role="document">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<form th:action="@{/manage/menus/{menuId}/items/{itemId}/update(menuId=${menu.id}, itemId=${item.id})}" method="post">
|
<form id="editItemForm" method="post" th:action="@{/manage/menus/{menuId}/items/0/update(menuId=${menu.id})}">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title">Edit Menu Item</h5>
|
<h5 class="modal-title">Edit Menu Item</h5>
|
||||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||||
@@ -176,30 +197,30 @@
|
|||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Link Text</label>
|
<label>Link Text</label>
|
||||||
<input type="text" class="form-control" name="label" th:value="${item.label}" required>
|
<input type="text" class="form-control" id="modalLabel" name="label" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>URL</label>
|
<label>URL</label>
|
||||||
<input type="text" class="form-control" name="url" th:value="${item.url}" required>
|
<input type="text" class="form-control" id="modalUrl" name="url" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Title (Tùy chọn)</label>
|
<label>Title (Tùy chọn)</label>
|
||||||
<input type="text" class="form-control" name="title" th:value="${item.title}">
|
<input type="text" class="form-control" id="modalTitle" name="title">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Image URL (Tùy chọn)</label>
|
<label>Image URL (Tùy chọn)</label>
|
||||||
<input type="text" class="form-control" name="imageUrl" th:value="${item.imageUrl}">
|
<input type="text" class="form-control" id="modalImageUrl" name="imageUrl">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Parent Item (Optional)</label>
|
<label>Parent Item (Optional)</label>
|
||||||
<select class="form-control" name="parentId">
|
<select class="form-control" id="modalParentId" name="parentId">
|
||||||
<option value="">-- None (Top Level) --</option>
|
<option value="">-- None (Top Level) --</option>
|
||||||
<th:block th:each="topOpt : ${menu.items}" th:if="${topOpt.parent == null and topOpt.id != item.id}">
|
<th:block th:each="topOpt : ${menu.items}" th:if="${topOpt.parent == null}">
|
||||||
<option th:value="${topOpt.id}" th:text="${topOpt.label}" th:selected="${item.parent != null and item.parent.id == topOpt.id}"></option>
|
<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 and childOpt.id != item.id}">
|
<th:block th:each="childOpt : ${topOpt.children}">
|
||||||
<option th:value="${childOpt.id}" th:text="'-- ' + ${childOpt.label}" th:selected="${item.parent != null and item.parent.id == childOpt.id}"></option>
|
<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 and grandChildOpt.id != item.id}">
|
<th:block th:each="grandChildOpt : ${childOpt.children}">
|
||||||
<option th:value="${grandChildOpt.id}" th:text="'---- ' + ${grandChildOpt.label}" th:selected="${item.parent != null and item.parent.id == grandChildOpt.id}"></option>
|
<option th:value="${grandChildOpt.id}" th:text="'---- ' + ${grandChildOpt.label}"></option>
|
||||||
</th:block>
|
</th:block>
|
||||||
</th:block>
|
</th:block>
|
||||||
</th:block>
|
</th:block>
|
||||||
@@ -207,7 +228,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Display Order</label>
|
<label>Display Order</label>
|
||||||
<input type="number" class="form-control" name="displayOrder" th:value="${item.displayOrder}">
|
<input type="number" class="form-control" id="modalDisplayOrder" name="displayOrder">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
@@ -218,9 +239,76 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</th:block>
|
|
||||||
|
|
||||||
|
<!-- 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">×</span>
|
||||||
|
</button>
|
||||||
</div>
|
</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>
|
||||||
|
</div>
|
||||||
|
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -442,6 +442,9 @@
|
|||||||
<script th:src="@{/js/manage/editor-plugins/hover-card.js}"></script>
|
<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/tag.js}"></script>
|
||||||
<script th:src="@{/js/manage/editor-plugins/nav.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 -->
|
<!-- SIS Standalone Media Picker Library -->
|
||||||
<script th:src="@{/js/manage/sis-media-picker.js}"></script>
|
<script th:src="@{/js/manage/sis-media-picker.js}"></script>
|
||||||
|
|||||||
@@ -38,33 +38,35 @@
|
|||||||
>
|
>
|
||||||
<!-- 1. HTML Snippet Block (Custom) -->
|
<!-- 1. HTML Snippet Block (Custom) -->
|
||||||
<th:block th:if="${block['type'] == 'snippet'}">
|
<th:block th:if="${block['type'] == 'snippet'}">
|
||||||
|
<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:utext="${block.data['htmlContent']}" th:remove="tag"></div>
|
||||||
|
</div>
|
||||||
</th:block>
|
</th:block>
|
||||||
|
|
||||||
<!-- 2. Header Block -->
|
<!-- 2. Header Block -->
|
||||||
<th:block th:if="${block['type'] == 'header'}">
|
<th:block th:if="${block['type'] == 'header'}">
|
||||||
<th:block th:switch="${block.data['level']}">
|
<th:block th:switch="${block.data['level']}">
|
||||||
<h1 th:case="1" th:utext="${block.data['text']}"></h1>
|
<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']}"></h2>
|
<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']}"></h3>
|
<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']}"></h4>
|
<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']}"></h5>
|
<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']}"></h6>
|
<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']}"></h2>
|
<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>
|
||||||
</th:block>
|
</th:block>
|
||||||
|
|
||||||
<!-- 3. Paragraph Block -->
|
<!-- 3. Paragraph Block -->
|
||||||
<th:block th:if="${block['type'] == 'paragraph'}">
|
<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>
|
</th:block>
|
||||||
|
|
||||||
<!-- 4. List Block -->
|
<!-- 4. List Block -->
|
||||||
<th:block th:if="${block['type'] == 'list'}">
|
<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>
|
<li th:each="item : ${block.data['items']}" th:utext="${item}"></li>
|
||||||
</ul>
|
</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>
|
<li th:each="item : ${block.data['items']}" th:utext="${item}"></li>
|
||||||
</ol>
|
</ol>
|
||||||
</th:block>
|
</th:block>
|
||||||
@@ -77,7 +79,7 @@
|
|||||||
|
|
||||||
<!-- 6. Quote Block -->
|
<!-- 6. Quote Block -->
|
||||||
<th:block th:if="${block['type'] == 'quote'}">
|
<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>
|
<p th:utext="${block.data['text']}"></p>
|
||||||
<footer th:if="${block.data['caption']}" th:utext="${block.data['caption']}"></footer>
|
<footer th:if="${block.data['caption']}" th:utext="${block.data['caption']}"></footer>
|
||||||
</blockquote>
|
</blockquote>
|
||||||
@@ -90,7 +92,7 @@
|
|||||||
|
|
||||||
<!-- 8. Table Block -->
|
<!-- 8. Table Block -->
|
||||||
<th:block th:if="${block['type'] == 'table'}">
|
<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>
|
<tbody>
|
||||||
<tr th:each="row, rowStat : ${block.data['content']}">
|
<tr th:each="row, rowStat : ${block.data['content']}">
|
||||||
<th:block th:if="${block.data['withHeadings'] == true and rowStat.index == 0}">
|
<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>
|
<div th:utext="${block.data['html']}" th:remove="tag"></div>
|
||||||
</th:block>
|
</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 -->
|
<!-- 10. Accordion Block -->
|
||||||
<th:block th:if="${block['type'] == 'accordion'}">
|
<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">
|
<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>
|
<span th:utext="${block.data['title']}">Accordion Title</span>
|
||||||
</summary>
|
</summary>
|
||||||
@@ -123,7 +130,7 @@
|
|||||||
|
|
||||||
<!-- 11. YouTube Video Block -->
|
<!-- 11. YouTube Video Block -->
|
||||||
<th:block th:if="${block['type'] == 'youtube'}">
|
<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
|
<div
|
||||||
class="sis-yt-thumb-wrapper"
|
class="sis-yt-thumb-wrapper"
|
||||||
th:data-embed-url="${block.data['url']}"
|
th:data-embed-url="${block.data['url']}"
|
||||||
@@ -169,6 +176,8 @@
|
|||||||
<th:block th:if="${block['type'] == 'hero'}">
|
<th:block th:if="${block['type'] == 'hero'}">
|
||||||
<div
|
<div
|
||||||
class="sis-hero-banner text-white py-5 px-3 rounded shadow-sm text-center"
|
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="
|
style="
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -179,7 +188,8 @@
|
|||||||
background-position: center;
|
background-position: center;
|
||||||
background-color: #002554;
|
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 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">
|
<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) -->
|
<!-- 13. Sticky Nav / Sub-menu Block (UMass Theme mc--info-menu style) -->
|
||||||
<th:block th:if="${block['type'] == 'stickyNav' or block['type'] == 'nav'}">
|
<th:block th:if="${block['type'] == 'stickyNav' or block['type'] == 'nav'}">
|
||||||
<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"
|
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: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">
|
<ul class="menu m--menu m--info-menu">
|
||||||
<li class="menu-item menu-item--expanded">
|
<li class="menu-item menu-item--expanded">
|
||||||
@@ -268,7 +280,7 @@
|
|||||||
|
|
||||||
<!-- 14. Timeline Block (Group) -->
|
<!-- 14. Timeline Block (Group) -->
|
||||||
<th:block th:if="${block['type'] == 'timeline' and block.data['items'] != null}">
|
<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">
|
<div class="sis-timeline__grid">
|
||||||
<!-- Text Column (Left) -->
|
<!-- Text Column (Left) -->
|
||||||
<div class="sis-timeline__text-col">
|
<div class="sis-timeline__text-col">
|
||||||
@@ -350,15 +362,18 @@
|
|||||||
|
|
||||||
<!-- 15. Grid / Columns Block -->
|
<!-- 15. Grid / Columns Block -->
|
||||||
<th:block th:if="${block['type'] == 'grid'}">
|
<th:block th:if="${block['type'] == 'grid'}">
|
||||||
<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['globalClass'] != null and !#strings.isEmpty(block.data['globalClass']) ? block.data['globalClass'] : null}"
|
th:class="${block.data['globalClass'] != null and !#strings.isEmpty(block.data['globalClass']) ? 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.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="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'])}">
|
<th:block th:each="i : ${#numbers.sequence(1, block.data['cols'])}">
|
||||||
<div
|
<div
|
||||||
th:id="${block.data['id' + i] != null and !#strings.isEmpty(block.data['id' + i]) ? block.data['id' + i] : null}"
|
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: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}"
|
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 -->
|
<!-- 16. Flex Layout Block -->
|
||||||
<th:block th:if="${block['type'] == 'flex'}">
|
<th:block th:if="${block['type'] == 'flex'}">
|
||||||
<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['globalClass'] != null and !#strings.isEmpty(block.data['globalClass']) ? block.data['globalClass'] : null}"
|
th:class="${block.data['globalClass'] != null and !#strings.isEmpty(block.data['globalClass']) ? 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="container py-4">
|
||||||
<div
|
<div
|
||||||
style="display: flex; flex-wrap: wrap;"
|
style="display: flex; flex-wrap: wrap;"
|
||||||
@@ -397,9 +415,12 @@
|
|||||||
|
|
||||||
<!-- 17. Dedicated Hover Cards Block -->
|
<!-- 17. Dedicated Hover Cards Block -->
|
||||||
<th:block th:if="${block['type'] == 'hovercard' or block['type'] == 'hover-card' or block['type'] == 'hoverCard'}">
|
<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: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="container py-4">
|
||||||
<div class="row align-items-center">
|
<div class="row align-items-center">
|
||||||
<th:block th:each="item, iterStat : ${block.data['items']}">
|
<th:block th:each="item, iterStat : ${block.data['items']}">
|
||||||
@@ -433,16 +454,28 @@
|
|||||||
<!-- Type 1: HTML / Text -->
|
<!-- Type 1: HTML / Text -->
|
||||||
<th:block th:if="${type == null or type == 'html'}" th:utext="${blockData != null ? blockData['col' + i] : ''}"></th:block>
|
<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 -->
|
<!-- Type 2: Image -->
|
||||||
<th:block th:if="${type == 'image'}">
|
<th:block th:if="${type == 'image'}">
|
||||||
<div class="sis-grid-image-wrapper text-center">
|
<div class="sis-grid-image-wrapper text-center">
|
||||||
<!-- Native Image Plugin structure -->
|
<!-- 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}">
|
<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>
|
</th:block>
|
||||||
<!-- Custom imgUrl fallback -->
|
<!-- Custom imgUrl fallback -->
|
||||||
<th:block th:unless="${blockData != null and blockData['col' + i] != null and blockData['col' + i] != '' and blockData['col' + i]['file'] != null}">
|
<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>
|
</th:block>
|
||||||
</div>
|
</div>
|
||||||
</th:block>
|
</th:block>
|
||||||
@@ -590,6 +623,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</th:block>
|
</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>
|
</th:block>
|
||||||
<style>
|
<style>
|
||||||
html {
|
html {
|
||||||
|
|||||||
@@ -49,11 +49,9 @@
|
|||||||
</style>
|
</style>
|
||||||
<div class="image-video-container has-video">
|
<div class="image-video-container has-video">
|
||||||
<img
|
<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."
|
alt="Students collaborate using a driving simulator in the UMass Center for Transportation."
|
||||||
srcset="
|
srcset="/uploads/2026/07/dotquy201.webp?h=9855f42d&itok=d27r8GaT 1920w"
|
||||||
https://www.umass.edu/sites/default/files/styles/1_1_1920x1920/public/2025-09/250616_UMASS_4515.jpg?h=9855f42d&itok=d27r8GaT 1920w
|
|
||||||
"
|
|
||||||
/>
|
/>
|
||||||
<div class="f--ambient-video" id="hero-ambient-video">
|
<div class="f--ambient-video" id="hero-ambient-video">
|
||||||
<video
|
<video
|
||||||
|
|||||||
@@ -48,11 +48,9 @@
|
|||||||
</style>
|
</style>
|
||||||
<div class="image-video-container has-video">
|
<div class="image-video-container has-video">
|
||||||
<img
|
<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."
|
alt="Students collaborate using a driving simulator in the UMass Center for Transportation."
|
||||||
srcset="
|
srcset="/uploads/2026/07/dotquy201.webp?h=9855f42d&itok=d27r8GaT 1920w"
|
||||||
https://www.umass.edu/sites/default/files/styles/1_1_1920x1920/public/2025-09/250616_UMASS_4515.jpg?h=9855f42d&itok=d27r8GaT 1920w
|
|
||||||
"
|
|
||||||
/>
|
/>
|
||||||
<div class="f--ambient-video" id="hero-ambient-video">
|
<div class="f--ambient-video" id="hero-ambient-video">
|
||||||
<video
|
<video
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 570 KiB |