From 60b7543f757e866ddca81d66009b04cfd3a16722 Mon Sep 17 00:00:00 2001 From: caoquyenis <58635793+caoquyenis@users.noreply.github.com> Date: Tue, 10 Feb 2026 12:05:49 +0700 Subject: [PATCH 01/10] feat: add layout and styling for application components - Introduced new SCSS files for layout structure including core, footer, main, menu, topbar, and typography. - Added responsive design rules for various screen sizes. - Implemented preloader styles for loading states. - Created utility classes for common styles and a clearfix mixin. - Defined global CSS variables for theming and styling consistency. - Integrated demo styles and code highlighting styles. - Added flag styles and responsive images for country flags. - Established a base layout structure with a sidebar and topbar for navigation. --- src/assets | 1 - src/assets/demo/code.scss | 17 +++ src/assets/demo/demo.scss | 2 + src/assets/demo/flags/flags.scss | 1 + src/assets/demo/flags/flags_responsive.png | Bin 0 -> 55194 bytes src/assets/layout/_core.scss | 24 ++++ src/assets/layout/_footer.scss | 8 ++ src/assets/layout/_main.scss | 17 +++ src/assets/layout/_menu.scss | 160 +++++++++++++++++++++ src/assets/layout/_mixins.scss | 15 ++ src/assets/layout/_preloading.scss | 47 ++++++ src/assets/layout/_responsive.scss | 110 ++++++++++++++ src/assets/layout/_topbar.scss | 160 +++++++++++++++++++++ src/assets/layout/_typography.scss | 68 +++++++++ src/assets/layout/_utils.scss | 25 ++++ src/assets/layout/layout.scss | 13 ++ src/assets/layout/variables/_common.scss | 20 +++ src/assets/layout/variables/_dark.scss | 5 + src/assets/layout/variables/_light.scss | 5 + src/assets/styles.scss | 5 + src/assets/tailwind.css | 11 ++ 21 files changed, 713 insertions(+), 1 deletion(-) delete mode 160000 src/assets create mode 100644 src/assets/demo/code.scss create mode 100644 src/assets/demo/demo.scss create mode 100755 src/assets/demo/flags/flags.scss create mode 100755 src/assets/demo/flags/flags_responsive.png create mode 100644 src/assets/layout/_core.scss create mode 100644 src/assets/layout/_footer.scss create mode 100644 src/assets/layout/_main.scss create mode 100644 src/assets/layout/_menu.scss create mode 100644 src/assets/layout/_mixins.scss create mode 100644 src/assets/layout/_preloading.scss create mode 100644 src/assets/layout/_responsive.scss create mode 100644 src/assets/layout/_topbar.scss create mode 100644 src/assets/layout/_typography.scss create mode 100644 src/assets/layout/_utils.scss create mode 100644 src/assets/layout/layout.scss create mode 100644 src/assets/layout/variables/_common.scss create mode 100644 src/assets/layout/variables/_dark.scss create mode 100644 src/assets/layout/variables/_light.scss create mode 100644 src/assets/styles.scss create mode 100644 src/assets/tailwind.css diff --git a/src/assets b/src/assets deleted file mode 160000 index eaa70ece4..000000000 --- a/src/assets +++ /dev/null @@ -1 +0,0 @@ -Subproject commit eaa70ece4cfa9aeb8f60b36dce5a422b6bae8003 diff --git a/src/assets/demo/code.scss b/src/assets/demo/code.scss new file mode 100644 index 000000000..d0d2e9af4 --- /dev/null +++ b/src/assets/demo/code.scss @@ -0,0 +1,17 @@ +pre.app-code { + background-color: var(--code-background); + margin: 0 0 1rem 0; + padding: 0; + border-radius: var(--content-border-radius); + overflow: auto; + + code { + color: var(--code-color); + padding: 1rem; + margin: 0; + line-height: 1.5; + display: block; + font-weight: semibold; + font-family: monaco, Consolas, monospace; + } +} diff --git a/src/assets/demo/demo.scss b/src/assets/demo/demo.scss new file mode 100644 index 000000000..b8f47d4c5 --- /dev/null +++ b/src/assets/demo/demo.scss @@ -0,0 +1,2 @@ +@use './code.scss'; +@use './flags/flags'; diff --git a/src/assets/demo/flags/flags.scss b/src/assets/demo/flags/flags.scss new file mode 100755 index 000000000..44a437680 --- /dev/null +++ b/src/assets/demo/flags/flags.scss @@ -0,0 +1 @@ +span.flag{width:44px;height:30px;display:inline-block;}img.flag{width:30px}.flag{background:url(./flags_responsive.png) no-repeat;background-size:100%;vertical-align: middle;}.flag-ad{background-position:0 .413223%}.flag-ae{background-position:0 .826446%}.flag-af{background-position:0 1.239669%}.flag-ag{background-position:0 1.652893%}.flag-ai{background-position:0 2.066116%}.flag-al{background-position:0 2.479339%}.flag-am{background-position:0 2.892562%}.flag-an{background-position:0 3.305785%}.flag-ao{background-position:0 3.719008%}.flag-aq{background-position:0 4.132231%}.flag-ar{background-position:0 4.545455%}.flag-as{background-position:0 4.958678%}.flag-at{background-position:0 5.371901%}.flag-au{background-position:0 5.785124%}.flag-aw{background-position:0 6.198347%}.flag-az{background-position:0 6.61157%}.flag-ba{background-position:0 7.024793%}.flag-bb{background-position:0 7.438017%}.flag-bd{background-position:0 7.85124%}.flag-be{background-position:0 8.264463%}.flag-bf{background-position:0 8.677686%}.flag-bg{background-position:0 9.090909%}.flag-bh{background-position:0 9.504132%}.flag-bi{background-position:0 9.917355%}.flag-bj{background-position:0 10.330579%}.flag-bm{background-position:0 10.743802%}.flag-bn{background-position:0 11.157025%}.flag-bo{background-position:0 11.570248%}.flag-br{background-position:0 11.983471%}.flag-bs{background-position:0 12.396694%}.flag-bt{background-position:0 12.809917%}.flag-bv{background-position:0 13.22314%}.flag-bw{background-position:0 13.636364%}.flag-by{background-position:0 14.049587%}.flag-bz{background-position:0 14.46281%}.flag-ca{background-position:0 14.876033%}.flag-cc{background-position:0 15.289256%}.flag-cd{background-position:0 15.702479%}.flag-cf{background-position:0 16.115702%}.flag-cg{background-position:0 16.528926%}.flag-ch{background-position:0 16.942149%}.flag-ci{background-position:0 17.355372%}.flag-ck{background-position:0 17.768595%}.flag-cl{background-position:0 18.181818%}.flag-cm{background-position:0 18.595041%}.flag-cn{background-position:0 19.008264%}.flag-co{background-position:0 19.421488%}.flag-cr{background-position:0 19.834711%}.flag-cu{background-position:0 20.247934%}.flag-cv{background-position:0 20.661157%}.flag-cx{background-position:0 21.07438%}.flag-cy{background-position:0 21.487603%}.flag-cz{background-position:0 21.900826%}.flag-de{background-position:0 22.31405%}.flag-dj{background-position:0 22.727273%}.flag-dk{background-position:0 23.140496%}.flag-dm{background-position:0 23.553719%}.flag-do{background-position:0 23.966942%}.flag-dz{background-position:0 24.380165%}.flag-ec{background-position:0 24.793388%}.flag-ee{background-position:0 25.206612%}.flag-eg{background-position:0 25.619835%}.flag-eh{background-position:0 26.033058%}.flag-er{background-position:0 26.446281%}.flag-es{background-position:0 26.859504%}.flag-et{background-position:0 27.272727%}.flag-fi{background-position:0 27.68595%}.flag-fj{background-position:0 28.099174%}.flag-fk{background-position:0 28.512397%}.flag-fm{background-position:0 28.92562%}.flag-fo{background-position:0 29.338843%}.flag-fr{background-position:0 29.752066%}.flag-ga{background-position:0 30.165289%}.flag-gd{background-position:0 30.578512%}.flag-ge{background-position:0 30.991736%}.flag-gf{background-position:0 31.404959%}.flag-gh{background-position:0 31.818182%}.flag-gi{background-position:0 32.231405%}.flag-gl{background-position:0 32.644628%}.flag-gm{background-position:0 33.057851%}.flag-gn{background-position:0 33.471074%}.flag-gp{background-position:0 33.884298%}.flag-gq{background-position:0 34.297521%}.flag-gr{background-position:0 34.710744%}.flag-gs{background-position:0 35.123967%}.flag-gt{background-position:0 35.53719%}.flag-gu{background-position:0 35.950413%}.flag-gw{background-position:0 36.363636%}.flag-gy{background-position:0 36.77686%}.flag-hk{background-position:0 37.190083%}.flag-hm{background-position:0 37.603306%}.flag-hn{background-position:0 38.016529%}.flag-hr{background-position:0 38.429752%}.flag-ht{background-position:0 38.842975%}.flag-hu{background-position:0 39.256198%}.flag-id{background-position:0 39.669421%}.flag-ie{background-position:0 40.082645%}.flag-il{background-position:0 40.495868%}.flag-in{background-position:0 40.909091%}.flag-io{background-position:0 41.322314%}.flag-iq{background-position:0 41.735537%}.flag-ir{background-position:0 42.14876%}.flag-is{background-position:0 42.561983%}.flag-it{background-position:0 42.975207%}.flag-jm{background-position:0 43.38843%}.flag-jo{background-position:0 43.801653%}.flag-jp{background-position:0 44.214876%}.flag-ke{background-position:0 44.628099%}.flag-kg{background-position:0 45.041322%}.flag-kh{background-position:0 45.454545%}.flag-ki{background-position:0 45.867769%}.flag-km{background-position:0 46.280992%}.flag-kn{background-position:0 46.694215%}.flag-kp{background-position:0 47.107438%}.flag-kr{background-position:0 47.520661%}.flag-kw{background-position:0 47.933884%}.flag-ky{background-position:0 48.347107%}.flag-kz{background-position:0 48.760331%}.flag-la{background-position:0 49.173554%}.flag-lb{background-position:0 49.586777%}.flag-lc{background-position:0 50%}.flag-li{background-position:0 50.413223%}.flag-lk{background-position:0 50.826446%}.flag-lr{background-position:0 51.239669%}.flag-ls{background-position:0 51.652893%}.flag-lt{background-position:0 52.066116%}.flag-lu{background-position:0 52.479339%}.flag-lv{background-position:0 52.892562%}.flag-ly{background-position:0 53.305785%}.flag-ma{background-position:0 53.719008%}.flag-mc{background-position:0 54.132231%}.flag-md{background-position:0 54.545455%}.flag-me{background-position:0 54.958678%}.flag-mg{background-position:0 55.371901%}.flag-mh{background-position:0 55.785124%}.flag-mk{background-position:0 56.198347%}.flag-ml{background-position:0 56.61157%}.flag-mm{background-position:0 57.024793%}.flag-mn{background-position:0 57.438017%}.flag-mo{background-position:0 57.85124%}.flag-mp{background-position:0 58.264463%}.flag-mq{background-position:0 58.677686%}.flag-mr{background-position:0 59.090909%}.flag-ms{background-position:0 59.504132%}.flag-mt{background-position:0 59.917355%}.flag-mu{background-position:0 60.330579%}.flag-mv{background-position:0 60.743802%}.flag-mw{background-position:0 61.157025%}.flag-mx{background-position:0 61.570248%}.flag-my{background-position:0 61.983471%}.flag-mz{background-position:0 62.396694%}.flag-na{background-position:0 62.809917%}.flag-nc{background-position:0 63.22314%}.flag-ne{background-position:0 63.636364%}.flag-nf{background-position:0 64.049587%}.flag-ng{background-position:0 64.46281%}.flag-ni{background-position:0 64.876033%}.flag-nl{background-position:0 65.289256%}.flag-no{background-position:0 65.702479%}.flag-np{background-position:0 66.115702%}.flag-nr{background-position:0 66.528926%}.flag-nu{background-position:0 66.942149%}.flag-nz{background-position:0 67.355372%}.flag-om{background-position:0 67.768595%}.flag-pa{background-position:0 68.181818%}.flag-pe{background-position:0 68.595041%}.flag-pf{background-position:0 69.008264%}.flag-pg{background-position:0 69.421488%}.flag-ph{background-position:0 69.834711%}.flag-pk{background-position:0 70.247934%}.flag-pl{background-position:0 70.661157%}.flag-pm{background-position:0 71.07438%}.flag-pn{background-position:0 71.487603%}.flag-pr{background-position:0 71.900826%}.flag-pt{background-position:0 72.31405%}.flag-pw{background-position:0 72.727273%}.flag-py{background-position:0 73.140496%}.flag-qa{background-position:0 73.553719%}.flag-re{background-position:0 73.966942%}.flag-ro{background-position:0 74.380165%}.flag-rs{background-position:0 74.793388%}.flag-ru{background-position:0 75.206612%}.flag-rw{background-position:0 75.619835%}.flag-sa{background-position:0 76.033058%}.flag-sb{background-position:0 76.446281%}.flag-sc{background-position:0 76.859504%}.flag-sd{background-position:0 77.272727%}.flag-se{background-position:0 77.68595%}.flag-sg{background-position:0 78.099174%}.flag-sh{background-position:0 78.512397%}.flag-si{background-position:0 78.92562%}.flag-sj{background-position:0 79.338843%}.flag-sk{background-position:0 79.752066%}.flag-sl{background-position:0 80.165289%}.flag-sm{background-position:0 80.578512%}.flag-sn{background-position:0 80.991736%}.flag-so{background-position:0 81.404959%}.flag-sr{background-position:0 81.818182%}.flag-ss{background-position:0 82.231405%}.flag-st{background-position:0 82.644628%}.flag-sv{background-position:0 83.057851%}.flag-sy{background-position:0 83.471074%}.flag-sz{background-position:0 83.884298%}.flag-tc{background-position:0 84.297521%}.flag-td{background-position:0 84.710744%}.flag-tf{background-position:0 85.123967%}.flag-tg{background-position:0 85.53719%}.flag-th{background-position:0 85.950413%}.flag-tj{background-position:0 86.363636%}.flag-tk{background-position:0 86.77686%}.flag-tl{background-position:0 87.190083%}.flag-tm{background-position:0 87.603306%}.flag-tn{background-position:0 88.016529%}.flag-to{background-position:0 88.429752%}.flag-tp{background-position:0 88.842975%}.flag-tr{background-position:0 89.256198%}.flag-tt{background-position:0 89.669421%}.flag-tv{background-position:0 90.082645%}.flag-tw{background-position:0 90.495868%}.flag-ty{background-position:0 90.909091%}.flag-tz{background-position:0 91.322314%}.flag-ua{background-position:0 91.735537%}.flag-ug{background-position:0 92.14876%}.flag-gb,.flag-uk{background-position:0 92.561983%}.flag-um{background-position:0 92.975207%}.flag-us{background-position:0 93.38843%}.flag-uy{background-position:0 93.801653%}.flag-uz{background-position:0 94.214876%}.flag-va{background-position:0 94.628099%}.flag-vc{background-position:0 95.041322%}.flag-ve{background-position:0 95.454545%}.flag-vg{background-position:0 95.867769%}.flag-vi{background-position:0 96.280992%}.flag-vn{background-position:0 96.694215%}.flag-vu{background-position:0 97.107438%}.flag-wf{background-position:0 97.520661%}.flag-ws{background-position:0 97.933884%}.flag-ye{background-position:0 98.347107%}.flag-za{background-position:0 98.760331%}.flag-zm{background-position:0 99.173554%}.flag-zr{background-position:0 99.586777%}.flag-zw{background-position:0 100%} diff --git a/src/assets/demo/flags/flags_responsive.png b/src/assets/demo/flags/flags_responsive.png new file mode 100755 index 0000000000000000000000000000000000000000..c27ce213fcad70eb18000e762fbcef2c08885fcf GIT binary patch literal 55194 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%0D6CrSxHv-bfoyIFjclhNCO|DLs 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 literal 0 HcmV?d00001 diff --git a/src/assets/layout/_core.scss b/src/assets/layout/_core.scss new file mode 100644 index 000000000..8dee918c6 --- /dev/null +++ b/src/assets/layout/_core.scss @@ -0,0 +1,24 @@ +html { + height: 100%; + font-size: 14px; +} + +body { + font-family: 'Lato', sans-serif; + color: var(--text-color); + background-color: var(--surface-ground); + margin: 0; + padding: 0; + min-height: 100%; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + line-height: 1.2; +} + +a { + text-decoration: none; +} + +.layout-wrapper { + min-height: 100vh; +} diff --git a/src/assets/layout/_footer.scss b/src/assets/layout/_footer.scss new file mode 100644 index 000000000..27bcbf0e9 --- /dev/null +++ b/src/assets/layout/_footer.scss @@ -0,0 +1,8 @@ +.layout-footer { + display: flex; + align-items: center; + justify-content: center; + padding: 1rem 0 1rem 0; + gap: 0.5rem; + border-top: 1px solid var(--surface-border); +} diff --git a/src/assets/layout/_main.scss b/src/assets/layout/_main.scss new file mode 100644 index 000000000..162e95c7a --- /dev/null +++ b/src/assets/layout/_main.scss @@ -0,0 +1,17 @@ +.layout-main-container { + display: flex; + flex-direction: column; + min-height: 100vh; + justify-content: space-between; + padding: 6rem 2rem 0 2rem; + transition: margin-left var(--layout-section-transition-duration); +} + +.layout-main { + flex: 1 1 auto; + padding-bottom: 2rem; +} + +img { + max-width: none !important; +} diff --git a/src/assets/layout/_menu.scss b/src/assets/layout/_menu.scss new file mode 100644 index 000000000..3d22fb580 --- /dev/null +++ b/src/assets/layout/_menu.scss @@ -0,0 +1,160 @@ +@use 'mixins' as *; + +.layout-sidebar { + position: fixed; + width: 20rem; + height: calc(100vh - 8rem); + z-index: 999; + overflow-y: auto; + user-select: none; + top: 6rem; + left: 2rem; + transition: + transform var(--layout-section-transition-duration), + left var(--layout-section-transition-duration); + background-color: var(--surface-overlay); + border-radius: var(--content-border-radius); + padding: 0.5rem 1.5rem; +} + +.layout-menu { + margin: 0; + padding: 0; + list-style-type: none; + + .layout-root-menuitem { + > .layout-menuitem-root-text { + font-size: 0.857rem; + text-transform: uppercase; + font-weight: 700; + color: var(--text-color); + margin: 0.75rem 0; + } + + > a { + display: none; + } + } + + a { + user-select: none; + + &.active-menuitem { + > .layout-submenu-toggler { + transform: rotate(-180deg); + } + } + } + + li.active-menuitem { + > a { + .layout-submenu-toggler { + transform: rotate(-180deg); + } + } + } + + ul { + margin: 0; + padding: 0; + list-style-type: none; + + a { + display: flex; + align-items: center; + position: relative; + outline: 0 none; + color: var(--text-color); + cursor: pointer; + padding: 0.75rem 1rem; + border-radius: var(--content-border-radius); + transition: + background-color var(--element-transition-duration), + box-shadow var(--element-transition-duration); + + .layout-menuitem-icon { + margin-right: 0.5rem; + } + + .layout-submenu-toggler { + font-size: 75%; + margin-left: auto; + transition: transform var(--element-transition-duration); + } + + &.active-route { + font-weight: 700; + color: var(--primary-color); + } + + &:hover { + background-color: var(--surface-hover); + } + + &:focus { + @include focused-inset(); + } + } + + ul { + overflow: hidden; + border-radius: var(--content-border-radius); + + li { + a { + margin-left: 1rem; + } + + li { + a { + margin-left: 2rem; + } + + li { + a { + margin-left: 2.5rem; + } + + li { + a { + margin-left: 3rem; + } + + li { + a { + margin-left: 3.5rem; + } + + li { + a { + margin-left: 4rem; + } + } + } + } + } + } + } + } + } +} + +.layout-submenu-enter-from, +.layout-submenu-leave-to { + max-height: 0; +} + +.layout-submenu-enter-to, +.layout-submenu-leave-from { + max-height: 1000px; +} + +.layout-submenu-leave-active { + overflow: hidden; + transition: max-height 0.45s cubic-bezier(0, 1, 0, 1); +} + +.layout-submenu-enter-active { + overflow: hidden; + transition: max-height 1s ease-in-out; +} diff --git a/src/assets/layout/_mixins.scss b/src/assets/layout/_mixins.scss new file mode 100644 index 000000000..ad330b10b --- /dev/null +++ b/src/assets/layout/_mixins.scss @@ -0,0 +1,15 @@ +@mixin focused() { + outline-width: var(--focus-ring-width); + outline-style: var(--focus-ring-style); + outline-color: var(--focus-ring-color); + outline-offset: var(--focus-ring-offset); + box-shadow: var(--focus-ring-shadow); + transition: + box-shadow var(--transition-duration), + outline-color var(--transition-duration); +} + +@mixin focused-inset() { + outline-offset: -1px; + box-shadow: inset var(--focus-ring-shadow); +} diff --git a/src/assets/layout/_preloading.scss b/src/assets/layout/_preloading.scss new file mode 100644 index 000000000..a81410444 --- /dev/null +++ b/src/assets/layout/_preloading.scss @@ -0,0 +1,47 @@ +.preloader { + position: fixed; + z-index: 999999; + background: #edf1f5; + width: 100%; + height: 100%; +} +.preloader-content { + border: 0 solid transparent; + border-radius: 50%; + width: 150px; + height: 150px; + position: absolute; + top: calc(50vh - 75px); + left: calc(50vw - 75px); +} + +.preloader-content:before, .preloader-content:after{ + content: ''; + border: 1em solid var(--primary-color); + border-radius: 50%; + width: inherit; + height: inherit; + position: absolute; + top: 0; + left: 0; + animation: loader 2s linear infinite; + opacity: 0; +} + +.preloader-content:before{ + animation-delay: 0.5s; +} + +@keyframes loader{ + 0%{ + transform: scale(0); + opacity: 0; + } + 50%{ + opacity: 1; + } + 100%{ + transform: scale(1); + opacity: 0; + } +} diff --git a/src/assets/layout/_responsive.scss b/src/assets/layout/_responsive.scss new file mode 100644 index 000000000..561d5f1b1 --- /dev/null +++ b/src/assets/layout/_responsive.scss @@ -0,0 +1,110 @@ +@media screen and (min-width: 1960px) { + .layout-main, + .landing-wrapper { + width: 1504px; + margin-left: auto !important; + margin-right: auto !important; + } +} + +@media (min-width: 992px) { + .layout-wrapper { + &.layout-overlay { + .layout-main-container { + margin-left: 0; + padding-left: 2rem; + } + + .layout-sidebar { + transform: translateX(-100%); + left: 0; + top: 0; + height: 100vh; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border-right: 1px solid var(--surface-border); + transition: + transform 0.4s cubic-bezier(0.05, 0.74, 0.2, 0.99), + left 0.4s cubic-bezier(0.05, 0.74, 0.2, 0.99); + box-shadow: + 0px 3px 5px rgba(0, 0, 0, 0.02), + 0px 0px 2px rgba(0, 0, 0, 0.05), + 0px 1px 4px rgba(0, 0, 0, 0.08); + } + + &.layout-overlay-active { + .layout-sidebar { + transform: translateX(0); + } + } + } + + &.layout-static { + .layout-main-container { + margin-left: 22rem; + } + + &.layout-static-inactive { + .layout-sidebar { + transform: translateX(-100%); + left: 0; + } + + .layout-main-container { + margin-left: 0; + padding-left: 2rem; + } + } + } + + .layout-mask { + display: none; + } + } +} + +@media (max-width: 991px) { + .blocked-scroll { + overflow: hidden; + } + + .layout-wrapper { + .layout-main-container { + margin-left: 0; + padding-left: 2rem; + } + + .layout-sidebar { + transform: translateX(-100%); + left: 0; + top: 0; + height: 100vh; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + transition: + transform 0.4s cubic-bezier(0.05, 0.74, 0.2, 0.99), + left 0.4s cubic-bezier(0.05, 0.74, 0.2, 0.99); + } + + .layout-mask { + display: none; + position: fixed; + top: 0; + left: 0; + z-index: 998; + width: 100%; + height: 100%; + background-color: var(--maskbg); + } + + &.layout-mobile-active { + .layout-sidebar { + transform: translateX(0); + } + + .layout-mask { + display: block; + } + } + } +} diff --git a/src/assets/layout/_topbar.scss b/src/assets/layout/_topbar.scss new file mode 100644 index 000000000..f5d239c18 --- /dev/null +++ b/src/assets/layout/_topbar.scss @@ -0,0 +1,160 @@ +@use 'mixins' as *; + +.layout-topbar { + position: fixed; + height: 4rem; + z-index: 997; + left: 0; + top: 0; + width: 100%; + padding: 0 2rem; + background-color: var(--surface-card); + transition: left var(--layout-section-transition-duration); + display: flex; + align-items: center; + + .layout-topbar-logo-container { + width: 20rem; + display: flex; + align-items: center; + } + + .layout-topbar-logo { + display: inline-flex; + align-items: center; + font-size: 1.5rem; + border-radius: var(--content-border-radius); + color: var(--text-color); + font-weight: 500; + gap: 0.5rem; + + svg { + width: 3rem; + } + + &:focus-visible { + @include focused(); + } + } + + .layout-topbar-action { + display: inline-flex; + justify-content: center; + align-items: center; + color: var(--text-color-secondary); + border-radius: 50%; + width: 2.5rem; + height: 2.5rem; + color: var(--text-color); + transition: background-color var(--element-transition-duration); + cursor: pointer; + + &:hover { + background-color: var(--surface-hover); + } + + &:focus-visible { + @include focused(); + } + + i { + font-size: 1.25rem; + } + + span { + font-size: 1rem; + display: none; + } + + &.layout-topbar-action-highlight { + background-color: var(--primary-color); + color: var(--primary-contrast-color); + } + } + + .layout-menu-button { + margin-right: 0.5rem; + } + + .layout-topbar-menu-button { + display: none; + } + + .layout-topbar-actions { + margin-left: auto; + display: flex; + gap: 1rem; + } + + .layout-topbar-menu-content { + display: flex; + gap: 1rem; + } + + .layout-config-menu { + display: flex; + gap: 1rem; + } +} + +@media (max-width: 991px) { + .layout-topbar { + padding: 0 2rem; + + .layout-topbar-logo-container { + width: auto; + } + + .layout-menu-button { + margin-left: 0; + margin-right: 0.5rem; + } + + .layout-topbar-menu-button { + display: inline-flex; + } + + .layout-topbar-menu { + position: absolute; + background-color: var(--surface-overlay); + transform-origin: top; + box-shadow: + 0px 3px 5px rgba(0, 0, 0, 0.02), + 0px 0px 2px rgba(0, 0, 0, 0.05), + 0px 1px 4px rgba(0, 0, 0, 0.08); + border-radius: var(--content-border-radius); + padding: 1rem; + right: 2rem; + top: 4rem; + min-width: 15rem; + border: 1px solid var(--surface-border); + + .layout-topbar-menu-content { + gap: 0.5rem; + } + + .layout-topbar-action { + display: flex; + width: 100%; + height: auto; + justify-content: flex-start; + border-radius: var(--content-border-radius); + padding: 0.5rem 1rem; + + i { + font-size: 1rem; + margin-right: 0.5rem; + } + + span { + font-weight: medium; + display: block; + } + } + } + + .layout-topbar-menu-content { + flex-direction: column; + } + } +} diff --git a/src/assets/layout/_typography.scss b/src/assets/layout/_typography.scss new file mode 100644 index 000000000..b17bbc2f9 --- /dev/null +++ b/src/assets/layout/_typography.scss @@ -0,0 +1,68 @@ +h1, +h2, +h3, +h4, +h5, +h6 { + margin: 1.5rem 0 1rem 0; + font-family: inherit; + font-weight: 700; + line-height: 1.5; + color: var(--text-color); + + &:first-child { + margin-top: 0; + } +} + +h1 { + font-size: 2.5rem; +} + +h2 { + font-size: 2rem; +} + +h3 { + font-size: 1.75rem; +} + +h4 { + font-size: 1.5rem; +} + +h5 { + font-size: 1.25rem; +} + +h6 { + font-size: 1rem; +} + +mark { + background: #fff8e1; + padding: 0.25rem 0.4rem; + border-radius: var(--content-border-radius); + font-family: monospace; +} + +blockquote { + margin: 1rem 0; + padding: 0 2rem; + border-left: 4px solid #90a4ae; +} + +hr { + border-top: solid var(--surface-border); + border-width: 1px 0 0 0; + margin: 1rem 0; +} + +p { + margin: 0 0 1rem 0; + line-height: 1.5; + + &:last-child { + margin-bottom: 0; + } +} diff --git a/src/assets/layout/_utils.scss b/src/assets/layout/_utils.scss new file mode 100644 index 000000000..6ccec88dd --- /dev/null +++ b/src/assets/layout/_utils.scss @@ -0,0 +1,25 @@ +/* Utils */ +.clearfix:after { + content: ' '; + display: block; + clear: both; +} + +.card { + background: var(--surface-card); + padding: 2rem; + margin-bottom: 2rem; + border-radius: var(--content-border-radius); + + &:last-child { + margin-bottom: 0; + } +} + +.p-toast { + &.p-toast-top-right, + &.p-toast-top-left, + &.p-toast-top-center { + top: 100px; + } +} diff --git a/src/assets/layout/layout.scss b/src/assets/layout/layout.scss new file mode 100644 index 000000000..ce93b988d --- /dev/null +++ b/src/assets/layout/layout.scss @@ -0,0 +1,13 @@ +@use './variables/_common'; +@use './variables/_light'; +@use './variables/_dark'; +@use './_mixins'; +@use './_preloading'; +@use './_core'; +@use './_main'; +@use './_topbar'; +@use './_menu'; +@use './_footer'; +@use './_responsive'; +@use './_utils'; +@use './_typography'; diff --git a/src/assets/layout/variables/_common.scss b/src/assets/layout/variables/_common.scss new file mode 100644 index 000000000..2a040c2c9 --- /dev/null +++ b/src/assets/layout/variables/_common.scss @@ -0,0 +1,20 @@ +:root { + --primary-color: var(--p-primary-color); + --primary-contrast-color: var(--p-primary-contrast-color); + --text-color: var(--p-text-color); + --text-color-secondary: var(--p-text-muted-color); + --surface-border: var(--p-content-border-color); + --surface-card: var(--p-content-background); + --surface-hover: var(--p-content-hover-background); + --surface-overlay: var(--p-overlay-popover-background); + --transition-duration: var(--p-transition-duration); + --maskbg: var(--p-mask-background); + --content-border-radius: var(--p-content-border-radius); + --layout-section-transition-duration: 0.2s; + --element-transition-duration: var(--p-transition-duration); + --focus-ring-width: var(--p-focus-ring-width); + --focus-ring-style: var(--p-focus-ring-style); + --focus-ring-color: var(--p-focus-ring-color); + --focus-ring-offset: var(--p-focus-ring-offset); + --focus-ring-shadow: var(--p-focus-ring-shadow); +} diff --git a/src/assets/layout/variables/_dark.scss b/src/assets/layout/variables/_dark.scss new file mode 100644 index 000000000..bb916050e --- /dev/null +++ b/src/assets/layout/variables/_dark.scss @@ -0,0 +1,5 @@ +:root[class*='app-dark'] { + --surface-ground: var(--p-surface-950); + --code-background: var(--p-surface-800); + --code-color: var(--p-surface-100); +} diff --git a/src/assets/layout/variables/_light.scss b/src/assets/layout/variables/_light.scss new file mode 100644 index 000000000..aa3403c92 --- /dev/null +++ b/src/assets/layout/variables/_light.scss @@ -0,0 +1,5 @@ +:root { + --surface-ground: var(--p-surface-100); + --code-background: var(--p-surface-900); + --code-color: var(--p-surface-200); +} diff --git a/src/assets/styles.scss b/src/assets/styles.scss new file mode 100644 index 000000000..0b502984a --- /dev/null +++ b/src/assets/styles.scss @@ -0,0 +1,5 @@ +/* You can add global styles to this file, and also import other style files */ +@use './tailwind.css'; +@use './layout/layout.scss'; +@use 'primeicons/primeicons.css'; +@use './demo/demo.scss'; diff --git a/src/assets/tailwind.css b/src/assets/tailwind.css new file mode 100644 index 000000000..a820cb7af --- /dev/null +++ b/src/assets/tailwind.css @@ -0,0 +1,11 @@ +@import 'tailwindcss'; +@import 'tailwindcss-primeui'; +@custom-variant dark (&:where(.app-dark, .app-dark *)); + +@theme { + --breakpoint-sm: 576px; + --breakpoint-md: 768px; + --breakpoint-lg: 992px; + --breakpoint-xl: 1200px; + --breakpoint-2xl: 1920px; +} \ No newline at end of file From e0c0c6de7e09aeb08001b604d844bf5805b44eaa Mon Sep 17 00:00:00 2001 From: caoquyenis <58635793+caoquyenis@users.noreply.github.com> Date: Tue, 10 Feb 2026 12:06:05 +0700 Subject: [PATCH 02/10] fix: remove unnecessary peer dependencies from package-lock.json --- package-lock.json | 46 +++------------------------------------------- 1 file changed, 3 insertions(+), 43 deletions(-) diff --git a/package-lock.json b/package-lock.json index e7c7d2f3c..bd90298c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -709,7 +709,6 @@ "resolved": "https://registry.npmjs.org/@angular/common/-/common-21.0.6.tgz", "integrity": "sha512-Yd8PF0dR37FAzqEcBHAyVCiSGMJOezSJe6rV/4BC6AVLfaZ7oZLl8CNVxKsod2UHd6rKxt1hzx05QdVcVvYNeA==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -726,7 +725,6 @@ "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.0.6.tgz", "integrity": "sha512-rBMzG7WnQMouFfDST+daNSAOVYdtw560645PhlxyVeIeHMlCm0j1jjBgVPGTBNpVgKRdT/sqbi6W6JYkY9mERA==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -740,7 +738,6 @@ "integrity": "sha512-UcIUx+fbn0VLlCBCIYxntAzWG3zPRUo0K7wvuK0MC6ZFCWawgewx9SdLLZTqcaWe1g5FRQlQeVQcFgHAO5R2Mw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "7.28.4", "@jridgewell/sourcemap-codec": "^1.4.14", @@ -773,7 +770,6 @@ "resolved": "https://registry.npmjs.org/@angular/core/-/core-21.0.6.tgz", "integrity": "sha512-SvWbOkkrsqprYJSBmzQEWkWjfZB/jkRYyFp2ClMJBPqOLxP1a+i3Om2rolcNQjZPz87bs9FszwgRlXUy7sw5cQ==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -799,7 +795,6 @@ "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.0.6.tgz", "integrity": "sha512-aAkAAKuUrP8U7R4aH/HbmG/CXP90GlML77ECBI5b4qCSb+bvaTEYsaf85mCyTpr9jvGkia2LTe42hPcOuyzdsQ==", "license": "MIT", - "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "tslib": "^2.3.0" @@ -819,7 +814,6 @@ "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.0.6.tgz", "integrity": "sha512-tPk8rlUEBPXIUPRYq6Xu7QhJgKtnVr0dOHHuhyi70biKTupr5VikpZC5X9dy2Q3H3zYbK6MHC6384YMuwfU2kg==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -860,7 +854,6 @@ "resolved": "https://registry.npmjs.org/@angular/router/-/router-21.0.6.tgz", "integrity": "sha512-HOfomKq7jRSgxt/uUvpdbB8RNaYuGB/FJQ3BfQCFfGw1O9L3B72b7Hilk6AcjCruul6cfv/kmT4EB6Vqi3dQtA==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -905,7 +898,6 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -3464,7 +3456,6 @@ "integrity": "sha512-X7/+dG9SLpSzRkwgG5/xiIzW0oMrV3C0HOa7YHG1WnrLK+vCQHfte4k/T80059YBdei29RBC3s+pSMvPJDU9/A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@inquirer/checkbox": "^4.3.0", "@inquirer/confirm": "^5.1.19", @@ -6733,7 +6724,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6821,7 +6811,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -7450,7 +7439,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -8762,7 +8750,6 @@ "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "iconv-lite": "^0.6.2" } @@ -9197,7 +9184,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -9591,7 +9577,6 @@ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -11714,8 +11699,7 @@ "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.4.0.tgz", "integrity": "sha512-T4fio3W++llLd7LGSGsioriDHgWyhoL6YTu4k37uwJLF7DzOzspz7mNxRoM3cQdLWtL/ebazQpIf/yZGJx/gzg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/jest-worker": { "version": "27.5.1", @@ -11877,7 +11861,6 @@ "integrity": "sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@colors/colors": "1.5.0", "body-parser": "^1.19.0", @@ -12367,7 +12350,6 @@ "integrity": "sha512-j1n1IuTX1VQjIy3tT7cyGbX7nvQOsFLoIqobZv4ttI5axP923gA44zUj6miiA6R5Aoms4sEGVIIcucXUbRI14g==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "copy-anything": "^2.0.1", "parse-node-version": "^1.0.1", @@ -14633,7 +14615,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -14782,7 +14763,6 @@ "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -15372,17 +15352,6 @@ "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", "license": "MIT" }, - "node_modules/primeclt/node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "optional": true, - "peer": true, - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "node_modules/primeicons": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/primeicons/-/primeicons-7.0.0.tgz", @@ -16043,7 +16012,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -16135,7 +16103,6 @@ "integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -17205,8 +17172,7 @@ "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tailwindcss-primeui": { "version": "0.6.1", @@ -17263,7 +17229,6 @@ "integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -17465,8 +17430,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tsx": { "version": "4.21.0", @@ -18090,7 +18054,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -18990,7 +18953,6 @@ "integrity": "sha512-5DeICTX8BVgNp6afSPYXAFjskIgWGlygQH58bcozPOXgo2r/6xx39Y1+cULZ3gTxUYQP88jmwLj2anu4Xaq84g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -19865,7 +19827,6 @@ "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "devOptional": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -20005,7 +19966,6 @@ "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } From 8998d801e3948da5ceb042d8974ce58e4c5f7810 Mon Sep 17 00:00:00 2001 From: caoquyenis <58635793+caoquyenis@users.noreply.github.com> Date: Tue, 10 Feb 2026 14:17:47 +0700 Subject: [PATCH 03/10] feat: add layout showcase component and update routing and menu --- src/app.routes.ts | 2 + src/app/layout/component/app.menu.ts | 5 + src/app/pages/layout/layout.ts | 496 +++++++++++++++++++++++++++ 3 files changed, 503 insertions(+) create mode 100644 src/app/pages/layout/layout.ts diff --git a/src/app.routes.ts b/src/app.routes.ts index 96e5ba31e..df5a18fa8 100644 --- a/src/app.routes.ts +++ b/src/app.routes.ts @@ -3,6 +3,7 @@ import { AppLayout } from './app/layout/component/app.layout'; import { Dashboard } from './app/pages/dashboard/dashboard'; import { Documentation } from './app/pages/documentation/documentation'; import { Landing } from './app/pages/landing/landing'; +import { Layout } from './app/pages/layout/layout'; import { Notfound } from './app/pages/notfound/notfound'; export const appRoutes: Routes = [ @@ -17,6 +18,7 @@ export const appRoutes: Routes = [ ] }, { path: 'landing', component: Landing }, + { path: 'layout', component: Layout }, { path: 'notfound', component: Notfound }, { path: 'auth', loadChildren: () => import('./app/pages/auth/auth.routes') }, { path: '**', redirectTo: '/notfound' } diff --git a/src/app/layout/component/app.menu.ts b/src/app/layout/component/app.menu.ts index fe501586f..a7494d27a 100644 --- a/src/app/layout/component/app.menu.ts +++ b/src/app/layout/component/app.menu.ts @@ -93,6 +93,11 @@ export class AppMenu { label: 'Empty', icon: 'pi pi-fw pi-circle-off', routerLink: ['/pages/empty'] + }, + { + label: 'Layout', + icon: 'pi pi-fw pi-objects-column', + routerLink: ['/layout'] } ] }, diff --git a/src/app/pages/layout/layout.ts b/src/app/pages/layout/layout.ts new file mode 100644 index 000000000..d8b2d6184 --- /dev/null +++ b/src/app/pages/layout/layout.ts @@ -0,0 +1,496 @@ +import { Component, signal, computed, effect } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { DrawerModule } from 'primeng/drawer'; +import { ToolbarModule } from 'primeng/toolbar'; +import { ButtonModule } from 'primeng/button'; +import { PanelMenuModule } from 'primeng/panelmenu'; +import { SelectButtonModule } from 'primeng/selectbutton'; +import { AvatarModule } from 'primeng/avatar'; +import { BadgeModule } from 'primeng/badge'; +import { InputTextModule } from 'primeng/inputtext'; +import { TextareaModule } from 'primeng/textarea'; +import { MenuItem } from 'primeng/api'; + +interface EditableMenuItem { + label: string; + icon: string; + routerLink?: string; + items?: EditableMenuItem[]; +} + +const DEFAULT_MENU: EditableMenuItem[] = [ + { + label: 'Home', + icon: 'pi pi-home', + items: [ + { label: 'Dashboard', icon: 'pi pi-chart-bar', routerLink: '/dashboard' }, + { label: 'Analytics', icon: 'pi pi-chart-line', routerLink: '/analytics' } + ] + }, + { + label: 'Users', + icon: 'pi pi-users', + items: [ + { label: 'List', icon: 'pi pi-list', routerLink: '/users' }, + { label: 'Create', icon: 'pi pi-user-plus', routerLink: '/users/create' }, + { label: 'Roles', icon: 'pi pi-shield', routerLink: '/users/roles' } + ] + }, + { + label: 'Content', + icon: 'pi pi-file', + items: [ + { label: 'Articles', icon: 'pi pi-file-edit', routerLink: '/content/articles' }, + { label: 'Media', icon: 'pi pi-images', routerLink: '/content/media' }, + { label: 'Categories', icon: 'pi pi-tags', routerLink: '/content/categories' } + ] + }, + { + label: 'Settings', + icon: 'pi pi-cog', + items: [ + { label: 'General', icon: 'pi pi-sliders-h', routerLink: '/settings/general' }, + { label: 'Security', icon: 'pi pi-lock', routerLink: '/settings/security' }, + { label: 'Integrations', icon: 'pi pi-link', routerLink: '/settings/integrations' } + ] + } +]; + +@Component({ + selector: 'app-layout-showcase', + standalone: true, + imports: [ + CommonModule, FormsModule, DrawerModule, ToolbarModule, ButtonModule, + PanelMenuModule, SelectButtonModule, AvatarModule, BadgeModule, + InputTextModule, TextareaModule + ], + template: ` +

+ + + + + Layout Showcase + + + + + + + + + + + + +
+ + @if (leftVisible()) { +
+ @if (mode() === 'demo') { + + } @else { + +
+ @for (group of menuItems(); track group.label; let gi = $index) { +
+ @if (isEditing(gi, -1)) { +
+ + +
+ + +
+
+ } @else { +
+
+ + {{ group.label }} +
+ +
+ } + + @if (group.items) { + @for (item of group.items; track item.label; let ii = $index) { + @if (isEditing(gi, ii)) { +
+ + + +
+ + +
+
+ } @else { +
+
+ + {{ item.label }} +
+ +
+ } + } +
+ +
+ } +
+ } + +
+ } +
+ } + + +
+ @if (mode() === 'demo') { + +
+ @for (stat of stats; track stat.label) { +
+
+
+
+ {{ stat.label }} +
{{ stat.value }}
+
+
+ +
+
+ {{ stat.change }} + {{ stat.period }} +
+
+ } +
+ +
+
Main Content Area
+

+ This is a full-page layout showcase with its own topbar, left navigation sidenav, right settings drawer, main content area, and footer. + Switch to Config mode using the toggle in the topbar to customize the navigation menu items via code or visual editing. +

+
+ } @else { + +
+
Live Preview
+

+ Edit menu items by clicking them in the left sidenav, use the visual form in the right panel, or edit the JSON code directly. +

+
+
Current Menu Structure
+
{{ menuItemsPreview() }}
+
+
+ } +
+ + + + @if (mode() === 'demo') { +
+
+
Notifications
+
+ @for (notification of notifications; track notification.id) { +
+ +
+
{{ notification.title }}
+
{{ notification.time }}
+
+
+ } +
+
+ +
+
Quick Actions
+
+ + + +
+
+ +
+
Activity
+
+
+ + System updated successfully +
+
+ + New user registered +
+
+ + Server maintenance scheduled +
+
+
+
+ } @else { +
+ +
+
Add Menu Group
+
+ + + +
+
+ +
+
Add Menu Item
+
+ + + + + +
+
+ + +
+
Reorder Groups
+
+ @for (group of menuItems(); track group.label; let i = $index) { +
+
+ + {{ group.label }} +
+
+ + +
+
+ } +
+
+ + +
+
+
Code Editor (JSON)
+ +
+ @if (jsonError()) { +
{{ jsonError() }}
+ } + +
+
+ } +
+
+ + +
+ © 2026 Layout Showcase. All rights reserved. + +
+
+ ` +}) +export class Layout { + mode = signal<'demo' | 'config'>('demo'); + leftVisible = signal(true); + rightVisible = signal(false); + darkMode = signal(false); + + menuItems = signal(structuredClone(DEFAULT_MENU)); + menuJson = signal(JSON.stringify(DEFAULT_MENU, null, 2)); + jsonError = signal(null); + + editingGroup = signal(-1); + editingItem = signal(-1); + + // Form fields for adding items + newGroupLabel = ''; + newGroupIcon = 'pi pi-folder'; + newItemParentIndex = -1; + newItemLabel = ''; + newItemIcon = 'pi pi-circle'; + newItemRoute = ''; + + modeOptions = [ + { label: 'Demo', value: 'demo' }, + { label: 'Config', value: 'config' } + ]; + + stats = [ + { label: 'Orders', value: '152', icon: 'pi pi-shopping-cart', iconClass: 'text-blue-500', bgClass: 'bg-blue-100 dark:bg-blue-400/10', change: '24 new', period: 'since last visit' }, + { label: 'Revenue', value: '$2,100', icon: 'pi pi-dollar', iconClass: 'text-orange-500', bgClass: 'bg-orange-100 dark:bg-orange-400/10', change: '%52+', period: 'since last week' }, + { label: 'Customers', value: '28,441', icon: 'pi pi-users', iconClass: 'text-cyan-500', bgClass: 'bg-cyan-100 dark:bg-cyan-400/10', change: '520', period: 'newly registered' }, + { label: 'Comments', value: '152 Unread', icon: 'pi pi-comment', iconClass: 'text-purple-500', bgClass: 'bg-purple-100 dark:bg-purple-400/10', change: '85', period: 'responded' } + ]; + + notifications = [ + { id: 1, icon: 'pi pi-envelope', color: '#3B82F6', title: 'New message from admin', time: '2 min ago' }, + { id: 2, icon: 'pi pi-shopping-cart', color: '#F59E0B', title: 'Order #1234 completed', time: '15 min ago' }, + { id: 3, icon: 'pi pi-exclamation-triangle', color: '#EF4444', title: 'Server alert detected', time: '1 hour ago' }, + { id: 4, icon: 'pi pi-user', color: '#10B981', title: 'New user registered', time: '3 hours ago' } + ]; + + panelMenuItems = computed(() => { + return this.menuItems().map(group => ({ + label: group.label, + icon: group.icon, + items: group.items?.map(item => ({ + label: item.label, + icon: item.icon, + routerLink: item.routerLink ? [item.routerLink] : undefined + })) + })) as MenuItem[]; + }); + + menuItemsPreview = computed(() => JSON.stringify(this.menuItems(), null, 2)); + + constructor() { + this.darkMode.set(document.documentElement.classList.contains('app-dark')); + } + + toggleDarkMode() { + this.darkMode.update(v => !v); + document.documentElement.classList.toggle('app-dark'); + } + + // Inline editing + isEditing(groupIndex: number, itemIndex: number): boolean { + return this.editingGroup() === groupIndex && this.editingItem() === itemIndex; + } + + startEdit(groupIndex: number, itemIndex: number) { + this.editingGroup.set(groupIndex); + this.editingItem.set(itemIndex); + } + + finishEdit() { + this.editingGroup.set(-1); + this.editingItem.set(-1); + this.syncJsonFromItems(); + } + + removeItem(groupIndex: number) { + this.menuItems.update(items => { + const updated = [...items]; + updated.splice(groupIndex, 1); + return updated; + }); + this.finishEdit(); + } + + removeSubItem(groupIndex: number, itemIndex: number) { + this.menuItems.update(items => { + const updated = structuredClone(items); + updated[groupIndex].items?.splice(itemIndex, 1); + return updated; + }); + this.finishEdit(); + } + + addSubItem(groupIndex: number) { + this.menuItems.update(items => { + const updated = structuredClone(items); + if (!updated[groupIndex].items) { + updated[groupIndex].items = []; + } + updated[groupIndex].items!.push({ label: 'New Item', icon: 'pi pi-circle', routerLink: '/' }); + return updated; + }); + const group = this.menuItems()[groupIndex]; + this.startEdit(groupIndex, group.items!.length - 1); + } + + addGroup() { + this.menuItems.update(items => [...items, { label: 'New Group', icon: 'pi pi-folder', items: [] }]); + this.startEdit(this.menuItems().length - 1, -1); + } + + // Form-based adding + addGroupFromForm() { + if (!this.newGroupLabel) return; + this.menuItems.update(items => [...items, { label: this.newGroupLabel, icon: this.newGroupIcon, items: [] }]); + this.newGroupLabel = ''; + this.newGroupIcon = 'pi pi-folder'; + this.syncJsonFromItems(); + } + + addItemFromForm() { + if (!this.newItemLabel || this.newItemParentIndex === -1) return; + this.menuItems.update(items => { + const updated = structuredClone(items); + if (!updated[this.newItemParentIndex].items) { + updated[this.newItemParentIndex].items = []; + } + updated[this.newItemParentIndex].items!.push({ + label: this.newItemLabel, + icon: this.newItemIcon, + routerLink: this.newItemRoute || undefined + }); + return updated; + }); + this.newItemLabel = ''; + this.newItemIcon = 'pi pi-circle'; + this.newItemRoute = ''; + this.syncJsonFromItems(); + } + + // Reorder + moveGroup(index: number, direction: number) { + this.menuItems.update(items => { + const updated = [...items]; + const target = index + direction; + [updated[index], updated[target]] = [updated[target], updated[index]]; + return updated; + }); + this.syncJsonFromItems(); + } + + // JSON sync + syncJsonFromItems() { + this.menuJson.set(JSON.stringify(this.menuItems(), null, 2)); + this.jsonError.set(null); + } + + onJsonInput() { + this.jsonError.set(null); + } + + applyJson() { + try { + const parsed = JSON.parse(this.menuJson()); + if (!Array.isArray(parsed)) { + this.jsonError.set('JSON must be an array of menu groups'); + return; + } + this.menuItems.set(parsed); + this.jsonError.set(null); + } catch (e: any) { + this.jsonError.set('Invalid JSON: ' + e.message); + } + } +} From 8b7e986fae5e8e97b55004412117daea31393a0c Mon Sep 17 00:00:00 2001 From: caoquyenis <58635793+caoquyenis@users.noreply.github.com> Date: Tue, 10 Feb 2026 15:14:07 +0700 Subject: [PATCH 04/10] feat: add configuration center with YAML/JSON import/export and update menu structure --- package-lock.json | 11 +- package.json | 2 + src/app/layout/component/app.menu.ts | 5 + src/app/pages/config/config.ts | 318 ++++++++++++++++++++++++ src/app/pages/layout/layout.ts | 23 +- src/app/pages/pages.routes.ts | 2 + src/app/pages/service/config.service.ts | 305 +++++++++++++++++++++++ 7 files changed, 661 insertions(+), 5 deletions(-) create mode 100644 src/app/pages/config/config.ts create mode 100644 src/app/pages/service/config.service.ts diff --git a/package-lock.json b/package-lock.json index bd90298c1..3c753de21 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@primeuix/themes": "^2.0.0", "@tailwindcss/postcss": "^4.1.11", "chart.js": "4.4.2", + "js-yaml": "^4.1.1", "primeclt": "^0.1.5", "primeicons": "^7.0.0", "primeng": "^21.0.2", @@ -31,6 +32,7 @@ "@angular/cli": "^21", "@angular/compiler-cli": "^21", "@types/jasmine": "~5.1.0", + "@types/js-yaml": "^4.0.9", "autoprefixer": "^10.4.20", "eslint": "^9.14.0", "eslint-config-prettier": "^9.1.0", @@ -6344,6 +6346,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -6976,7 +6985,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/array-buffer-byte-length": { @@ -11762,7 +11770,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" diff --git a/package.json b/package.json index 9e030faf9..f83dc72c3 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@primeuix/themes": "^2.0.0", "@tailwindcss/postcss": "^4.1.11", "chart.js": "4.4.2", + "js-yaml": "^4.1.1", "primeclt": "^0.1.5", "primeicons": "^7.0.0", "primeng": "^21.0.2", @@ -34,6 +35,7 @@ "@angular/cli": "^21", "@angular/compiler-cli": "^21", "@types/jasmine": "~5.1.0", + "@types/js-yaml": "^4.0.9", "autoprefixer": "^10.4.20", "eslint": "^9.14.0", "eslint-config-prettier": "^9.1.0", diff --git a/src/app/layout/component/app.menu.ts b/src/app/layout/component/app.menu.ts index a7494d27a..5e3e233e5 100644 --- a/src/app/layout/component/app.menu.ts +++ b/src/app/layout/component/app.menu.ts @@ -98,6 +98,11 @@ export class AppMenu { label: 'Layout', icon: 'pi pi-fw pi-objects-column', routerLink: ['/layout'] + }, + { + label: 'Config', + icon: 'pi pi-fw pi-cog', + routerLink: ['/pages/config'] } ] }, diff --git a/src/app/pages/config/config.ts b/src/app/pages/config/config.ts new file mode 100644 index 000000000..985a1c5ea --- /dev/null +++ b/src/app/pages/config/config.ts @@ -0,0 +1,318 @@ +import { Component, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { TabsModule } from 'primeng/tabs'; +import { ButtonModule } from 'primeng/button'; +import { InputTextModule } from 'primeng/inputtext'; +import { TextareaModule } from 'primeng/textarea'; +import { CardModule } from 'primeng/card'; +import { MessageModule } from 'primeng/message'; +import { FileUploadModule } from 'primeng/fileupload'; +import { ToastModule } from 'primeng/toast'; +import { MessageService } from 'primeng/api'; +import { ConfigService } from '../service/config.service'; +import { ToggleButtonModule } from 'primeng/togglebutton'; +import { DividerModule } from 'primeng/divider'; + +@Component({ + selector: 'app-config', + standalone: true, + imports: [ + CommonModule, + FormsModule, + TabsModule, + ButtonModule, + InputTextModule, + TextareaModule, + CardModule, + MessageModule, + FileUploadModule, + ToastModule, + ToggleButtonModule, + DividerModule + ], + providers: [MessageService], + template: ` +
+

Configuration Center

+

Centralized configuration management for showcase demos and application settings

+ + + + + + Menu Configuration + + + + Theme Settings + + + + Notifications + + + + Dashboard + + + + Advanced + + + + + + +
+ + +
+ + + +
+ + + +
+ + +
+ +
+ + +
+ +
+ + +
+ + @if (errorMessage()) { + + } +
+
+ + + +
+ +
+ +

Theme Settings

+

+ Configure primary colors, dark mode preferences, and other theme-related settings. + This feature will be implemented in a future update. +

+
+
+
+ + + +
+ +
+ +

Notification Settings

+

+ Enable/disable notifications, configure notification sounds, and manage notification preferences. + This feature will be implemented in a future update. +

+
+
+
+ + + +
+ +
+ +

Dashboard Widgets

+

+ Customize dashboard widgets, set refresh intervals, and configure widget layouts. + This feature will be implemented in a future update. +

+
+
+
+ + + +
+ +
+ +

Advanced Settings

+

+ Configure advanced application settings, API endpoints, performance tuning, and more. + This feature will be implemented in a future update. +

+
+
+
+
+
+
+ + + `, +}) +export class Config { + configCode = signal(''); + editMode = signal<'yaml' | 'json'>('yaml'); + errorMessage = signal(''); + isYamlMode = true; + + constructor( + private configService: ConfigService, + private messageService: MessageService + ) { + this.reloadConfigCode(); + console.log('Config component initialized, code length:', this.configCode().length); + } + + toggleEditMode() { + this.editMode.set(this.isYamlMode ? 'yaml' : 'json'); + this.reloadConfigCode(); + } + + reloadConfigCode() { + this.errorMessage.set(''); + if (this.editMode() === 'yaml') { + this.configCode.set(this.configService.exportConfigAsYaml()); + } else { + this.configCode.set(this.configService.exportConfigAsJson()); + } + } + + applyConfigCode() { + try { + this.errorMessage.set(''); + console.log('Applying config, mode:', this.editMode(), 'code length:', this.configCode().length); + if (this.editMode() === 'yaml') { + this.configService.importConfigFromYaml(this.configCode()); + } else { + this.configService.importConfigFromJson(this.configCode()); + } + this.messageService.add({ + severity: 'success', + summary: 'Success', + detail: 'Configuration applied successfully' + }); + console.log('Config applied successfully'); + } catch (e) { + console.error('Failed to apply config:', e); + this.errorMessage.set((e as Error).message); + this.messageService.add({ + severity: 'error', + summary: 'Error', + detail: (e as Error).message + }); + } + } + + exportYaml() { + const yaml = this.configService.exportConfigAsYaml(); + this.downloadFile(yaml, 'config.yaml', 'text/yaml'); + this.messageService.add({ + severity: 'success', + summary: 'Exported', + detail: 'Configuration exported as YAML' + }); + } + + exportJson() { + const json = this.configService.exportConfigAsJson(); + this.downloadFile(json, 'config.json', 'application/json'); + this.messageService.add({ + severity: 'success', + summary: 'Exported', + detail: 'Configuration exported as JSON' + }); + } + + resetConfig() { + this.configService.resetConfig(); + this.reloadConfigCode(); + this.messageService.add({ + severity: 'success', + summary: 'Reset', + detail: 'Configuration reset to default' + }); + } + + onFileSelect(event: any) { + const file = event.files[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (e: any) => { + try { + const content = e.target.result; + const isJson = file.name.endsWith('.json'); + + if (isJson) { + this.configService.importConfigFromJson(content); + } else { + this.configService.importConfigFromYaml(content); + } + + this.reloadConfigCode(); + this.messageService.add({ + severity: 'success', + summary: 'Imported', + detail: `Configuration imported from ${file.name}` + }); + } catch (e) { + this.errorMessage.set((e as Error).message); + this.messageService.add({ + severity: 'error', + summary: 'Import Failed', + detail: (e as Error).message + }); + } + }; + reader.readAsText(file); + } + + private downloadFile(content: string, filename: string, mimeType: string) { + const blob = new Blob([content], { type: mimeType }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + link.click(); + window.URL.revokeObjectURL(url); + } +} diff --git a/src/app/pages/layout/layout.ts b/src/app/pages/layout/layout.ts index d8b2d6184..066be99bb 100644 --- a/src/app/pages/layout/layout.ts +++ b/src/app/pages/layout/layout.ts @@ -11,6 +11,7 @@ import { BadgeModule } from 'primeng/badge'; import { InputTextModule } from 'primeng/inputtext'; import { TextareaModule } from 'primeng/textarea'; import { MenuItem } from 'primeng/api'; +import { ConfigService } from '../service/config.service'; interface EditableMenuItem { label: string; @@ -321,8 +322,8 @@ export class Layout { rightVisible = signal(false); darkMode = signal(false); - menuItems = signal(structuredClone(DEFAULT_MENU)); - menuJson = signal(JSON.stringify(DEFAULT_MENU, null, 2)); + menuItems = signal([]); + menuJson = signal(''); jsonError = signal(null); editingGroup = signal(-1); @@ -369,7 +370,19 @@ export class Layout { menuItemsPreview = computed(() => JSON.stringify(this.menuItems(), null, 2)); - constructor() { + constructor(private configService: ConfigService) { + // Load menu from config service + const configMenu = this.configService.getShowcaseMenuConfig(); + this.menuItems.set(structuredClone(configMenu as any)); + this.menuJson.set(JSON.stringify(configMenu, null, 2)); + + // Watch for changes in config service and update menu + effect(() => { + const menu = this.configService.showcaseMenuConfig(); + this.menuItems.set(structuredClone(menu as any)); + this.menuJson.set(JSON.stringify(menu, null, 2)); + }); + this.darkMode.set(document.documentElement.classList.contains('app-dark')); } @@ -474,6 +487,8 @@ export class Layout { syncJsonFromItems() { this.menuJson.set(JSON.stringify(this.menuItems(), null, 2)); this.jsonError.set(null); + // Save to config service + this.configService.updateShowcaseMenuConfig(this.menuItems() as any); } onJsonInput() { @@ -489,6 +504,8 @@ export class Layout { } this.menuItems.set(parsed); this.jsonError.set(null); + // Save to config service + this.configService.updateShowcaseMenuConfig(parsed); } catch (e: any) { this.jsonError.set('Invalid JSON: ' + e.message); } diff --git a/src/app/pages/pages.routes.ts b/src/app/pages/pages.routes.ts index 6561edc49..533ca964c 100644 --- a/src/app/pages/pages.routes.ts +++ b/src/app/pages/pages.routes.ts @@ -2,10 +2,12 @@ import { Routes } from '@angular/router'; import { Documentation } from './documentation/documentation'; import { Crud } from './crud/crud'; import { Empty } from './empty/empty'; +import { Config } from './config/config'; export default [ { path: 'documentation', component: Documentation }, { path: 'crud', component: Crud }, { path: 'empty', component: Empty }, + { path: 'config', component: Config }, { path: '**', redirectTo: '/notfound' } ] as Routes; diff --git a/src/app/pages/service/config.service.ts b/src/app/pages/service/config.service.ts new file mode 100644 index 000000000..df7826b91 --- /dev/null +++ b/src/app/pages/service/config.service.ts @@ -0,0 +1,305 @@ +import { Injectable, signal } from '@angular/core'; +import { MenuItem } from 'primeng/api'; +import * as yaml from 'js-yaml'; + +export interface ConfigState { + showcaseMenu: MenuItem[]; // Menu for showcase demos, not the main app menu + // Placeholder for future configs + theme?: { + primaryColor?: string; + darkMode?: boolean; + }; + notifications?: { + enabled?: boolean; + sound?: boolean; + }; + dashboard?: { + widgets?: string[]; + refreshInterval?: number; + }; +} + +// Default menu configuration for showcase demos (not the main app menu) +const DEFAULT_SHOWCASE_MENU_CONFIG: MenuItem[] = [ + { + label: 'Home', + items: [{ label: 'Dashboard', icon: 'pi pi-fw pi-home', routerLink: ['/'] }] + }, + { + label: 'UI Components', + items: [ + { label: 'Form Layout', icon: 'pi pi-fw pi-id-card', routerLink: ['/uikit/formlayout'] }, + { label: 'Input', icon: 'pi pi-fw pi-check-square', routerLink: ['/uikit/input'] }, + { label: 'Button', icon: 'pi pi-fw pi-mobile', class: 'rotated-icon', routerLink: ['/uikit/button'] }, + { label: 'Table', icon: 'pi pi-fw pi-table', routerLink: ['/uikit/table'] }, + { label: 'List', icon: 'pi pi-fw pi-list', routerLink: ['/uikit/list'] }, + { label: 'Tree', icon: 'pi pi-fw pi-share-alt', routerLink: ['/uikit/tree'] }, + { label: 'Panel', icon: 'pi pi-fw pi-tablet', routerLink: ['/uikit/panel'] }, + { label: 'Overlay', icon: 'pi pi-fw pi-clone', routerLink: ['/uikit/overlay'] }, + { label: 'Media', icon: 'pi pi-fw pi-image', routerLink: ['/uikit/media'] }, + { label: 'Menu', icon: 'pi pi-fw pi-bars', routerLink: ['/uikit/menu'] }, + { label: 'Message', icon: 'pi pi-fw pi-comment', routerLink: ['/uikit/message'] }, + { label: 'File', icon: 'pi pi-fw pi-file', routerLink: ['/uikit/file'] }, + { label: 'Chart', icon: 'pi pi-fw pi-chart-bar', routerLink: ['/uikit/charts'] }, + { label: 'Timeline', icon: 'pi pi-fw pi-calendar', routerLink: ['/uikit/timeline'] }, + { label: 'Misc', icon: 'pi pi-fw pi-circle', routerLink: ['/uikit/misc'] } + ] + }, + { + label: 'Pages', + icon: 'pi pi-fw pi-briefcase', + items: [ + { + label: 'Landing', + icon: 'pi pi-fw pi-globe', + routerLink: ['/landing'] + }, + { + label: 'Auth', + icon: 'pi pi-fw pi-user', + items: [ + { + label: 'Login', + icon: 'pi pi-fw pi-sign-in', + routerLink: ['/auth/login'] + }, + { + label: 'Error', + icon: 'pi pi-fw pi-times-circle', + routerLink: ['/auth/error'] + }, + { + label: 'Access Denied', + icon: 'pi pi-fw pi-lock', + routerLink: ['/auth/access'] + } + ] + }, + { + label: 'Crud', + icon: 'pi pi-fw pi-pencil', + routerLink: ['/pages/crud'] + }, + { + label: 'Not Found', + icon: 'pi pi-fw pi-exclamation-circle', + routerLink: ['/pages/notfound'] + }, + { + label: 'Empty', + icon: 'pi pi-fw pi-circle-off', + routerLink: ['/pages/empty'] + }, + { + label: 'Layout', + icon: 'pi pi-fw pi-objects-column', + routerLink: ['/layout'] + } + ] + }, + { + label: 'Hierarchy', + items: [ + { + label: 'Submenu 1', + icon: 'pi pi-fw pi-bookmark', + items: [ + { + label: 'Submenu 1.1', + icon: 'pi pi-fw pi-bookmark', + items: [ + { label: 'Submenu 1.1.1', icon: 'pi pi-fw pi-bookmark' }, + { label: 'Submenu 1.1.2', icon: 'pi pi-fw pi-bookmark' }, + { label: 'Submenu 1.1.3', icon: 'pi pi-fw pi-bookmark' } + ] + }, + { + label: 'Submenu 1.2', + icon: 'pi pi-fw pi-bookmark', + items: [{ label: 'Submenu 1.2.1', icon: 'pi pi-fw pi-bookmark' }] + } + ] + }, + { + label: 'Submenu 2', + icon: 'pi pi-fw pi-bookmark', + items: [ + { + label: 'Submenu 2.1', + icon: 'pi pi-fw pi-bookmark', + items: [ + { label: 'Submenu 2.1.1', icon: 'pi pi-fw pi-bookmark' }, + { label: 'Submenu 2.1.2', icon: 'pi pi-fw pi-bookmark' } + ] + }, + { + label: 'Submenu 2.2', + icon: 'pi pi-fw pi-bookmark', + items: [{ label: 'Submenu 2.2.1', icon: 'pi pi-fw pi-bookmark' }] + } + ] + } + ] + }, + { + label: 'Get Started', + items: [ + { + label: 'Documentation', + icon: 'pi pi-fw pi-book', + routerLink: ['/documentation'] + }, + { + label: 'View Source', + icon: 'pi pi-fw pi-github', + url: 'https://github.com/primefaces/sakai-ng', + target: '_blank' + } + ] + } +]; + +@Injectable({ + providedIn: 'root' +}) +export class ConfigService { + private configState = signal({ + showcaseMenu: [] + }); + + // Public signals + showcaseMenuConfig = signal([]); + + constructor() { + // Load config from localStorage on initialization + this.loadConfig(); + } + + private loadShowcaseMenuConfig(): MenuItem[] { + const stored = localStorage.getItem('demo-showcase-menu-config'); + if (stored) { + try { + return JSON.parse(stored); + } catch (e) { + console.error('Failed to parse showcase menu config from localStorage', e); + } + } + return [...DEFAULT_SHOWCASE_MENU_CONFIG]; + } + + private loadConfig(): void { + const stored = localStorage.getItem('demo-config'); + let config: ConfigState; + + if (stored) { + try { + config = JSON.parse(stored); + } catch (e) { + console.error('Failed to parse config from localStorage', e); + config = { + showcaseMenu: [...DEFAULT_SHOWCASE_MENU_CONFIG] + }; + } + } else { + // Initialize with defaults if no stored config + config = { + showcaseMenu: [...DEFAULT_SHOWCASE_MENU_CONFIG] + }; + } + + this.configState.set(config); + this.showcaseMenuConfig.set(config.showcaseMenu); + + // Save to localStorage if it was empty + if (!stored) { + this.saveConfig(); + } + } + + private saveConfig(): void { + const config = this.configState(); + console.log('Saving config to localStorage:', config); + localStorage.setItem('demo-config', JSON.stringify(config)); + localStorage.setItem('demo-showcase-menu-config', JSON.stringify(config.showcaseMenu)); + console.log('Config saved successfully'); + } + + getConfig(): ConfigState { + return this.configState(); + } + + getShowcaseMenuConfig(): MenuItem[] { + return this.showcaseMenuConfig(); + } + + updateShowcaseMenuConfig(menu: MenuItem[]): void { + this.configState.update(state => ({ ...state, showcaseMenu: menu })); + this.showcaseMenuConfig.set(menu); + this.saveConfig(); + } + + resetShowcaseMenuConfig(): void { + const defaultMenu = [...DEFAULT_SHOWCASE_MENU_CONFIG]; + this.updateShowcaseMenuConfig(defaultMenu); + } + + updateConfig(config: Partial): void { + this.configState.update(state => ({ ...state, ...config })); + if (config.showcaseMenu) { + this.showcaseMenuConfig.set(config.showcaseMenu); + } + this.saveConfig(); + } + + resetConfig(): void { + const defaultConfig: ConfigState = { + showcaseMenu: [...DEFAULT_SHOWCASE_MENU_CONFIG] + }; + this.configState.set(defaultConfig); + this.showcaseMenuConfig.set(defaultConfig.showcaseMenu); + this.saveConfig(); + } + + exportConfigAsYaml(): string { + const config = this.configState(); + try { + return yaml.dump(config, { indent: 2, lineWidth: -1 }); + } catch (e) { + console.error('Failed to export YAML:', e); + return ''; + } + } + + importConfigFromYaml(yamlString: string): void { + try { + const config = yaml.load(yamlString) as ConfigState; + if (!config || typeof config !== 'object') { + throw new Error('Invalid YAML structure'); + } + this.configState.set(config); + if (config.showcaseMenu) { + this.showcaseMenuConfig.set(config.showcaseMenu); + } + this.saveConfig(); + } catch (e) { + throw new Error('Failed to parse YAML: ' + (e as Error).message); + } + } + + exportConfigAsJson(): string { + return JSON.stringify(this.configState(), null, 2); + } + + importConfigFromJson(json: string): void { + try { + const config = JSON.parse(json); + this.configState.set(config); + if (config.showcaseMenu) { + this.showcaseMenuConfig.set(config.showcaseMenu); + } + this.saveConfig(); + } catch (e) { + throw new Error('Failed to parse JSON: ' + (e as Error).message); + } + } +} From b5eef81cf65c6c5e751e780871aac074813fbb1b Mon Sep 17 00:00:00 2001 From: caoquyenis <58635793+caoquyenis@users.noreply.github.com> Date: Tue, 10 Feb 2026 15:51:01 +0700 Subject: [PATCH 05/10] feat: implement theme configuration with dark mode, color selection, and presets --- src/app.component.ts | 14 +- src/app/layout/component/app.configurator.ts | 6 + src/app/layout/component/app.topbar.ts | 27 +- src/app/layout/service/layout.service.ts | 153 ++++++++++- src/app/pages/config/config.ts | 259 ++++++++++++++++++- src/app/pages/layout/layout.ts | 20 +- src/app/pages/service/config.service.ts | 64 ++++- 7 files changed, 482 insertions(+), 61 deletions(-) diff --git a/src/app.component.ts b/src/app.component.ts index e124abf3e..d023fb3a9 100644 --- a/src/app.component.ts +++ b/src/app.component.ts @@ -1,5 +1,6 @@ -import { Component } from '@angular/core'; +import { Component, inject, OnInit } from '@angular/core'; import { RouterModule } from '@angular/router'; +import { LayoutService } from './app/layout/service/layout.service'; @Component({ selector: 'app-root', @@ -7,4 +8,13 @@ import { RouterModule } from '@angular/router'; imports: [RouterModule], template: `` }) -export class AppComponent {} +export class AppComponent implements OnInit { + private layoutService = inject(LayoutService); + + ngOnInit() { + // Force initialization of LayoutService to load theme from storage + // This ensures the theme is applied before any components render + const config = this.layoutService.layoutConfig(); + console.log('App initialized with theme config:', config); + } +} diff --git a/src/app/layout/component/app.configurator.ts b/src/app/layout/component/app.configurator.ts index b8c7ff2d2..db83373e4 100644 --- a/src/app/layout/component/app.configurator.ts +++ b/src/app/layout/component/app.configurator.ts @@ -9,6 +9,7 @@ import Nora from '@primeuix/themes/nora'; import { PrimeNG } from 'primeng/config'; import { SelectButtonModule } from 'primeng/selectbutton'; import { LayoutService } from '@/app/layout/service/layout.service'; +import { ConfigService } from '@/app/pages/service/config.service'; const presets = { Aura, @@ -102,6 +103,8 @@ export class AppConfigurator { layoutService: LayoutService = inject(LayoutService); + configService = inject(ConfigService); + platformId = inject(PLATFORM_ID); primeng = inject(PrimeNG); @@ -417,8 +420,10 @@ export class AppConfigurator { updateColors(event: any, type: string, color: any) { if (type === 'primary') { this.layoutService.layoutConfig.update((state) => ({ ...state, primary: color.name })); + this.configService.updateThemeConfig({ primaryColor: color.name }); } else if (type === 'surface') { this.layoutService.layoutConfig.update((state) => ({ ...state, surface: color.name })); + this.configService.updateThemeConfig({ surfaceColor: color.name }); } this.applyTheme(type, color); @@ -435,6 +440,7 @@ export class AppConfigurator { onPresetChange(event: any) { this.layoutService.layoutConfig.update((state) => ({ ...state, preset: event })); + this.configService.updateThemeConfig({ preset: event }); const preset = presets[event as KeyOfType]; const surfacePalette = this.surfaces.find((s) => s.name === this.selectedSurfaceColor())?.palette; $t().preset(preset).preset(this.getPresetExt()).surfacePalette(surfacePalette).use({ useDefaultOptions: true }); diff --git a/src/app/layout/component/app.topbar.ts b/src/app/layout/component/app.topbar.ts index 9baa4619c..d47ecaf33 100644 --- a/src/app/layout/component/app.topbar.ts +++ b/src/app/layout/component/app.topbar.ts @@ -3,13 +3,13 @@ import { MenuItem } from 'primeng/api'; import { RouterModule } from '@angular/router'; import { CommonModule } from '@angular/common'; import { StyleClassModule } from 'primeng/styleclass'; -import { AppConfigurator } from './app.configurator'; import { LayoutService } from '@/app/layout/service/layout.service'; +import { ConfigService } from '@/app/pages/service/config.service'; @Component({ selector: 'app-topbar', standalone: true, - imports: [RouterModule, CommonModule, StyleClassModule, AppConfigurator], + imports: [RouterModule, CommonModule, StyleClassModule], template: `
-
- - -
- +
- -
- -

Theme Settings

-

- Configure primary colors, dark mode preferences, and other theme-related settings. - This feature will be implemented in a future update. -

+ + +
+

Dark Mode

+
+ + Toggle between light and dark themes +
+
+ +
+

Primary Color

+
+ @for (primaryColor of primaryColors(); track primaryColor.name) { + + } +
+
+ +
+

Surface Color

+
+ @for (surface of surfaces; track surface.name) { + + } +
+
+ +
+

Preset

+
@@ -193,13 +280,92 @@ export class Config { editMode = signal<'yaml' | 'json'>('yaml'); errorMessage = signal(''); isYamlMode = true; + themeConfig = signal({ + darkMode: false, + primaryColor: 'emerald', + surfaceColor: 'slate', + preset: 'Aura' + }); + + layoutService = inject(LayoutService); + platformId = inject(PLATFORM_ID); + primeng = inject(PrimeNG); + + presets = Object.keys(presets); + + surfaces: SurfacesType[] = [ + { name: 'slate', palette: { 0: '#ffffff', 50: '#f8fafc', 100: '#f1f5f9', 200: '#e2e8f0', 300: '#cbd5e1', 400: '#94a3b8', 500: '#64748b', 600: '#475569', 700: '#334155', 800: '#1e293b', 900: '#0f172a', 950: '#020617' } }, + { name: 'gray', palette: { 0: '#ffffff', 50: '#f9fafb', 100: '#f3f4f6', 200: '#e5e7eb', 300: '#d1d5db', 400: '#9ca3af', 500: '#6b7280', 600: '#4b5563', 700: '#374151', 800: '#1f2937', 900: '#111827', 950: '#030712' } }, + { name: 'zinc', palette: { 0: '#ffffff', 50: '#fafafa', 100: '#f4f4f5', 200: '#e4e4e7', 300: '#d4d4d8', 400: '#a1a1aa', 500: '#71717a', 600: '#52525b', 700: '#3f3f46', 800: '#27272a', 900: '#18181b', 950: '#09090b' } }, + { name: 'neutral', palette: { 0: '#ffffff', 50: '#fafafa', 100: '#f5f5f5', 200: '#e5e5e5', 300: '#d4d4d4', 400: '#a3a3a3', 500: '#737373', 600: '#525252', 700: '#404040', 800: '#262626', 900: '#171717', 950: '#0a0a0a' } }, + { name: 'stone', palette: { 0: '#ffffff', 50: '#fafaf9', 100: '#f5f5f4', 200: '#e7e5e4', 300: '#d6d3d1', 400: '#a8a29e', 500: '#78716c', 600: '#57534e', 700: '#44403c', 800: '#292524', 900: '#1c1917', 950: '#0c0a09' } }, + { name: 'soho', palette: { 0: '#ffffff', 50: '#ececec', 100: '#dedfdf', 200: '#c4c4c6', 300: '#adaeb0', 400: '#97979b', 500: '#7f8084', 600: '#6a6b70', 700: '#55565b', 800: '#3f4046', 900: '#2c2c34', 950: '#16161d' } }, + { name: 'viva', palette: { 0: '#ffffff', 50: '#f3f3f3', 100: '#e7e7e8', 200: '#cfd0d0', 300: '#b7b8b9', 400: '#9fa1a1', 500: '#87898a', 600: '#6e7173', 700: '#565a5b', 800: '#3e4244', 900: '#262b2c', 950: '#0e1315' } }, + { name: 'ocean', palette: { 0: '#ffffff', 50: '#fbfcfc', 100: '#F7F9F8', 200: '#EFF3F2', 300: '#DADEDD', 400: '#B1B7B6', 500: '#828787', 600: '#5F7274', 700: '#415B61', 800: '#29444E', 900: '#183240', 950: '#0c1920' } } + ]; + + selectedPrimaryColor = computed(() => this.layoutService.layoutConfig().primary); + selectedSurfaceColor = computed(() => this.layoutService.layoutConfig().surface); + selectedPreset = computed(() => this.layoutService.layoutConfig().preset); + + primaryColors = computed(() => { + const presetPalette = presets[this.layoutService.layoutConfig().preset as KeyOfType].primitive; + const colors = ['emerald', 'green', 'lime', 'orange', 'amber', 'yellow', 'teal', 'cyan', 'sky', 'blue', 'indigo', 'violet', 'purple', 'fuchsia', 'pink', 'rose']; + const palettes: SurfacesType[] = [{ name: 'noir', palette: {} }]; + colors.forEach((color) => { + palettes.push({ + name: color, + palette: presetPalette?.[color as KeyOfType] as SurfacesType['palette'] + }); + }); + return palettes; + }); constructor( private configService: ConfigService, private messageService: MessageService ) { this.reloadConfigCode(); + this.loadThemeConfig(); console.log('Config component initialized, code length:', this.configCode().length); + + if (isPlatformBrowser(this.platformId)) { + // Load and apply saved theme on initialization + const savedTheme = this.configService.getThemeConfig(); + this.layoutService.layoutConfig.update(state => ({ + ...state, + darkTheme: savedTheme.darkMode, + primary: savedTheme.primaryColor, + surface: savedTheme.surfaceColor, + preset: savedTheme.preset + })); + this.onPresetChange(savedTheme.preset); + } + } + + ngOnInit() { + } + + loadThemeConfig() { + const theme = this.configService.getThemeConfig(); + this.themeConfig.set(theme); + } + + updateDarkMode(darkMode: boolean) { + this.configService.updateThemeConfig({ darkMode }); + this.themeConfig.update(config => ({ ...config, darkMode })); + + // Update LayoutService + this.layoutService.layoutConfig.update(state => ({ + ...state, + darkTheme: darkMode + })); + + this.messageService.add({ + severity: 'success', + summary: 'Theme Updated', + detail: `Switched to ${darkMode ? 'dark' : 'light'} mode` + }); } toggleEditMode() { @@ -306,6 +472,73 @@ export class Config { reader.readAsText(file); } + getPresetExt() { + const color: SurfacesType = this.primaryColors().find((c) => c.name === this.selectedPrimaryColor()) || {}; + const preset = this.layoutService.layoutConfig().preset; + + if (color.name === 'noir') { + return { + semantic: { + primary: { 50: '{surface.50}', 100: '{surface.100}', 200: '{surface.200}', 300: '{surface.300}', 400: '{surface.400}', 500: '{surface.500}', 600: '{surface.600}', 700: '{surface.700}', 800: '{surface.800}', 900: '{surface.900}', 950: '{surface.950}' }, + colorScheme: { + light: { primary: { color: '{primary.950}', contrastColor: '#ffffff', hoverColor: '{primary.800}', activeColor: '{primary.700}' }, highlight: { background: '{primary.950}', focusBackground: '{primary.700}', color: '#ffffff', focusColor: '#ffffff' } }, + dark: { primary: { color: '{primary.50}', contrastColor: '{primary.950}', hoverColor: '{primary.200}', activeColor: '{primary.300}' }, highlight: { background: '{primary.50}', focusBackground: '{primary.300}', color: '{primary.950}', focusColor: '{primary.950}' } } + } + } + }; + } else { + if (preset === 'Nora') { + return { + semantic: { + primary: color.palette, + colorScheme: { + light: { primary: { color: '{primary.600}', contrastColor: '#ffffff', hoverColor: '{primary.700}', activeColor: '{primary.800}' }, highlight: { background: '{primary.600}', focusBackground: '{primary.700}', color: '#ffffff', focusColor: '#ffffff' } }, + dark: { primary: { color: '{primary.500}', contrastColor: '{surface.900}', hoverColor: '{primary.400}', activeColor: '{primary.300}' }, highlight: { background: '{primary.500}', focusBackground: '{primary.400}', color: '{surface.900}', focusColor: '{surface.900}' } } + } + } + }; + } else { + return { + semantic: { + primary: color.palette, + colorScheme: { + light: { primary: { color: '{primary.500}', contrastColor: '#ffffff', hoverColor: '{primary.600}', activeColor: '{primary.700}' }, highlight: { background: '{primary.50}', focusBackground: '{primary.100}', color: '{primary.700}', focusColor: '{primary.800}' } }, + dark: { primary: { color: '{primary.400}', contrastColor: '{surface.900}', hoverColor: '{primary.300}', activeColor: '{primary.200}' }, highlight: { background: 'color-mix(in srgb, {primary.400}, transparent 84%)', focusBackground: 'color-mix(in srgb, {primary.400}, transparent 76%)', color: 'rgba(255,255,255,.87)', focusColor: 'rgba(255,255,255,.87)' } } + } + } + }; + } + } + } + + updateColors(event: any, type: string, color: any) { + if (type === 'primary') { + this.layoutService.layoutConfig.update((state) => ({ ...state, primary: color.name })); + this.configService.updateThemeConfig({ primaryColor: color.name }); + } else if (type === 'surface') { + this.layoutService.layoutConfig.update((state) => ({ ...state, surface: color.name })); + this.configService.updateThemeConfig({ surfaceColor: color.name }); + } + this.applyTheme(type, color); + event.stopPropagation(); + } + + applyTheme(type: string, color: any) { + if (type === 'primary') { + updatePreset(this.getPresetExt()); + } else if (type === 'surface') { + updateSurfacePalette(color.palette); + } + } + + onPresetChange(event: any) { + this.layoutService.layoutConfig.update((state) => ({ ...state, preset: event })); + this.configService.updateThemeConfig({ preset: event }); + const preset = presets[event as KeyOfType]; + const surfacePalette = this.surfaces.find((s) => s.name === this.selectedSurfaceColor())?.palette; + $t().preset(preset).preset(this.getPresetExt()).surfacePalette(surfacePalette).use({ useDefaultOptions: true }); + } + private downloadFile(content: string, filename: string, mimeType: string) { const blob = new Blob([content], { type: mimeType }); const url = window.URL.createObjectURL(blob); diff --git a/src/app/pages/layout/layout.ts b/src/app/pages/layout/layout.ts index 066be99bb..5112553ed 100644 --- a/src/app/pages/layout/layout.ts +++ b/src/app/pages/layout/layout.ts @@ -5,7 +5,6 @@ import { DrawerModule } from 'primeng/drawer'; import { ToolbarModule } from 'primeng/toolbar'; import { ButtonModule } from 'primeng/button'; import { PanelMenuModule } from 'primeng/panelmenu'; -import { SelectButtonModule } from 'primeng/selectbutton'; import { AvatarModule } from 'primeng/avatar'; import { BadgeModule } from 'primeng/badge'; import { InputTextModule } from 'primeng/inputtext'; @@ -63,7 +62,7 @@ const DEFAULT_MENU: EditableMenuItem[] = [ standalone: true, imports: [ CommonModule, FormsModule, DrawerModule, ToolbarModule, ButtonModule, - PanelMenuModule, SelectButtonModule, AvatarModule, BadgeModule, + PanelMenuModule, AvatarModule, BadgeModule, InputTextModule, TextareaModule ], template: ` @@ -74,11 +73,7 @@ const DEFAULT_MENU: EditableMenuItem[] = [ Layout Showcase - - - - @@ -320,8 +315,6 @@ export class Layout { mode = signal<'demo' | 'config'>('demo'); leftVisible = signal(true); rightVisible = signal(false); - darkMode = signal(false); - menuItems = signal([]); menuJson = signal(''); jsonError = signal(null); @@ -337,11 +330,6 @@ export class Layout { newItemIcon = 'pi pi-circle'; newItemRoute = ''; - modeOptions = [ - { label: 'Demo', value: 'demo' }, - { label: 'Config', value: 'config' } - ]; - stats = [ { label: 'Orders', value: '152', icon: 'pi pi-shopping-cart', iconClass: 'text-blue-500', bgClass: 'bg-blue-100 dark:bg-blue-400/10', change: '24 new', period: 'since last visit' }, { label: 'Revenue', value: '$2,100', icon: 'pi pi-dollar', iconClass: 'text-orange-500', bgClass: 'bg-orange-100 dark:bg-orange-400/10', change: '%52+', period: 'since last week' }, @@ -383,12 +371,6 @@ export class Layout { this.menuJson.set(JSON.stringify(menu, null, 2)); }); - this.darkMode.set(document.documentElement.classList.contains('app-dark')); - } - - toggleDarkMode() { - this.darkMode.update(v => !v); - document.documentElement.classList.toggle('app-dark'); } // Inline editing diff --git a/src/app/pages/service/config.service.ts b/src/app/pages/service/config.service.ts index df7826b91..ab9f3b504 100644 --- a/src/app/pages/service/config.service.ts +++ b/src/app/pages/service/config.service.ts @@ -4,10 +4,11 @@ import * as yaml from 'js-yaml'; export interface ConfigState { showcaseMenu: MenuItem[]; // Menu for showcase demos, not the main app menu - // Placeholder for future configs - theme?: { - primaryColor?: string; - darkMode?: boolean; + theme: { + darkMode: boolean; + primaryColor: string; + surfaceColor: string; + preset: string; }; notifications?: { enabled?: boolean; @@ -164,7 +165,13 @@ const DEFAULT_SHOWCASE_MENU_CONFIG: MenuItem[] = [ }) export class ConfigService { private configState = signal({ - showcaseMenu: [] + showcaseMenu: [], + theme: { + darkMode: false, + primaryColor: 'emerald', + surfaceColor: 'slate', + preset: 'Aura' + } }); // Public signals @@ -197,25 +204,60 @@ export class ConfigService { } catch (e) { console.error('Failed to parse config from localStorage', e); config = { - showcaseMenu: [...DEFAULT_SHOWCASE_MENU_CONFIG] + showcaseMenu: [...DEFAULT_SHOWCASE_MENU_CONFIG], + theme: { + darkMode: false, + primaryColor: 'emerald', + surfaceColor: 'slate', + preset: 'Aura' + } }; } } else { // Initialize with defaults if no stored config config = { - showcaseMenu: [...DEFAULT_SHOWCASE_MENU_CONFIG] + showcaseMenu: [...DEFAULT_SHOWCASE_MENU_CONFIG], + theme: { + darkMode: false, + primaryColor: 'emerald', + surfaceColor: 'slate', + preset: 'Aura' + } }; } this.configState.set(config); this.showcaseMenuConfig.set(config.showcaseMenu); + // Ensure theme defaults exist + if (!config.theme) { + config.theme = { + darkMode: false, + primaryColor: 'emerald', + surfaceColor: 'slate', + preset: 'Aura' + }; + this.configState.set(config); + } + // Save to localStorage if it was empty if (!stored) { this.saveConfig(); } } + getThemeConfig() { + return this.configState().theme; + } + + updateThemeConfig(theme: Partial) { + this.configState.update(state => ({ + ...state, + theme: { ...state.theme, ...theme } + })); + this.saveConfig(); + } + private saveConfig(): void { const config = this.configState(); console.log('Saving config to localStorage:', config); @@ -253,7 +295,13 @@ export class ConfigService { resetConfig(): void { const defaultConfig: ConfigState = { - showcaseMenu: [...DEFAULT_SHOWCASE_MENU_CONFIG] + showcaseMenu: [...DEFAULT_SHOWCASE_MENU_CONFIG], + theme: { + darkMode: false, + primaryColor: 'emerald', + surfaceColor: 'slate', + preset: 'Aura' + } }; this.configState.set(defaultConfig); this.showcaseMenuConfig.set(defaultConfig.showcaseMenu); From 39107f39c51ca92e07b747424e54cc329237ed99 Mon Sep 17 00:00:00 2001 From: caoquyenis <58635793+caoquyenis@users.noreply.github.com> Date: Tue, 10 Feb 2026 16:09:27 +0700 Subject: [PATCH 06/10] feat: add layout builder component with YAML configuration and dynamic rendering --- src/app/layout/component/app.menu.ts | 5 + src/app/pages/builder/builder.service.ts | 236 ++++++++++++++++++ src/app/pages/builder/builder.ts | 199 +++++++++++++++ .../builder/dynamic-renderer.component.ts | 190 ++++++++++++++ src/app/pages/pages.routes.ts | 2 + 5 files changed, 632 insertions(+) create mode 100644 src/app/pages/builder/builder.service.ts create mode 100644 src/app/pages/builder/builder.ts create mode 100644 src/app/pages/builder/dynamic-renderer.component.ts diff --git a/src/app/layout/component/app.menu.ts b/src/app/layout/component/app.menu.ts index 5e3e233e5..56fcd3b6e 100644 --- a/src/app/layout/component/app.menu.ts +++ b/src/app/layout/component/app.menu.ts @@ -103,6 +103,11 @@ export class AppMenu { label: 'Config', icon: 'pi pi-fw pi-cog', routerLink: ['/pages/config'] + }, + { + label: 'Layout Builder', + icon: 'pi pi-fw pi-code', + routerLink: ['/pages/builder'] } ] }, diff --git a/src/app/pages/builder/builder.service.ts b/src/app/pages/builder/builder.service.ts new file mode 100644 index 000000000..57177326f --- /dev/null +++ b/src/app/pages/builder/builder.service.ts @@ -0,0 +1,236 @@ +import { Injectable, signal } from '@angular/core'; +import * as yaml from 'js-yaml'; + +export interface BlockConfig { + type: string; + props?: Record; + blocks?: BlockConfig[]; + fields?: FieldConfig[]; + columns?: ColumnConfig[]; + data?: any[]; +} + +export interface FieldConfig { + name: string; + label: string; + type: string; + placeholder?: string; + required?: boolean; + defaultValue?: any; + rows?: number; + options?: { label: string; value: any }[]; +} + +export interface ColumnConfig { + field: string; + header: string; +} + +export interface PageConfig { + id: string; + title: string; + path?: string; + description?: string; + blocks: BlockConfig[]; +} + +export interface LayoutBuilderConfig { + pages: PageConfig[]; +} + +const STORAGE_KEY = 'layout-builder-config'; + +const DEFAULT_YAML = `pages: + - id: welcome + title: Welcome + description: Getting started with Layout Builder + blocks: + - type: message + props: + severity: info + text: "Edit the YAML on the left to see changes here!" + + - type: card + props: + header: Sample Form + blocks: + - type: form + props: + layout: vertical + fields: + - name: username + label: Username + type: text + placeholder: Enter username + - name: email + label: Email + type: text + placeholder: Enter email + - name: role + label: Role + type: select + options: + - { label: Admin, value: admin } + - { label: User, value: user } + - { label: Guest, value: guest } + - name: active + label: Active + type: checkbox + - name: birthdate + label: Birth Date + type: date + + - type: button + props: + label: Submit + icon: pi pi-check + severity: primary + + - type: divider + + - type: panel + props: + header: Data Table + toggleable: true + blocks: + - type: table + props: + paginator: true + rows: 5 + columns: + - { field: id, header: ID } + - { field: name, header: Name } + - { field: status, header: Status } + data: + - { id: 1, name: Alice, status: Active } + - { id: 2, name: Bob, status: Inactive } + - { id: 3, name: Charlie, status: Active } + - { id: 4, name: Diana, status: Active } + - { id: 5, name: Eve, status: Inactive } + - { id: 6, name: Frank, status: Active } + + - id: dashboard + title: Dashboard + description: Sample dashboard page + blocks: + - type: message + props: + severity: success + text: "Welcome to the dashboard!" + + - type: card + props: + header: Quick Stats + blocks: + - type: table + props: + rows: 5 + columns: + - { field: metric, header: Metric } + - { field: value, header: Value } + - { field: change, header: Change } + data: + - { metric: Users, value: "1,234", change: "+12%" } + - { metric: Revenue, value: "$5,678", change: "+8%" } + - { metric: Orders, value: "456", change: "-3%" } +`; + +@Injectable({ + providedIn: 'root' +}) +export class LayoutBuilderService { + yamlCode = signal(''); + errors = signal([]); + selectedPageId = signal(''); + parsedConfig = signal(null); + pages = signal([]); + + constructor() { + this.load(); + this.parse(); + } + + private parse(): void { + const code = this.yamlCode(); + if (!code.trim()) { + this.parsedConfig.set(null); + this.pages.set([]); + this.errors.set([]); + return; + } + try { + const parsed = yaml.load(code) as any; + const validationErrors = this.validate(parsed); + if (validationErrors.length > 0) { + this.errors.set(validationErrors); + this.parsedConfig.set(null); + this.pages.set([]); + return; + } + this.errors.set([]); + const config = parsed as LayoutBuilderConfig; + this.parsedConfig.set(config); + this.pages.set(config.pages ?? []); + } catch (e) { + this.errors.set([(e as Error).message]); + this.parsedConfig.set(null); + this.pages.set([]); + } + } + + private validate(config: any): string[] { + const errors: string[] = []; + if (!config || typeof config !== 'object') { + errors.push('Configuration must be a YAML object'); + return errors; + } + if (!Array.isArray(config.pages)) { + errors.push('"pages" must be an array'); + return errors; + } + config.pages.forEach((page: any, i: number) => { + if (!page.id) errors.push(`Page ${i + 1}: missing "id"`); + if (!page.title) errors.push(`Page ${i + 1}: missing "title"`); + if (!Array.isArray(page.blocks)) errors.push(`Page ${i + 1}: "blocks" must be an array`); + }); + return errors; + } + + load(): void { + try { + const stored = localStorage.getItem(STORAGE_KEY); + this.yamlCode.set(stored || DEFAULT_YAML); + } catch { + this.yamlCode.set(DEFAULT_YAML); + } + } + + save(): void { + try { + localStorage.setItem(STORAGE_KEY, this.yamlCode()); + } catch (e) { + console.error('Failed to save layout builder config', e); + } + } + + applyYaml(code: string): boolean { + this.yamlCode.set(code); + this.parse(); + if (this.parsedConfig()) { + this.save(); + return true; + } + return false; + } + + exportAsJson(): string { + const config = this.parsedConfig(); + return config ? JSON.stringify(config, null, 2) : ''; + } + + reset(): void { + this.yamlCode.set(DEFAULT_YAML); + this.parse(); + this.save(); + } +} diff --git a/src/app/pages/builder/builder.ts b/src/app/pages/builder/builder.ts new file mode 100644 index 000000000..5eaa18c71 --- /dev/null +++ b/src/app/pages/builder/builder.ts @@ -0,0 +1,199 @@ +import { Component, signal, inject, computed } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { SplitterModule } from 'primeng/splitter'; +import { TabsModule } from 'primeng/tabs'; +import { ButtonModule } from 'primeng/button'; +import { TextareaModule } from 'primeng/textarea'; +import { MessageModule } from 'primeng/message'; +import { FileUploadModule } from 'primeng/fileupload'; +import { ToastModule } from 'primeng/toast'; +import { ToggleButtonModule } from 'primeng/togglebutton'; +import { MessageService } from 'primeng/api'; +import { LayoutBuilderService } from './builder.service'; +import { DynamicRendererComponent } from './dynamic-renderer.component'; + +@Component({ + selector: 'app-layout-builder', + standalone: true, + imports: [ + CommonModule, FormsModule, + SplitterModule, TabsModule, ButtonModule, TextareaModule, + MessageModule, FileUploadModule, ToastModule, ToggleButtonModule, + DynamicRendererComponent + ], + providers: [MessageService], + template: ` +
+
+
+

Layout Builder

+

Define pages and components using YAML, see them rendered live

+
+
+ + + + +
+
+ + + +
+
+ YAML Editor +
+ +
+
+ + @for (err of builderService.errors(); track $index) { + + } + + +
+
+ + +
+ Live Preview + + @if (builderService.pages().length > 0) { + + + @for (page of builderService.pages(); track page.id) { + {{ page.title }} + } + + + @for (page of builderService.pages(); track page.id) { + + @if (page.description) { +

{{ page.description }}

+ } +
+ @for (block of page.blocks; track $index) { + + } +
+
+ } +
+
+ } @else { +
+ +

Write YAML on the left and click "Apply" to see the preview

+
+ } +
+
+
+
+ + + ` +}) +export class LayoutBuilder { + builderService = inject(LayoutBuilderService); + private messageService = inject(MessageService); + + editorCode = signal(''); + + constructor() { + this.editorCode.set(this.builderService.yamlCode()); + } + + applyChanges(): void { + const success = this.builderService.applyYaml(this.editorCode()); + if (success) { + this.messageService.add({ + severity: 'success', + summary: 'Applied', + detail: 'Configuration applied successfully' + }); + } else { + this.messageService.add({ + severity: 'error', + summary: 'Error', + detail: 'Failed to parse YAML. Check errors above.' + }); + } + } + + exportYaml(): void { + this.downloadFile(this.builderService.yamlCode(), 'layout-config.yaml', 'text/yaml'); + this.messageService.add({ severity: 'success', summary: 'Exported', detail: 'YAML exported' }); + } + + exportJson(): void { + const json = this.builderService.exportAsJson(); + if (json) { + this.downloadFile(json, 'layout-config.json', 'application/json'); + this.messageService.add({ severity: 'success', summary: 'Exported', detail: 'JSON exported' }); + } + } + + reset(): void { + this.builderService.reset(); + this.editorCode.set(this.builderService.yamlCode()); + this.messageService.add({ severity: 'success', summary: 'Reset', detail: 'Configuration reset to default' }); + } + + onFileImport(event: any): void { + const file = event.files[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (e: any) => { + try { + let content = e.target.result as string; + + if (file.name.endsWith('.json')) { + const parsed = JSON.parse(content); + const yaml = (window as any).jsyaml?.dump?.(parsed); + content = yaml || content; + } + + this.editorCode.set(content); + const success = this.builderService.applyYaml(content); + if (success) { + this.messageService.add({ severity: 'success', summary: 'Imported', detail: `Imported from ${file.name}` }); + } else { + this.messageService.add({ severity: 'error', summary: 'Import Error', detail: 'File content is invalid' }); + } + } catch (err) { + this.messageService.add({ severity: 'error', summary: 'Import Failed', detail: (err as Error).message }); + } + }; + reader.readAsText(file); + } + + private downloadFile(content: string, filename: string, mimeType: string): void { + const blob = new Blob([content], { type: mimeType }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + link.click(); + window.URL.revokeObjectURL(url); + } +} diff --git a/src/app/pages/builder/dynamic-renderer.component.ts b/src/app/pages/builder/dynamic-renderer.component.ts new file mode 100644 index 000000000..4f2af0a58 --- /dev/null +++ b/src/app/pages/builder/dynamic-renderer.component.ts @@ -0,0 +1,190 @@ +import { Component, Input, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { CardModule } from 'primeng/card'; +import { PanelModule } from 'primeng/panel'; +import { TableModule } from 'primeng/table'; +import { ButtonModule } from 'primeng/button'; +import { MessageModule } from 'primeng/message'; +import { DividerModule } from 'primeng/divider'; +import { InputTextModule } from 'primeng/inputtext'; +import { TextareaModule } from 'primeng/textarea'; +import { CheckboxModule } from 'primeng/checkbox'; +import { SelectModule } from 'primeng/select'; +import { DatePickerModule } from 'primeng/datepicker'; +import { InputNumberModule } from 'primeng/inputnumber'; +import { BlockConfig, FieldConfig } from './builder.service'; + +@Component({ + selector: 'app-dynamic-renderer', + standalone: true, + imports: [ + CommonModule, FormsModule, + CardModule, PanelModule, TableModule, ButtonModule, + MessageModule, DividerModule, InputTextModule, TextareaModule, + CheckboxModule, SelectModule, DatePickerModule, InputNumberModule + ], + template: ` + @switch (block.type) { + @case ('message') { + + } + @case ('divider') { + + } + @case ('button') { + + } + @case ('card') { + +
+ @for (child of block.blocks || []; track $index) { + + } +
+
+ } + @case ('panel') { + +
+ @for (child of block.blocks || []; track $index) { + + } +
+
+ } + @case ('form') { +
+ @for (field of block.fields || []; track field.name) { +
+ @switch (field.type) { + @case ('checkbox') { +
+ + +
+ } + @default { + + @switch (field.type) { + @case ('text') { + + } + @case ('number') { + + } + @case ('textarea') { + + } + @case ('select') { + + } + @case ('date') { + + } + @default { + + } + } + } + } +
+ } +
+ } + @case ('table') { + + + + @for (col of block.columns || []; track col.field) { + {{ col.header }} + } + + + + + @for (col of block.columns || []; track col.field) { + {{ row[col.field] }} + } + + + + + + No data available + + + + + } + @default { + + } + } + ` +}) +export class DynamicRendererComponent { + @Input() block!: BlockConfig; + + private formData: Record = {}; + + getFormValue(name: string): any { + return this.formData[name] ?? null; + } + + setFormValue(name: string, value: any): void { + this.formData = { ...this.formData, [name]: value }; + } +} diff --git a/src/app/pages/pages.routes.ts b/src/app/pages/pages.routes.ts index 533ca964c..977a5066d 100644 --- a/src/app/pages/pages.routes.ts +++ b/src/app/pages/pages.routes.ts @@ -3,11 +3,13 @@ import { Documentation } from './documentation/documentation'; import { Crud } from './crud/crud'; import { Empty } from './empty/empty'; import { Config } from './config/config'; +import { LayoutBuilder } from './builder/builder'; export default [ { path: 'documentation', component: Documentation }, { path: 'crud', component: Crud }, { path: 'empty', component: Empty }, { path: 'config', component: Config }, + { path: 'builder', component: LayoutBuilder }, { path: '**', redirectTo: '/notfound' } ] as Routes; From d1c06caecac2b85f780e8a6d6ee6d0ab7ba8a45a Mon Sep 17 00:00:00 2001 From: caoquyenis <58635793+caoquyenis@users.noreply.github.com> Date: Tue, 10 Feb 2026 16:10:35 +0700 Subject: [PATCH 07/10] feat: add route for layout builder and update menu link --- src/app.routes.ts | 2 ++ src/app/layout/component/app.menu.ts | 2 +- src/app/pages/pages.routes.ts | 3 --- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/app.routes.ts b/src/app.routes.ts index df5a18fa8..d1ab85883 100644 --- a/src/app.routes.ts +++ b/src/app.routes.ts @@ -5,6 +5,7 @@ import { Documentation } from './app/pages/documentation/documentation'; import { Landing } from './app/pages/landing/landing'; import { Layout } from './app/pages/layout/layout'; import { Notfound } from './app/pages/notfound/notfound'; +import { LayoutBuilder } from './app/pages/builder/builder'; export const appRoutes: Routes = [ { @@ -19,6 +20,7 @@ export const appRoutes: Routes = [ }, { path: 'landing', component: Landing }, { path: 'layout', component: Layout }, + { path: 'builder', component: LayoutBuilder }, { path: 'notfound', component: Notfound }, { path: 'auth', loadChildren: () => import('./app/pages/auth/auth.routes') }, { path: '**', redirectTo: '/notfound' } diff --git a/src/app/layout/component/app.menu.ts b/src/app/layout/component/app.menu.ts index 56fcd3b6e..deb9d6e3c 100644 --- a/src/app/layout/component/app.menu.ts +++ b/src/app/layout/component/app.menu.ts @@ -107,7 +107,7 @@ export class AppMenu { { label: 'Layout Builder', icon: 'pi pi-fw pi-code', - routerLink: ['/pages/builder'] + routerLink: ['/builder'] } ] }, diff --git a/src/app/pages/pages.routes.ts b/src/app/pages/pages.routes.ts index 977a5066d..dfce46ecf 100644 --- a/src/app/pages/pages.routes.ts +++ b/src/app/pages/pages.routes.ts @@ -3,13 +3,10 @@ import { Documentation } from './documentation/documentation'; import { Crud } from './crud/crud'; import { Empty } from './empty/empty'; import { Config } from './config/config'; -import { LayoutBuilder } from './builder/builder'; - export default [ { path: 'documentation', component: Documentation }, { path: 'crud', component: Crud }, { path: 'empty', component: Empty }, { path: 'config', component: Config }, - { path: 'builder', component: LayoutBuilder }, { path: '**', redirectTo: '/notfound' } ] as Routes; From 9b2ab94a727c7b7ff19b5e4a2ea3c42099cd6604 Mon Sep 17 00:00:00 2001 From: caoquyenis <58635793+caoquyenis@users.noreply.github.com> Date: Tue, 10 Feb 2026 16:15:33 +0700 Subject: [PATCH 08/10] feat: integrate CodeMirror for YAML editing with dark mode support --- package-lock.json | 187 ++++++++++++++++++ package.json | 5 + src/app/pages/builder/builder.ts | 35 ++-- src/app/pages/builder/codemirror.component.ts | 135 +++++++++++++ 4 files changed, 341 insertions(+), 21 deletions(-) create mode 100644 src/app/pages/builder/codemirror.component.ts diff --git a/package-lock.json b/package-lock.json index 3c753de21..d849a508b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,9 +15,14 @@ "@angular/platform-browser": "^21", "@angular/platform-browser-dynamic": "^21", "@angular/router": "^21", + "@codemirror/lang-yaml": "^6.1.2", + "@codemirror/state": "^6.5.4", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.39.13", "@primeuix/themes": "^2.0.0", "@tailwindcss/postcss": "^4.1.11", "chart.js": "4.4.2", + "codemirror": "^6.0.2", "js-yaml": "^4.1.1", "primeclt": "^0.1.5", "primeicons": "^7.0.0", @@ -2511,6 +2516,114 @@ "node": ">=6.9.0" } }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz", + "integrity": "sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.2.tgz", + "integrity": "sha512-vvX1fsih9HledO1c9zdotZYUZnE4xV0m6i3m25s5DIfXofuprk6cRcLUZvSk3CASUbwjQX21tOGbkY2BH8TpnQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.4.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-yaml": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.2.tgz", + "integrity": "sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.2.0", + "@lezer/lr": "^1.0.0", + "@lezer/yaml": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.1", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.1.tgz", + "integrity": "sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.3", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.3.tgz", + "integrity": "sha512-y3YkYhdnhjDBAe0VIA0c4wVoFOvnp8CnAvfLqi0TqotIv92wIlAAP7HELOpLBsKwjAX6W92rSflA6an/2zBvXw==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.35.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.6.0.tgz", + "integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.4.tgz", + "integrity": "sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/theme-one-dark": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz", + "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.39.13", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.13.tgz", + "integrity": "sha512-QBO8ZsgJLCbI28KdY0/oDy5NQLqOQVZCozBknxc2/7L98V+TVYFHnfaCsnGh1U+alpd2LOkStVwYY7nW2R1xbw==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.5.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -3807,6 +3920,41 @@ "dev": true, "license": "MIT" }, + "node_modules/@lezer/common": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz", + "integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.8.tgz", + "integrity": "sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/yaml": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz", + "integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.4.0" + } + }, "node_modules/@listr2/prompt-adapter-inquirer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-3.0.5.tgz", @@ -3922,6 +4070,12 @@ "win32" ] }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "license": "MIT" + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.24.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.24.0.tgz", @@ -7909,6 +8063,21 @@ "integrity": "sha512-q4dMFMlXtKR3XNBHyMHt/3pwYNA69EDk00lloMOaaUMKPUXBw6lpXtbu3MMVG6/uOihGnRDOlkyqsONEUj60+w==", "license": "MIT" }, + "node_modules/codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -8266,6 +8435,12 @@ } } }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -17150,6 +17325,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -18898,6 +19079,12 @@ "node": ">=0.10.0" } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/watchpack": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", diff --git a/package.json b/package.json index f83dc72c3..50944531a 100644 --- a/package.json +++ b/package.json @@ -18,9 +18,14 @@ "@angular/platform-browser": "^21", "@angular/platform-browser-dynamic": "^21", "@angular/router": "^21", + "@codemirror/lang-yaml": "^6.1.2", + "@codemirror/state": "^6.5.4", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.39.13", "@primeuix/themes": "^2.0.0", "@tailwindcss/postcss": "^4.1.11", "chart.js": "4.4.2", + "codemirror": "^6.0.2", "js-yaml": "^4.1.1", "primeclt": "^0.1.5", "primeicons": "^7.0.0", diff --git a/src/app/pages/builder/builder.ts b/src/app/pages/builder/builder.ts index 5eaa18c71..faab91b34 100644 --- a/src/app/pages/builder/builder.ts +++ b/src/app/pages/builder/builder.ts @@ -4,23 +4,23 @@ import { FormsModule } from '@angular/forms'; import { SplitterModule } from 'primeng/splitter'; import { TabsModule } from 'primeng/tabs'; import { ButtonModule } from 'primeng/button'; -import { TextareaModule } from 'primeng/textarea'; import { MessageModule } from 'primeng/message'; import { FileUploadModule } from 'primeng/fileupload'; import { ToastModule } from 'primeng/toast'; -import { ToggleButtonModule } from 'primeng/togglebutton'; import { MessageService } from 'primeng/api'; import { LayoutBuilderService } from './builder.service'; import { DynamicRendererComponent } from './dynamic-renderer.component'; +import { CodemirrorComponent } from './codemirror.component'; +import { LayoutService } from '@/app/layout/service/layout.service'; @Component({ selector: 'app-layout-builder', standalone: true, imports: [ CommonModule, FormsModule, - SplitterModule, TabsModule, ButtonModule, TextareaModule, - MessageModule, FileUploadModule, ToastModule, ToggleButtonModule, - DynamicRendererComponent + SplitterModule, TabsModule, ButtonModule, + MessageModule, FileUploadModule, ToastModule, + DynamicRendererComponent, CodemirrorComponent ], providers: [MessageService], template: ` @@ -61,14 +61,12 @@ import { DynamicRendererComponent } from './dynamic-renderer.component'; } - +
@@ -114,9 +112,11 @@ import { DynamicRendererComponent } from './dynamic-renderer.component'; }) export class LayoutBuilder { builderService = inject(LayoutBuilderService); + private layoutService = inject(LayoutService); private messageService = inject(MessageService); editorCode = signal(''); + isDark = computed(() => this.layoutService.isDarkTheme()); constructor() { this.editorCode.set(this.builderService.yamlCode()); @@ -165,14 +165,7 @@ export class LayoutBuilder { const reader = new FileReader(); reader.onload = (e: any) => { try { - let content = e.target.result as string; - - if (file.name.endsWith('.json')) { - const parsed = JSON.parse(content); - const yaml = (window as any).jsyaml?.dump?.(parsed); - content = yaml || content; - } - + const content = e.target.result as string; this.editorCode.set(content); const success = this.builderService.applyYaml(content); if (success) { diff --git a/src/app/pages/builder/codemirror.component.ts b/src/app/pages/builder/codemirror.component.ts new file mode 100644 index 000000000..05aff3316 --- /dev/null +++ b/src/app/pages/builder/codemirror.component.ts @@ -0,0 +1,135 @@ +import { Component, Input, Output, EventEmitter, ElementRef, ViewChild, AfterViewInit, OnDestroy, OnChanges, SimpleChanges } from '@angular/core'; +import { EditorState } from '@codemirror/state'; +import { EditorView, keymap, lineNumbers, highlightActiveLineGutter, highlightSpecialChars, drawSelection, highlightActiveLine, rectangularSelection, crosshairCursor } from '@codemirror/view'; +import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'; +import { syntaxHighlighting, defaultHighlightStyle, indentOnInput, bracketMatching, foldGutter, foldKeymap } from '@codemirror/language'; +import { closeBrackets, closeBracketsKeymap, autocompletion, completionKeymap } from '@codemirror/autocomplete'; +import { searchKeymap, highlightSelectionMatches } from '@codemirror/search'; +import { lintKeymap } from '@codemirror/lint'; +import { yaml } from '@codemirror/lang-yaml'; +import { oneDark } from '@codemirror/theme-one-dark'; + +@Component({ + selector: 'app-codemirror', + standalone: true, + template: `
`, + styles: [` + :host { + display: block; + width: 100%; + height: 100%; + } + .codemirror-host { + width: 100%; + height: 100%; + } + :host ::ng-deep .cm-editor { + height: 100%; + border: 1px solid var(--p-surface-300); + border-radius: var(--p-content-border-radius); + } + :host ::ng-deep .cm-editor.cm-focused { + outline: none; + border-color: var(--p-primary-color); + } + :host ::ng-deep .cm-scroller { + overflow: auto; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 13px; + } + `] +}) +export class CodemirrorComponent implements AfterViewInit, OnDestroy, OnChanges { + @ViewChild('editorHost', { static: true }) editorHost!: ElementRef; + @Input() value: string = ''; + @Input() darkMode: boolean = false; + @Output() valueChange = new EventEmitter(); + + private view: EditorView | null = null; + private ignoreNextChange = false; + + ngAfterViewInit(): void { + this.createEditor(); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes['value'] && this.view && !this.ignoreNextChange) { + const current = this.view.state.doc.toString(); + if (current !== this.value) { + this.view.dispatch({ + changes: { from: 0, to: current.length, insert: this.value } + }); + } + } + this.ignoreNextChange = false; + + if (changes['darkMode'] && this.view) { + this.recreateEditor(); + } + } + + ngOnDestroy(): void { + this.view?.destroy(); + } + + private createEditor(): void { + const extensions = [ + lineNumbers(), + highlightActiveLineGutter(), + highlightSpecialChars(), + history(), + foldGutter(), + drawSelection(), + indentOnInput(), + syntaxHighlighting(defaultHighlightStyle, { fallback: true }), + bracketMatching(), + closeBrackets(), + autocompletion(), + rectangularSelection(), + crosshairCursor(), + highlightActiveLine(), + highlightSelectionMatches(), + keymap.of([ + ...closeBracketsKeymap, + ...defaultKeymap, + ...searchKeymap, + ...historyKeymap, + ...foldKeymap, + ...completionKeymap, + ...lintKeymap, + indentWithTab + ]), + yaml(), + EditorView.updateListener.of((update) => { + if (update.docChanged) { + this.ignoreNextChange = true; + this.valueChange.emit(update.state.doc.toString()); + } + }), + EditorView.theme({ + '&': { height: '100%' } + }) + ]; + + if (this.darkMode) { + extensions.push(oneDark); + } + + const state = EditorState.create({ + doc: this.value, + extensions + }); + + this.view = new EditorView({ + state, + parent: this.editorHost.nativeElement + }); + } + + private recreateEditor(): void { + const currentValue = this.view?.state.doc.toString() ?? this.value; + this.view?.destroy(); + this.value = currentValue; + this.createEditor(); + } +} From 2664c89c5394c8576f6b89f9c2f0d2a31b0b68e4 Mon Sep 17 00:00:00 2001 From: caoquyenis <58635793+caoquyenis@users.noreply.github.com> Date: Tue, 10 Feb 2026 16:23:07 +0700 Subject: [PATCH 09/10] feat: enhance layout builder with component defaults and improved YAML configuration --- src/app/pages/builder/builder.service.ts | 48 ++++++++++++++---- src/app/pages/builder/builder.ts | 2 +- .../builder/dynamic-renderer.component.ts | 50 ++++++++++++------- 3 files changed, 69 insertions(+), 31 deletions(-) diff --git a/src/app/pages/builder/builder.service.ts b/src/app/pages/builder/builder.service.ts index 57177326f..4bc94e301 100644 --- a/src/app/pages/builder/builder.service.ts +++ b/src/app/pages/builder/builder.service.ts @@ -34,20 +34,43 @@ export interface PageConfig { blocks: BlockConfig[]; } +export interface ComponentDefaults { + [componentType: string]: Record; +} + export interface LayoutBuilderConfig { + config?: ComponentDefaults; pages: PageConfig[]; } const STORAGE_KEY = 'layout-builder-config'; -const DEFAULT_YAML = `pages: +const DEFAULT_YAML = `# Default config per component type. +# Each block inherits these unless it overrides with its own props. +config: + button: + severity: primary + outlined: false + table: + paginator: true + rows: 5 + stripedRows: true + showGridlines: false + form: + layout: vertical + message: + severity: info + panel: + toggleable: true + collapsed: false + +pages: - id: welcome title: Welcome description: Getting started with Layout Builder blocks: - type: message props: - severity: info text: "Edit the YAML on the left to see changes here!" - type: card @@ -55,8 +78,6 @@ const DEFAULT_YAML = `pages: header: Sample Form blocks: - type: form - props: - layout: vertical fields: - name: username label: Username @@ -84,19 +105,14 @@ const DEFAULT_YAML = `pages: props: label: Submit icon: pi pi-check - severity: primary - type: divider - type: panel props: header: Data Table - toggleable: true blocks: - type: table - props: - paginator: true - rows: 5 columns: - { field: id, header: ID } - { field: name, header: Name } @@ -123,8 +139,6 @@ const DEFAULT_YAML = `pages: header: Quick Stats blocks: - type: table - props: - rows: 5 columns: - { field: metric, header: Metric } - { field: value, header: Value } @@ -133,6 +147,13 @@ const DEFAULT_YAML = `pages: - { metric: Users, value: "1,234", change: "+12%" } - { metric: Revenue, value: "$5,678", change: "+8%" } - { metric: Orders, value: "456", change: "-3%" } + + - type: button + props: + label: Export Report + icon: pi pi-download + severity: secondary + outlined: true `; @Injectable({ @@ -144,6 +165,7 @@ export class LayoutBuilderService { selectedPageId = signal(''); parsedConfig = signal(null); pages = signal([]); + componentDefaults = signal({}); constructor() { this.load(); @@ -155,6 +177,7 @@ export class LayoutBuilderService { if (!code.trim()) { this.parsedConfig.set(null); this.pages.set([]); + this.componentDefaults.set({}); this.errors.set([]); return; } @@ -165,16 +188,19 @@ export class LayoutBuilderService { this.errors.set(validationErrors); this.parsedConfig.set(null); this.pages.set([]); + this.componentDefaults.set({}); return; } this.errors.set([]); const config = parsed as LayoutBuilderConfig; this.parsedConfig.set(config); this.pages.set(config.pages ?? []); + this.componentDefaults.set(config.config ?? {}); } catch (e) { this.errors.set([(e as Error).message]); this.parsedConfig.set(null); this.pages.set([]); + this.componentDefaults.set({}); } } diff --git a/src/app/pages/builder/builder.ts b/src/app/pages/builder/builder.ts index faab91b34..b8f0a96fb 100644 --- a/src/app/pages/builder/builder.ts +++ b/src/app/pages/builder/builder.ts @@ -89,7 +89,7 @@ import { LayoutService } from '@/app/layout/service/layout.service'; }
@for (block of page.blocks; track $index) { - + }
diff --git a/src/app/pages/builder/dynamic-renderer.component.ts b/src/app/pages/builder/dynamic-renderer.component.ts index 4f2af0a58..337627e99 100644 --- a/src/app/pages/builder/dynamic-renderer.component.ts +++ b/src/app/pages/builder/dynamic-renderer.component.ts @@ -1,4 +1,4 @@ -import { Component, Input, signal } from '@angular/core'; +import { Component, Input } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { CardModule } from 'primeng/card'; @@ -13,7 +13,7 @@ import { CheckboxModule } from 'primeng/checkbox'; import { SelectModule } from 'primeng/select'; import { DatePickerModule } from 'primeng/datepicker'; import { InputNumberModule } from 'primeng/inputnumber'; -import { BlockConfig, FieldConfig } from './builder.service'; +import { BlockConfig, FieldConfig, ComponentDefaults } from './builder.service'; @Component({ selector: 'app-dynamic-renderer', @@ -27,42 +27,42 @@ import { BlockConfig, FieldConfig } from './builder.service'; template: ` @switch (block.type) { @case ('message') { - + } @case ('divider') { - + } @case ('button') { } @case ('card') { - +
@for (child of block.blocks || []; track $index) { - + }
} @case ('panel') { - +
@for (child of block.blocks || []; track $index) { - + }
} @case ('form') { -
+
@for (field of block.fields || []; track field.name) { -
+
@switch (field.type) { @case ('checkbox') {
@@ -141,10 +141,10 @@ import { BlockConfig, FieldConfig } from './builder.service'; @case ('table') { @@ -177,9 +177,21 @@ import { BlockConfig, FieldConfig } from './builder.service'; }) export class DynamicRendererComponent { @Input() block!: BlockConfig; + @Input() componentDefaults: ComponentDefaults = {}; private formData: Record = {}; + /** Resolve a prop: block.props[key] > config[block.type][key] > fallback */ + prop(key: string, fallback?: any): any { + const blockVal = this.block.props?.[key]; + if (blockVal !== undefined && blockVal !== null) return blockVal; + + const defaultVal = this.componentDefaults[this.block.type]?.[key]; + if (defaultVal !== undefined && defaultVal !== null) return defaultVal; + + return fallback ?? null; + } + getFormValue(name: string): any { return this.formData[name] ?? null; } From e6821fc87738aef0e189acc23fc7b8509e777094 Mon Sep 17 00:00:00 2001 From: caoquyenis <58635793+caoquyenis@users.noreply.github.com> Date: Tue, 10 Feb 2026 16:27:16 +0700 Subject: [PATCH 10/10] feat: add YAML component guide dialog with accordion for supported block types and props --- src/app/pages/builder/builder.ts | 210 +++++++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) diff --git a/src/app/pages/builder/builder.ts b/src/app/pages/builder/builder.ts index b8f0a96fb..b20779931 100644 --- a/src/app/pages/builder/builder.ts +++ b/src/app/pages/builder/builder.ts @@ -7,6 +7,8 @@ import { ButtonModule } from 'primeng/button'; import { MessageModule } from 'primeng/message'; import { FileUploadModule } from 'primeng/fileupload'; import { ToastModule } from 'primeng/toast'; +import { DialogModule } from 'primeng/dialog'; +import { AccordionModule } from 'primeng/accordion'; import { MessageService } from 'primeng/api'; import { LayoutBuilderService } from './builder.service'; import { DynamicRendererComponent } from './dynamic-renderer.component'; @@ -20,6 +22,7 @@ import { LayoutService } from '@/app/layout/service/layout.service'; CommonModule, FormsModule, SplitterModule, TabsModule, ButtonModule, MessageModule, FileUploadModule, ToastModule, + DialogModule, AccordionModule, DynamicRendererComponent, CodemirrorComponent ], providers: [MessageService], @@ -44,6 +47,7 @@ import { LayoutService } from '@/app/layout/service/layout.service'; size="small" /> +
@@ -108,6 +112,54 @@ import { LayoutService } from '@/app/layout/service/layout.service';
+ + +

Reference for all supported block types and their props. Copy snippets directly into your YAML editor.

+ + + + Page Structure + +
{{ guideSnippets.structure }}
+
+
+ + + Config (Component Defaults) + +
{{ guideSnippets.config }}
+
+
+ + @for (guide of componentGuides; track guide.type) { + + {{ guide.label }} + +

{{ guide.description }}

+ + + + + + + + + + @for (prop of guide.props; track prop.name) { + + + + + + } + +
PropTypeDefault
{{ prop.name }}{{ prop.type }}{{ prop.default }}
+
{{ guide.example }}
+
+
+ } +
+
` }) export class LayoutBuilder { @@ -117,6 +169,164 @@ export class LayoutBuilder { editorCode = signal(''); isDark = computed(() => this.layoutService.isDarkTheme()); + showGuide = signal(false); + + guideSnippets = { + structure: `pages: + - id: my-page # unique page ID (required) + title: My Page # tab label (required) + description: ... # optional subtitle + blocks: # array of component blocks + - type: message + props: + text: "Hello!"`, + config: `# Set default props per component type. +# Blocks inherit these unless they override with own props. +config: + button: + severity: primary + outlined: false + table: + paginator: true + rows: 5 + stripedRows: true + form: + layout: vertical + message: + severity: info + panel: + toggleable: true + collapsed: false` + }; + + componentGuides = [ + { + type: 'message', label: 'Message', description: 'Displays an inline message/alert.', + props: [ + { name: 'severity', type: 'string', default: 'info' }, + { name: 'text', type: 'string', default: '""' } + ], + example: `- type: message + props: + severity: success # info | success | warn | error + text: "Operation completed!"` + }, + { + type: 'button', label: 'Button', description: 'A clickable button.', + props: [ + { name: 'label', type: 'string', default: '"Button"' }, + { name: 'icon', type: 'string', default: 'none' }, + { name: 'severity', type: 'string', default: 'primary' }, + { name: 'outlined', type: 'boolean', default: 'false' }, + { name: 'disabled', type: 'boolean', default: 'false' } + ], + example: `- type: button + props: + label: Save + icon: pi pi-check + severity: success # primary | secondary | success | info | warn | danger | help + outlined: true` + }, + { + type: 'divider', label: 'Divider', description: 'A horizontal or vertical separator line.', + props: [ + { name: 'align', type: 'string', default: 'none' }, + { name: 'type', type: 'string', default: '"solid"' } + ], + example: `- type: divider + props: + align: center # left | center | right + type: dashed # solid | dashed | dotted` + }, + { + type: 'card', label: 'Card', description: 'A container card. Supports nested blocks inside.', + props: [ + { name: 'header', type: 'string', default: 'none' }, + { name: 'subheader', type: 'string', default: 'none' } + ], + example: `- type: card + props: + header: My Card + subheader: Optional subtitle + blocks: # nested blocks rendered inside + - type: message + props: + text: "Inside a card!"` + }, + { + type: 'panel', label: 'Panel', description: 'A collapsible panel. Supports nested blocks inside.', + props: [ + { name: 'header', type: 'string', default: '"Panel"' }, + { name: 'toggleable', type: 'boolean', default: 'false' }, + { name: 'collapsed', type: 'boolean', default: 'false' } + ], + example: `- type: panel + props: + header: Details + toggleable: true + collapsed: false + blocks: + - type: message + props: + text: "Inside a panel!"` + }, + { + type: 'form', label: 'Form', description: 'A form with multiple field types. Fields are defined in the "fields" array.', + props: [ + { name: 'layout', type: 'string', default: '"vertical"' } + ], + example: `- type: form + props: + layout: grid # vertical | grid (2-column) + fields: + - name: username # unique field name (required) + label: Username # display label (required) + type: text # text | number | textarea | checkbox | select | date + placeholder: Enter username + required: true + - name: bio + label: Biography + type: textarea + rows: 4 # textarea only + - name: age + label: Age + type: number + - name: role + label: Role + type: select + options: # select only + - { label: Admin, value: admin } + - { label: User, value: user } + - name: active + label: Active + type: checkbox + - name: birthdate + label: Birth Date + type: date` + }, + { + type: 'table', label: 'Table', description: 'A data table with columns and rows.', + props: [ + { name: 'paginator', type: 'boolean', default: 'false' }, + { name: 'rows', type: 'number', default: '10' }, + { name: 'showGridlines', type: 'boolean', default: 'false' }, + { name: 'stripedRows', type: 'boolean', default: 'true' } + ], + example: `- type: table + props: + paginator: true + rows: 5 + stripedRows: true + showGridlines: false + columns: + - { field: id, header: ID } + - { field: name, header: Name } + - { field: status, header: Status } + data: + - { id: 1, name: Alice, status: Active } + - { id: 2, name: Bob, status: Inactive }` + } + ]; constructor() { this.editorCode.set(this.builderService.yamlCode());