chore: init commit
in worker.../repo/GITFOLDER.zip is the .git folder.
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 154 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 16V21M16 14V21M20 10V21M22 3L13.354 11.646C13.3076 11.6926 13.2524 11.7295 13.1916 11.7547C13.1309 11.7799 13.0658 11.7929 13 11.7929C12.9342 11.7929 12.8691 11.7799 12.8084 11.7547C12.7476 11.7295 12.6924 11.6926 12.646 11.646L9.354 8.354C9.26024 8.26026 9.13308 8.20761 9.0005 8.20761C8.86792 8.20761 8.74076 8.26026 8.647 8.354L2 15M4 18V21M8 14V21" stroke="#EEEFBE" stroke-width="1.33" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 555 B |
|
After Width: | Height: | Size: 500 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15 21V13C15 12.7348 14.8946 12.4804 14.7071 12.2929C14.5196 12.1054 14.2652 12 14 12H10C9.73478 12 9.48043 12.1054 9.29289 12.2929C9.10536 12.4804 9 12.7348 9 13V21M3 9.99997C2.99993 9.70904 3.06333 9.42159 3.18579 9.15768C3.30824 8.89378 3.4868 8.65976 3.709 8.47197L10.709 2.47297C11.07 2.16788 11.5274 2.00049 12 2.00049C12.4726 2.00049 12.93 2.16788 13.291 2.47297L20.291 8.47197C20.5132 8.65976 20.6918 8.89378 20.8142 9.15768C20.9367 9.42159 21.0001 9.70904 21 9.99997V19C21 19.5304 20.7893 20.0391 20.4142 20.4142C20.0391 20.7893 19.5304 21 19 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V9.99997Z" stroke="#EEEFBE" stroke-width="1.33" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 839 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16 10H18M16 14H18M6.17004 15.0001C6.37618 14.4141 6.75915 13.9065 7.26606 13.5475C7.77298 13.1884 8.37885 12.9956 9.00004 12.9956C9.62123 12.9956 10.2271 13.1884 10.734 13.5475C11.2409 13.9065 11.6239 14.4141 11.83 15.0001M11 11C11 12.1046 10.1046 13 9 13C7.89543 13 7 12.1046 7 11C7 9.89543 7.89543 9 9 9C10.1046 9 11 9.89543 11 11ZM4 5H20C21.1046 5 22 5.89543 22 7V17C22 18.1046 21.1046 19 20 19H4C2.89543 19 2 18.1046 2 17V7C2 5.89543 2.89543 5 4 5Z" stroke="black" stroke-width="1.33" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 651 B |
|
After Width: | Height: | Size: 145 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 152 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16 8H10C9.46957 8 8.96086 8.21071 8.58579 8.58579C8.21071 8.96086 8 9.46957 8 10C8 10.5304 8.21071 11.0391 8.58579 11.4142C8.96086 11.7893 9.46957 12 10 12H14C14.5304 12 15.0391 12.2107 15.4142 12.5858C15.7893 12.9609 16 13.4696 16 14C16 14.5304 15.7893 15.0391 15.4142 15.4142C15.0391 15.7893 14.5304 16 14 16H8M12 17.5V6.5M4 2V22L6 21L8 22L10 21L12 22L14 21L16 22L18 21L20 22V2L18 3L16 2L14 3L12 2L10 3L8 2L6 3L4 2Z" stroke="#EEEFBE" stroke-width="1.33" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 618 B |
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 500 500">
|
||||
<!-- Generator: Adobe Illustrator 29.8.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 2) -->
|
||||
<defs>
|
||||
<style>
|
||||
.st0 {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.st1 {
|
||||
fill: #e31837;
|
||||
}
|
||||
|
||||
.st2 {
|
||||
fill: #012169;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g>
|
||||
<path class="st0" d="M251,498.5c-66.3,0-128.7-25.8-175.6-72.7S2.7,316.6,2.7,250.2,28.6,121.6,75.5,74.7,184.7,2,251,2s128.7,25.8,175.6,72.7,72.7,109.2,72.7,175.6-25.8,128.7-72.7,175.6-109.2,72.7-175.6,72.7Z"/>
|
||||
<path d="M251,2.5c33.5,0,65.9,6.6,96.4,19.5,29.5,12.5,56,30.3,78.8,53.1,22.8,22.8,40.6,49.3,53.1,78.8,12.9,30.5,19.5,63,19.5,96.4s-6.6,65.9-19.5,96.4c-12.5,29.5-30.3,56-53.1,78.8-22.8,22.8-49.3,40.6-78.8,53.1-30.5,12.9-63,19.5-96.4,19.5s-65.9-6.6-96.4-19.5c-29.5-12.5-56-30.3-78.8-53.1-22.8-22.8-40.6-49.3-53.1-78.8-12.9-30.5-19.5-63-19.5-96.4s6.6-65.9,19.5-96.4c12.5-29.5,30.3-56,53.1-78.8,22.8-22.8,49.3-40.6,78.8-53.1,30.5-12.9,63-19.5,96.4-19.5M251,1.5C113.6,1.5,2.2,112.9,2.2,250.2s111.4,248.8,248.8,248.8,248.8-111.4,248.8-248.8S388.4,1.5,251,1.5h0Z"/>
|
||||
</g>
|
||||
<path class="st1" d="M251.6,381.1c73.7-63.1,177.1-124.5,234-146.6-8.8-5.6-22.4-13.7-37.9-22.4-73.3,24.2-158.9,77.9-235.4,138.6,13,9.8,26.7,20,39.3,30.5h0Z"/>
|
||||
<path class="st2" d="M217.9,209.5c-10.9-5.3-22.8-10.2-31.9-14.4-27.7,13.7-63.8,34.4-109.4,67,9.8,5.3,20.3,11.6,31.6,17.9,35.1-26,70.5-50.5,109.8-70.5Z"/>
|
||||
<path class="st1" d="M283.5,179.4c-13.3-7.4-59.6-24.6-91.6-31.9-9.5,3.5-22.8,8.8-31.9,12.6,11.6,3.2,54.7,14.7,91.6,33.3,9.5-4.6,22.8-10.2,31.9-14Z"/>
|
||||
<path class="st2" d="M126.7,174.8c-41,18.9-84.2,44.9-107.7,60,8.4,3.9,16.8,7,28.4,12.6,51.9-35.1,92.6-56.5,108.7-63.5-11.6-4.2-22.4-7-29.5-9.1h0Z"/>
|
||||
<path class="st1" d="M314.3,168.5c9.5-3.2,20.3-6,29.8-8.8-27.4-11.6-61.7-23.9-92.6-31.6-4.9,1.4-19.6,5.3-29.8,8.4,10.5,3.2,45.3,11.2,92.6,31.9ZM141.4,300.4c11.2,6.7,23.2,15.8,34.7,23.5,76.8-59.6,152.6-105.6,235.7-131.5-11.6-6-21.7-11.2-34.7-17.5-49.8,12.6-135,46.7-235.7,125.6Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 500 500">
|
||||
<!-- Generator: Adobe Illustrator 29.8.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 2) -->
|
||||
<defs>
|
||||
<style>
|
||||
.st0 {
|
||||
fill: #fff5f1;
|
||||
}
|
||||
|
||||
.st1 {
|
||||
fill: #dc2329;
|
||||
}
|
||||
|
||||
.st2 {
|
||||
fill: #1670a9;
|
||||
}
|
||||
|
||||
.st3 {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.st4 {
|
||||
fill: #f5c6b5;
|
||||
}
|
||||
|
||||
.st5 {
|
||||
fill: #7592b9;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g>
|
||||
<path class="st3" d="M251,498.5c-66.3,0-128.7-25.8-175.6-72.7S2.7,316.6,2.7,250.2,28.6,121.6,75.5,74.7,184.7,2,251,2s128.7,25.8,175.6,72.7,72.7,109.2,72.7,175.6-25.8,128.7-72.7,175.6-109.2,72.7-175.6,72.7Z"/>
|
||||
<path d="M251,2.5c33.5,0,65.9,6.6,96.4,19.5,29.5,12.5,56,30.3,78.8,53.1,22.8,22.8,40.6,49.3,53.1,78.8,12.9,30.5,19.5,63,19.5,96.4s-6.6,65.9-19.5,96.4c-12.5,29.5-30.3,56-53.1,78.8-22.8,22.8-49.3,40.6-78.8,53.1-30.5,12.9-63,19.5-96.4,19.5s-65.9-6.6-96.4-19.5c-29.5-12.5-56-30.3-78.8-53.1-22.8-22.8-40.6-49.3-53.1-78.8-12.9-30.5-19.5-63-19.5-96.4s6.6-65.9,19.5-96.4c12.5-29.5,30.3-56,53.1-78.8,22.8-22.8,49.3-40.6,78.8-53.1,30.5-12.9,63-19.5,96.4-19.5M251,1.5C113.6,1.5,2.2,112.9,2.2,250.2s111.4,248.8,248.8,248.8,248.8-111.4,248.8-248.8S388.4,1.5,251,1.5h0Z"/>
|
||||
</g>
|
||||
<path class="st3" d="M438.5,132.7c-40.5,9.6-79.3,24.1-116.9,41.9-16.5,7.8-31.8,13.7-39.1,32-13.7,34.2-.3,63.1,8.3,96.1,5.5,21,9.4,42.6,11.3,64.3.2,1.8-.2,3.5.5,5.3l.5-4.8,10.7-141.7c.6-1.7,16.6-7.4,19.2-9.4s-.3-4.1-1.8-5.5c-3.1-3-7.5-5.4-10.6-8.5l4.8-4.8c37.2-22.1,75.2-43.3,113-64.4"/>
|
||||
<path class="st3" d="M355.2,86.6l-93.1,77.4c-3.9,3-6.9,5.5-11.8,6.8-14.5,3.6-30-2.7-44.9-3.6s-11.2-.4-12.8.5c-7.6,4.3-7.5,30.8.6,34.9,3.5,1.8,8.6-.4,12.1,3.8,5.7,7-1.4,13.4-6.3,18-49.2,52.4-97.7,105.6-145.9,159-1,1.1-2.2,2.3-3.5,3.2h-.5"/>
|
||||
<path class="st1" d="M451.2,125.8c-48.3,10.5-97,23.3-142.7,42.2-24.6,10.2-55.7,22.5-63.5,50.5-16.9,59.9,33.2,115.7,52.4,168.7H51.5s-1.8-.5-1.8-.5c1.3-.9,2.4-2,3.5-3.2,48.2-53.4,96.7-106.6,145.9-159,4.9-4.6,12-11,6.3-18-3.5-4.2-8.5-2-12.1-3.8-8.1-4.1-8.2-30.6-.6-34.9s10.4-.6,12.8-.5c14.9.9,30.5,7.2,44.9,3.6s7.9-3.7,11.8-6.8l93.1-77.4"/>
|
||||
<path class="st1" d="M199.1,174.1c-6.7,1.9-5.7,22.1.4,22.1,4.4,0,5.3-9.8,5.1-13-.2-2.7-1.8-9.7-5.5-9.2Z"/>
|
||||
<polygon class="st4" points="297.4 387.1 297.4 387.7 49.2 387.7 49.2 386.6 49.7 386.6 51.5 387.2 297.4 387.1"/>
|
||||
<path class="st2" d="M438.5,133.2c-37.8,21.1-75.8,42.3-113,64.4l-4.8,4.8c3.2,3.1,7.5,5.5,10.6,8.5s4.2,3.6,1.8,5.5-18.7,7.7-19.2,9.4l-10.7,141.7-1.1-.5c-1.9-21.7-5.8-43.2-11.3-64.3-8.7-33-22-61.9-8.3-96.1,7.3-18.3,22.6-24.1,39.1-32,37.6-17.9,76.4-32.4,116.9-41.9"/>
|
||||
<path class="st5" d="M303.2,367.5l-.5,4.8c-.8-1.8-.4-3.5-.5-5.3l1.1.5Z"/>
|
||||
<path class="st0" d="M199.1,174.1c3.7-.6,5.3,6.5,5.5,9.2.2,3.2-.7,13-5.1,13-6.1,0-7.1-20.2-.4-22.1Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 500 500">
|
||||
<!-- Generator: Adobe Illustrator 29.8.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 2) -->
|
||||
<defs>
|
||||
<style>
|
||||
.st0 {
|
||||
fill: #fff;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g>
|
||||
<path class="st0" d="M251,498.5c-66.3,0-128.7-25.8-175.6-72.7S2.7,316.6,2.7,250.2,28.6,121.6,75.5,74.7,184.7,2,251,2s128.7,25.8,175.6,72.7,72.7,109.2,72.7,175.6-25.8,128.7-72.7,175.6-109.2,72.7-175.6,72.7Z"/>
|
||||
<path d="M251,2.5c33.5,0,65.9,6.6,96.4,19.5,29.5,12.5,56,30.3,78.8,53.1,22.8,22.8,40.6,49.3,53.1,78.8,12.9,30.5,19.5,63,19.5,96.4s-6.6,65.9-19.5,96.4c-12.5,29.5-30.3,56-53.1,78.8-22.8,22.8-49.3,40.6-78.8,53.1-30.5,12.9-63,19.5-96.4,19.5s-65.9-6.6-96.4-19.5c-29.5-12.5-56-30.3-78.8-53.1-22.8-22.8-40.6-49.3-53.1-78.8-12.9-30.5-19.5-63-19.5-96.4s6.6-65.9,19.5-96.4c12.5-29.5,30.3-56,53.1-78.8,22.8-22.8,49.3-40.6,78.8-53.1,30.5-12.9,63-19.5,96.4-19.5M251,1.5C113.6,1.5,2.2,112.9,2.2,250.2s111.4,248.8,248.8,248.8,248.8-111.4,248.8-248.8S388.4,1.5,251,1.5h0Z"/>
|
||||
</g>
|
||||
<path id="path3534" d="M151.7,147.7c-5.3,0-10.6.2-16,.7-35.6,3-69.5,22.8-69.6,53,0,15.3,10.9,27.7,27.9,27.1,20-.7,36.4-16.5,45-36.5,3.3-7.6-2.8-10.7-5.6-6.4-5.1,7.9-12.4,14-20.2,17.8-9.4,4.6-19.5,3.3-22.5-4.2-4.3-10.7,4.6-26.6,24.9-34.1,29.3-10.8,60.1-3.6,89.6,1.9.4,0,2,.9.6,1.6-5.3,2.9-10.5,5.2-18.6,12.1-5.8,4.9-13.4,11.5-19.3,18.7-5.8,7.1-10,13.3-15.5,20.3-.8,1-1.7,1-1.7,1-13.4,2.2-26.5,3.7-38.4,12-2.5,1.7-3.6,4.7-2,7,1.4,2,4.4,2.3,6.6.7,7.4-5.5,15.1-8.1,23.9-8.3.4,0,.6,0,.7.3,0,.1,0,.4-.1.6-13.2,18.1-16.5,22.2-26.4,33.4-5.1,5.8-10.1,10.6-15.6,15.7-22.5,20.7-46.7,20.3-55.4,7.4-5.2-7.8-4.5-16-1.1-22.7,4.2-8.3,12.6-14.4,19.7-17.9,2.6-1.3,3.4-4.6.7-7.6-1.7-2-5.7-2.3-8.7-1.8-9.2,1.7-20.1,8.6-26.5,16.9-7.2,9.3-10.9,20.6-9.7,33.3,2.2,22.9,21.2,36.1,42.8,36,17.2-.1,33.4-5.3,52.9-24.9,18.2-18.3,34.7-45.6,50.2-70.9.5-.8.8-1.2,3.2-1.5,9.7-1.2,24.1-2.6,33.3-3.1,2-.1,2.5,0,3.3.9,2.4,3,6,5.5,8.9,7.1,1.4.8,2.1,1.1,3.2,1.2,1.4,0,2.4-.7,3.1-1.5,1-1.1,1.2-2.4.7-3.3-.5-.9-7-4.5-7.2-5.9-.1-.9.8-1.1.8-1.1,4.7-1.5,9.6-4.5,12.6-10.1,2.9-5.6,3.4-13-1.9-17.1-4.7-3.6-11.8-3-17.4,2-5.5,4.9-7.4,12-6.6,18.2.1,1.1,0,1.4-1.4,1.6-8.5.7-16.7.9-25.8,1.4-.6,0-.8-.4-.4-.8,10.5-18,26.2-30.9,43.5-43.6,1.1-.9.8-2.5.8-3.8,31.3,6.7,61.5,19.3,94,19,13.4-.1,26.3-2.1,38.6-8.8,10.8-5.8,15.2-11.2,15.5-16.8.3-3.9-2.5-6.3-6.3-5.5-32.8,7.5-64.1,7.4-96.7,3.5-37.3-4.5-73-13.7-110.1-13.6ZM217.2,200c.8,0,1.5.3,2.1.8,1.7,1.5.9,5.1-.8,7.6-1.9,2.8-5.1,5.2-8.1,5.8-.6.1-1.1-.1-1.3-1.4-.5-3.4.7-8,4.4-11.3,1.2-1,2.6-1.5,3.9-1.5ZM480.4,200.6c-6,0-13.8,0-20,0s-2.2.2-2.9,1.5c-2.3,4.4-30.2,45.8-35.4,53.9-.9,1.2-1.9,1.1-2.1-.3-.5-4.8-4.7-10.3-11.3-12.9-5-2-9.9-2.4-15-1.7-9.2,1.2-17.4,6-24.6,11.4-10.9,8.2-20.2,18.8-32.3,26.6-6.6,4.3-15.6,8-21.7,2.4-5.5-5-4.9-16.2,3.5-24.8.9-.9,1.9-.1,1.8.7-.8,3.9.2,7.8,3.2,10.5,3.7,3.2,8.9,3.6,13.3,1.4,5.3-2.6,8.4-7.6,9.3-13.3,1.4-8.8-5.4-16.6-14-17.2-7-.5-13.6,1.9-20.1,7.2-3.3,2.7-5.1,4.7-7.9,8.6-.7,1-1.7,1.1-1.7-.4.3-11.3-4.4-17.6-13.8-17.8-7.3-.2-15.1,3.8-21.2,8.6-6.7,5.3-12.5,12.3-18.7,18.9-.8.8-1.5.8-1.7-.8-.2-7.5-2.1-14.7-5.6-19.8-1.2-1.8-3.8-2.7-6-1.5-1,.5-4.5,2.1-7.1,4.2-1.3,1-1.8,2.5-1.2,4.4,3.4,11.2,2.6,23.8-1.9,34.6-4.2,9.8-12.4,18.7-21.9,21.7-6.3,2-12.9,1-16.9-4.5-5.6-7.7-3.3-21,5-32,7.3-9.7,18-15.9,28.6-19.9,1.2-.5,1.5-1.3,1.2-2.3-.6-1.7-1.5-4.1-1.9-5.1-1-2.6-3.7-3-7-2.6-7.7.9-14.6,3.5-21.5,7.1-18,9.5-27,28-30.9,37.8-1.9,4.7-3.5,7.7-5.6,10.3-2.8,3.5-6.4,6.7-12.9,12.4-.6.5-1,1.6-.5,2.6.7,1.4,4,6.1,5,6.5,1.1.5,2.3-.2,2.8-.6,4.5-3.1,10-7.9,12.7-11.2.9-1.1,1.9-.7,2.4,1.1,2.5,8.8,9,15.9,18,18.9,16.5,5.5,33.5-2.5,46.4-16.3,8.2-8.8,11.2-14.5,14.3-18.3,5.3-6.5,15.8-21.1,28.3-30.5,4.6-3.5,10-6.1,12.7-4.3,2.2,1.4,2.9,4.7-.6,11-12.7,23-31.5,50.1-34.9,56.7-.6,1.1,0,2,1.1,2,6.7,0,13.3,0,19.3,0,1,0,1.5-.5,2-1.2,9.8-15.2,19-29.6,28.9-44.5.6-.9,1.1-.2,1.1.2.2,3.5,1.1,8.2,3.1,11.3,3.7,5.7,9,8.2,14.9,8.3,4.7,0,7-.6,12.1-2.5,4.1-1.5,7.4-3.5,9.9-5.3,1.5-1,1.7.7,1.7.9-2.3,12,.5,26.3,12.6,32.2,14.5,7.1,30.3-2.9,39.5-11.8.9-.9,1.9-.8,1.9,1.1.2,3.4,1.8,7.5,4.6,10.3,7.7,7.6,23.1,4.6,38.6-7.3,10-7.6,20.4-18.4,29.8-29.8.3-.4.6-1.2,0-1.9-1.4-1.5-3.5-3.2-4.9-4.5-.6-.5-1.6-.4-2.1,0-9.6,9.1-18.3,19.4-30.9,28-4.2,2.9-11,5.3-14,1.2-1.2-1.6-1-3.8.1-6.6,3.7-9,61.6-96.4,64.6-101.6.5-.9,0-1.6-1.2-1.6h0ZM399.2,255.2c1.5,0,2.9.3,4.2.9,7.1,3.4,4.9,11.6,1.7,18-.2.5-.3.9-.1,1.3.1.7,1,1.3,1.9,1.6.3.1.5.3.1,1-2.5,5.2-4.7,8.2-7.8,12.8-2.8,4.2-6,7.5-9.9,10.6-5.8,4.8-14,10-20.7,6.5-3-1.5-4.3-5.7-4.2-9,.2-9.4,4.4-19.1,12.2-28.5,7.7-9.4,16.1-15.2,22.7-15.2Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 500 500">
|
||||
<!-- Generator: Adobe Illustrator 29.8.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 2) -->
|
||||
<defs>
|
||||
<style>
|
||||
.st0 {
|
||||
fill: #fff;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g>
|
||||
<path class="st0" d="M251,498.5c-66.3,0-128.7-25.8-175.6-72.7S2.7,316.6,2.7,250.2,28.6,121.6,75.5,74.7,184.7,2,251,2s128.7,25.8,175.6,72.7,72.7,109.2,72.7,175.6-25.8,128.7-72.7,175.6-109.2,72.7-175.6,72.7Z"/>
|
||||
<path d="M251,2.5c33.5,0,65.9,6.6,96.4,19.5,29.5,12.5,56,30.3,78.8,53.1,22.8,22.8,40.6,49.3,53.1,78.8,12.9,30.5,19.5,63,19.5,96.4s-6.6,65.9-19.5,96.4c-12.5,29.5-30.3,56-53.1,78.8-22.8,22.8-49.3,40.6-78.8,53.1-30.5,12.9-63,19.5-96.4,19.5s-65.9-6.6-96.4-19.5c-29.5-12.5-56-30.3-78.8-53.1-22.8-22.8-40.6-49.3-53.1-78.8-12.9-30.5-19.5-63-19.5-96.4s6.6-65.9,19.5-96.4c12.5-29.5,30.3-56,53.1-78.8,22.8-22.8,49.3-40.6,78.8-53.1,30.5-12.9,63-19.5,96.4-19.5M251,1.5C113.6,1.5,2.2,112.9,2.2,250.2s111.4,248.8,248.8,248.8,248.8-111.4,248.8-248.8S388.4,1.5,251,1.5h0Z"/>
|
||||
</g>
|
||||
<polygon class="st0" points="297 56.9 206.6 56.9 205.1 58.5 205.1 128.4 206.6 130 297 130 297 56.9"/>
|
||||
<polygon class="st0" points="297 161.3 205.1 161.3 205.1 455 206.6 456.6 297 456.6 297 161.3"/>
|
||||
<polygon points="297 161.3 297 456.6 206.6 456.6 205.1 455 205.1 161.3 297 161.3"/>
|
||||
<polygon points="297 56.9 297 130 206.6 130 205.1 128.4 205.1 58.5 206.6 56.9 297 56.9"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,60 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 500 500">
|
||||
<!-- Generator: Adobe Illustrator 29.8.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 2) -->
|
||||
<defs>
|
||||
<style>
|
||||
.st0 {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.st1 {
|
||||
fill: #e32726;
|
||||
}
|
||||
|
||||
.st2 {
|
||||
fill: #1d3c6d;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g>
|
||||
<path class="st0" d="M250,496.4c-66.3,0-128.7-25.8-175.6-72.7S1.7,314.5,1.7,248.1,27.6,119.5,74.5,72.6C121.4,25.7,183.7,0,250,0s128.7,25.8,175.6,72.7c46.9,46.9,72.7,109.2,72.7,175.6s-25.8,128.7-72.7,175.6-109.2,72.7-175.6,72.7h0Z"/>
|
||||
<path d="M250,.4c33.5,0,65.9,6.6,96.4,19.5,29.5,12.5,56,30.3,78.8,53.1,22.8,22.8,40.6,49.3,53.1,78.8,12.9,30.5,19.5,63,19.5,96.4s-6.6,65.9-19.5,96.4c-12.5,29.5-30.3,56-53.1,78.8s-49.3,40.6-78.8,53.1c-30.5,12.9-63,19.5-96.4,19.5s-65.9-6.6-96.4-19.5c-29.5-12.5-56-30.3-78.8-53.1-22.8-22.8-40.6-49.3-53.1-78.8-12.9-30.5-19.5-63-19.5-96.4s6.6-65.9,19.5-96.4c12.5-29.5,30.3-56,53.1-78.8,22.8-22.8,49.3-40.6,78.8-53.1C184.1,7,216.6.4,250,.4M250-.6C112.6-.6,1.2,110.8,1.2,248.1s111.4,248.8,248.8,248.8,248.8-111.4,248.8-248.8S387.4-.6,250-.6h0Z"/>
|
||||
</g>
|
||||
<g>
|
||||
<g id="g30">
|
||||
<path id="path32" class="st2" d="M411.5,186.9c0-6.8,0-13.6,0-20.4-5.8,0-11.7,0-17.5,0,0-7.4,0-14.8,0-22.2-7.7,0-15.5,0-23.2,0v22.2h-9.6c0,6.8,0,13.6,0,20.4h9.6s0,0,0,0c0,6.2,0,12.4,0,18.6h0c0,.5,0,1,0,1.5s0,.8,0,1.2h0c0,3.6,0,7.2,0,10.9.2,9.8,3.8,20.3,12,26,7.3,5.2,16.3,6.2,25,6-7.1-7.4-11.4-18.3-13.1-27v-.3c0,0,0-.3,0-.3-.2-1.9-.5-5-.5-8.2,0-.7,0-1.4-.1-2.1,0-1.8,0-3.5,0-5.3,0-7.1,0-14.1,0-21.2,5.8,0,11.7,0,17.5,0"/>
|
||||
</g>
|
||||
<g id="g34">
|
||||
<path id="path36" class="st2" d="M356.9,150.6c0-6.8,0-13.6,0-20.4-11.4-1.9-24.6-1.2-33.8,6.7-8.2,7.4-9.8,19.1-9.5,29.5h-10c0,6.8,0,13.6,0,20.4h8.5c.5,0,1.1,0,1.6,0h0s0,0,0,0v11.2h0c0,16.7,0,33.4,0,50.1h10.8c4.3,0,8.5,0,12.8,0,0-17,0-33.9,0-50.8h0v-10.4h15.7c0-6.8,0-13.6,0-20.4h-15.8c.2-4.6-.3-10.1,3.5-13.5,4.6-3.8,10.9-2.6,16.4-2.3"/>
|
||||
</g>
|
||||
<g id="g38">
|
||||
<path id="path40" class="st2" d="M271.6,248.2h10.5c2,0,4,0,5.9,0h6.6c-.1-17.5.1-35-.1-52.5-.2-10.3-4.1-21.7-13.9-26.5-14-7-30.4-5.4-45.3-3,0,7.4,0,14.9,0,22.3,9.6-2.1,20.1-4.3,29.6-.7,4.4,1.5,6.1,6.2,6.8,10.4-6.7-.8-13.4-2.4-20.1-1.1-8,1-15.8,4.9-20.3,11.7-7.3,10.9-5.2,27.3,5.6,35.1,10.1,7.7,24.7,6.9,35,0,0,1.1,0,3.2,0,4.3M270.6,230.5c-6,1.8-13.2,3.9-18.7-.3-5-3.5-4.3-12,1.1-14.8,5.5-2.9,12-1.9,17.8-.7.5,5.3.5,10.5-.2,15.8"/>
|
||||
</g>
|
||||
<g id="g42">
|
||||
<path id="path44" class="st2" d="M151.3,137.4c9.4,0,18.7,0,28.1,0-13.7,18.5-27.2,37.2-41,55.6,12.3,18.4,24.4,36.9,36.8,55.2-9.7,0-19.4,0-29.2,0-10.8-16.4-21.7-32.9-32.5-49.3,0,16.4,0,32.9,0,49.3-8.4,0-16.8,0-25.1,0,0-36.9,0-73.8,0-110.8,8.4,0,16.7,0,25.1,0,0,17.4,0,34.9,0,52.3,12.5-17.5,25.1-34.8,37.6-52.3"/>
|
||||
</g>
|
||||
<g id="g46">
|
||||
<path id="path48" class="st2" d="M181.1,166.7c7.9,0,15.8,0,23.7,0,0,5.2,0,10.4-.1,15.6,3.8-8.6,12.1-14.2,21.3-15.7,0,7.7.1,15.5,0,23.2-10.1,1.2-20.4,8.7-20.8,19.6-.3,12.9,0,25.9,0,38.8-8,0-15.9,0-23.9,0,0-27.2,0-54.3,0-81.4"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<g id="g50">
|
||||
<path id="path52" class="st1" d="M240.8,364.1c-3.3,1.3-4,2-7.9,3.3-4,1.3-8.6,1.3-12.6,0-12.6-5.3-4.6-24.5-4.6-24.5,51.5-12.6,44.3-40.3,30.4-41.6-8.6,0-39.7,1.3-54.2,37-2,5.3-15.9,43,15.2,45.6,6.6,0,13.9-.7,20.5-4,5.9-3.3,10.6-7.3,14.5-12.6,3.3-5.9-1.3-3.3-1.3-3.3M233.5,311.3c4,1.3-5.9,21.1-5.9,21.1-4,1.3-7.9,2.6-10.6,2.6,1.3-2.6,8.6-27.8,16.5-23.8"/>
|
||||
</g>
|
||||
<g id="g54">
|
||||
<path id="path56" class="st1" d="M361,371.4c-.7-.7-2-.7-2.6-1.3-4-2.6-.7-9.9,0-13.9l11.9-42.3c.7-2.6,5.3-16.5-13.2-13.9-15.9,2.6-27.1,12.6-27.1,12.6,1.3-4,2.6-9.9,0-11.2-3.3-1.3-22.5,4.6-27.1,8.6-1.3,0-3.3,2-1.3,4,2,2.6,2.6,4.6,1.3,9.2-2.6,7.9-10.6,35.7-12.6,41-2,5.3-4.6,15.2,0,18.5,3.3,2.6,17.2-3.3,19.2-4.6,2-.7,4-2,4.6-2.6,2.6-2.6-1.3-4,2.6-17.8,0-.7,4.6-14.5,7.9-26.4,2.6-9.9,11.9-14.5,15.2-15.2,5.3-.7,4,4,3.3,7.3-4.6,14.5-9.9,36.3-11.9,41.6-2,5.3-5.3,15.9,0,17.8,4.6,3.3,25.8-5.9,27.8-7.3,4-2.6,4-4,2-4"/>
|
||||
</g>
|
||||
<g id="g58">
|
||||
<path id="path60" class="st1" d="M280.4,371.4c-1.3-.7-2-.7-2.6-1.3-4-2.6-1.3-9.9,0-13.9l12.6-42.3c.7-4.6,3.3-10.6,0-12.6-4-1.3-23.1,4.6-27.8,8.6-.7,0-2.6,2-.7,4,2,2.6,2,4.6.7,9.2-2,7.9-11.2,36.3-13.2,41.6-2,5.3-4.6,15.9,1.3,17.8,4.6,3.3,24.5-5.3,27.1-7.3,4.6-2,4.6-4,2.6-4"/>
|
||||
</g>
|
||||
<g id="g62">
|
||||
<path id="path64" class="st1" d="M276.5,291.4c1.3,2.6,13.2-2.6,19.2-5.9.7-.7,3.3-2.6,4-4.6,1.3-4.6,2-6.6,2.6-9.9.7-1.3.7-3.3-1.3-4-2.6-.7-18.5,1.3-22.5,7.9-1.3,2.6-5.9,15.2-2,16.5"/>
|
||||
</g>
|
||||
<g id="g66">
|
||||
<path id="path68" class="st1" d="M453.6,337.7c-2-7.3-13.9-9.3-18.5-5.3-2.6,2.6-4,7.9-3.3,9.3,11.2-3.3,13.2,6.6,11.2,10.6-4,6-5.3,7.9-10.6,7.9h-35c-.7-.7-1.3-1.3,0-2.6,2-2.6,36.3-49.6,37.7-51.5.7-1.3,2.6-4.6-2-4.6h-38.3c-4,0-5.9,1.3-9.2,4.6-2.6,3.3-5.9,13.2-7.9,19.8,0,0,0,2.6,4-.7.7-.7,2-3.3,4-3.3h23.1c7.3,0-31.1,36.3-43,55.5-2,3.3,1.3,3.3,3.3,3.3h50.2c12.6,0,10.6,2,17.2-7.3,0,0,20.5-26.4,17.2-35.7"/>
|
||||
</g>
|
||||
<g id="g70">
|
||||
<path id="path72" class="st1" d="M177.3,360.2l13.9-53.5c9.2-32.4,14.5-33.7,17.8-37h-21.8c-7.9,0-13.9,11.2-15.2,18.5l-9.3,33.7h-19.8s11.9-43,13.2-47.6c.7-2,.7-4.6-3.3-4.6s-42.3-1.3-55.5,3.3c-5.9,2-14.5,7.3-17.2,15.2-2,4.6-4.6,13.2,1.3,29.1.7,2,2,3.3,2.6,4,2,1.3,19.2-1.3,18.5-1.3,1.3,0,2-.7.7-3.3-1.3-3.3-5.3-9.3-5.9-15.2-2.6-23.1,13.9-26.4,16.5-27.1,4-.7,7.3-.7,11.2,0,1.3,0,4.6.7,5.3,2,1.3,2.6-.7,7.3-2,12.6-1.3,4-12.6,44.9-15.9,56.2-2,8.6-10.6,27.1-19.2,29.7-2,.7-4.6.7-7.3,0-2.6-.7-5.3-2-7.3-3.3-5.3-5.3-8.6-15.9-9.3-23.8-.7-9.9,1.3-17.2-.7-17.2-3.4,0-15,4.6-19.6,7.9,0,0,0,0,0,0-1.9,1.4-2,1.6-2,1.6,0,0-.2.2-.2.3-1.2,2.1-1.1,9.5-.6,14,2,9.9,7.3,21.1,15.2,26.4,21.1,14.5,58.2-11.9,62.1-14.5,5.9-5.3,9.9-12.6,11.2-17.8,2-6.6,5.3-17.8,5.3-17.8h19.8s-7.9,29.7-9.9,37c-2,5.9-3.3,15.9,2,15.2,19.8,0,27.8-7.3,28.4-7.9.7-.7.7-2,0-2.6-1.3-.7-5.9-5.3-3.3-11.9"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.8 KiB |
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 500 500">
|
||||
<!-- Generator: Adobe Illustrator 29.8.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 2) -->
|
||||
<defs>
|
||||
<style>
|
||||
.st0 {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.st1 {
|
||||
fill: #f40000;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<circle class="st1" cx="251" cy="250.2" r="248.8"/>
|
||||
<g id="Page-1">
|
||||
<g id="Asset-1KO">
|
||||
<path id="Path" class="st1" d="M494.2,251.1c0-134.3-108.9-243.1-243.1-243.1S7.9,116.8,7.9,251.1s108.9,243.1,243.1,243.1,243.1-108.9,243.1-243.1h0"/>
|
||||
<path id="Shape" class="st0" d="M340.6,189.3c-6.8,3.3-12.8,8-17.7,13.8-9.6,10.5-15.1,22.3-11,25.4,1.6,1.2,5.2,1.4,11.7-4.5,6.1-5.3,10.9-12,14-19.5,4.2-10.4,2.9-15.2,2.9-15.3M392.6,208.1c-10.6-5.5-18.3-12.1-26.4-16.2s-12.5-5.5-18.7-4.8c-.9.1-1.7.3-2.6.6,0,0,3.2,10.9-9.5,29.3-12.7,18.4-24.6,18.7-27.8,13.5s1-16.4,8.5-26.2c6.2-7.3,13.5-13.6,21.7-18.8,0,0-4.4-2.6-15.2,3.5-18.2,11.3-33.8,26.4-45.7,44.2-16.8,23.9-20.2,37.2-22.6,47.5-2.4,10.4-.2,21.5,11.3,21.5s24.9-17.6,28.7-23.7c7-11.4,11.7-28.8,2.1-26.4-4.8,1.2-8.6,6.4-10.9,11.4-1.5,3.2-2.7,6.6-3.5,10.1-3.3,1.4-6.5,3.2-9.3,5.5-3.2,2.3-6.1,5-8.8,7.8.7-7.6,3-15,6.7-21.7,4.8-10.4,14.3-17.9,25.5-20.1,6.2-.5,14.3,4.8,7.4,24.2s-26.9,43-48.1,43.3c-19.5.3-32.4-24.7-5.2-68.3,30.3-48.1,65.3-62.9,77-64.6s15.2,3.4,15.2,3.4c6.9-2.8,14.4-4.1,21.8-3.6,14,.9,24.8,10.9,39.1,17.8-3.9,3.6-7.2,7.4-10.5,10.9M455.7,191.9c-9.7,6.3-23.1,11.9-36,10.7-3.3,3.4-6.8,7.3-10.3,11.7,22.4,4.4,42-9.5,52.4-21.3,3.8-4.2,7-8.9,9.7-13.9-4.7,4.9-10,9.2-15.8,12.9M426,255c.2-.3.3-.6.4-.9-.1-1.1-1-1.9-2-1.9-2.1,0-5.1.3-12.7,8.7-6.3,7-11.8,14.7-16.2,23.1-4.4,7.8-6.1,13.9-3.6,15.6.8.4,1.8.5,2.6.3,1.7-.4,3.6-2,6.5-5.4,1.4-1.6,2.9-3.9,4.7-6.3,5-6.9,17.7-28.5,20.2-32.9M346.7,264.2c1.3-3.3,2.9-9.9-.5-12.4s-12.8,3.7-12.9,10.2c.3,4.8,3.5,8.8,8.1,10.2.9.3,1.2.2,1.6-.5,1.4-2.4,2.6-4.9,3.7-7.4M339.8,276.6c-2.3-1-4.4-2.3-6.3-3.9-1.9-1.6-3.5-3.6-4.8-5.8,0-.2-.2,0-.4,0-2,2.5-3.9,5.1-5.6,7.8-4.9,7.2-12.5,22.2-5.6,24.7,4.4,1.6,11.7-6,15.6-11.1,2.7-3.5,5.2-7.3,7.4-11.2,0-.4,0-.5-.2-.6M417.3,293.9c-1.9,3-3.9,8.6,2.2,5.1,7.5-4.3,18.1-20.5,18.1-20.5h4.8c-4.4,6.8-9.2,13.2-14.6,19.2-7.6,8.4-16.8,16-23.4,15.3-7.8-.9-5.2-10.9-5.2-10.9,0,0-10.9,11.7-18.4,10.7-10-1-6.9-13.7-6.9-13.7,0,0-11.7,14.1-19.5,13.7-12.3-.7-9.2-15.6-5.8-26.1,1.8-5.6,3.5-10,3.5-10,0,0-1.2.3-3.9.7-1.3,0-2.6.2-3.9.4,0,0-5.1,9-9.2,14.4s-21.3,24.1-31.4,20.4c-9.5-3.5-6.3-18-.8-29.4,8-16.7,30.6-40.9,43.6-37.7s.3,27.9.3,27.9h0c.8.2,1.7.2,2.6,0,1.7-.3,3.3-.8,4.9-1.4,8-16.6,17.5-32.3,28.5-47.1,15-18.6,44.9-50.6,56.7-42.8,2.8,1.9,1.6,8.5-2.3,16.4-1.7.5-3.5,1-5.3,1.4,2.1-3.6,3.8-7.4,4.8-11.4,1.8-9.6-20.7,10.7-36.7,32.5-9.6,13.1-18.2,27-25.6,41.5,2.9-1.7,5.7-3.7,8.3-5.9,7.1-5.9,13.9-12.3,20.3-19,6.2-6.3,12.1-13,17.5-20,1.7,0,3.4-.2,5.1-.5-5.8,7.9-12.1,15.5-18.9,22.5-6.4,6.5-13.3,13.4-19.5,18.5-5.4,4.6-11,8.8-16.9,12.6,0,0-14.1,28.2-6.4,30.1,4.5,1.1,13.3-12.6,13.3-12.6,0,0,11.7-17.6,18.5-25.6,9.5-10.8,17.7-17,25.9-17.2,3.2.3,6,2.2,7.6,5l2.2-3.5h13.9s-25.8,43.7-27.8,47M455.9,192c-9.7,6.3-23.1,11.9-36,10.7-3.3,3.4-6.8,7.3-10.3,11.7,22.4,4.4,42-9.5,52.4-21.3,3.8-4.2,7-8.9,9.7-13.9-4.7,4.9-10,9.2-15.8,12.9M432.1,304.7h1.9c.6,0,1.1,0,1.7,0,.6.3,1,.9,1,1.6,0,.5-.2.9-.6,1.2-.3.2-.6.3-.9.4.4,0,.8.2,1.1.6.2.3.3.6.4.9v1.2h-1v-1c0-.7,0-1.2-.6-1.4-.3-.2-.7-.3-1.1-.2h-.9v2.4h-1.1l.2-5.6ZM435.8,306.6c0-.5,0-.8-.4-.9-.4-.2-.8-.3-1.3-.2h-.9v2.2h.9c.3,0,.7,0,1,0,.4-.2.6-.5.6-.9M439.6,307.8c0,3.1-2.4,5.5-5.5,5.6-3.1,0-5.5-2.4-5.6-5.5,0-1.5.6-2.9,1.6-4,2.2-2.1,5.6-2.1,7.8,0,1.1,1,1.6,2.4,1.6,3.9M434.1,303c-1.3,0-2.5.5-3.3,1.4-.9.9-1.4,2.1-1.4,3.3,0,1.3.5,2.5,1.4,3.4.9.9,2.1,1.4,3.3,1.4,2.6.2,4.8-1.8,5-4.4,0-.1,0-.2,0-.4,0-1.3-.5-2.5-1.4-3.3-.9-.9-2.1-1.4-3.4-1.4M262.2,213.5h-13.1l-7.4,12h13.1l7.5-12ZM199.9,255c5.6-7.8,21-34.5,21-34.5-.1-1.1-1-1.9-2.1-1.9-2.1,0-5.6.6-12.7,9.5-6.2,7.3-11.7,15.1-16.5,23.4-4.4,7.8-6.3,13.6-3.9,15.1.8.4,1.8.5,2.6.3,2.8-1.1,5.3-3.1,7-5.6,1.4-1.6,2.9-3.9,4.8-6.2M129.7,218.9c-3.6-2.7-12.9,3.7-12.9,10.2,0,6.6,8.3,8.8,9.4,8.8.5.2,1.1,0,1.3-.5.2-.5,1.4-2.6,2.6-5.9s2.9-9.9-.5-12.5M124.3,242.1c-2.5-.8-5-1.9-7.2-3.3-2.1-1.4-3.9-3.3-5.1-5.6,0-.2-.3,0-.4,0s-.9,1.2-5.8,8.4c-4.9,7.2-12.1,22.2-5.3,24.7,4.4,1.6,11.7-6.1,15.6-11.1,3-4,5.8-8.1,8.3-12.4.2-.4,0-.5-.2-.6M231.3,246.5h4.8s-24.1,34.8-37.7,33.3c-7.6-.9-5.3-10.3-5.3-10.3,0,0-10.7,12.5-20.4,10-8.9-2.3-3.2-17.1-3.2-17.1-1,.4-20.2,22.1-33.8,16.9s-6.8-23.6-3.9-29c2.2-4.6,4.7-9.1,4.7-9.1,0,0-2.2.6-3.5.9l-3.4.7s-6.7,10.9-10.8,16.3-21.2,24.1-31.6,20.4-7.8-18-1.9-29.4c9.3-18.5,31.4-41.2,44.7-37.7,13.3,3.4,2.3,25.2,2.3,25.2,0,0,2.6.4,9.1-3.9,9.3-6.3,21.5-23.4,34.7-21.3,6.2.9,12,7.3,3.9,19.5-2.6,3.9-6.8,7.4-10,3.9-2-2.1-.3-6,1.9-8.7,1.7-2.2,4.4-3.4,7.1-3.3,0,0,2.2-5-3.3-4.9-4.5,0-15.8,10.6-22.3,21.1-5.9,9.7-14.8,26.3-5.8,29.9,8.2,3.3,23.8-15.2,31.2-25,7.4-9.7,23.7-31.8,37.5-32.7,3,0,5.9,1.4,7.6,3.9l1.6-2.5h14.1s-26.1,43.5-28.1,46.7c-1.6,2.6-3.9,8.4,2.2,5.1,6.1-3.2,18.1-19,18.1-19M223.8,316.2c-10.2-7-22.4-10.6-34.8-10.1-6.8.2-13.1,3.6-17,9.2-5.3-9.2-15.6-14.8-31.8-14.7-26.1.3-51.9,12.3-72.4,12.3s-34-12.3-33.5-34.2c.8-38.4,33.3-73.1,57.6-88.7,13.9-9,25.6-11.7,33.3-10.7,6.1.9,10.3,6.6,9.4,12.7-.2,1.3-.7,2.6-1.3,3.8-6.2,13.9-14.8,12.7-14.5,6.1.3-3.5,2.2-6.7,5.2-8.5,1.5-.8,3.3-1.2,5-1.2,1.2-1,2.2-8.6-7.8-6.1s-22,12.3-33.5,25c-11.5,12.7-29.2,36.6-33.8,62-2.1,11.7-.7,32.9,27,32.7,23.4,0,58-15.6,89.8-15.3,11.1,0,21.9,2.9,31.4,8.6,8.1,4.7,15.4,10.6,21.6,17.5"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.3 KiB |
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 500 500">
|
||||
<!-- Generator: Adobe Illustrator 29.8.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 2) -->
|
||||
<defs>
|
||||
<style>
|
||||
.st0 {
|
||||
fill: #e3000b;
|
||||
}
|
||||
|
||||
.st1 {
|
||||
fill: #fff;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<circle class="st0" cx="251" cy="250.2" r="248.8"/>
|
||||
<g>
|
||||
<polygon class="st0" points="41.6 309.3 27.4 323.5 46.7 329.8 41.6 309.3"/>
|
||||
<polygon class="st0" points="48.7 337 37.7 352.5 53.4 353.6 48.7 337"/>
|
||||
<polygon class="st0" points="34.4 282.9 20 295.5 39.8 302.9 34.4 282.9"/>
|
||||
<polygon class="st0" points="64.4 354.5 32 273.1 17.9 265.7 39.4 252.1 18.6 236.1 41.5 224.8 22.2 207.4 47.4 199.5 30.2 179.8 55.7 174.1 41.8 152.5 67.3 149.9 55.9 126.2 82.7 126.9 75 103.5 101.2 105.6 95.7 81.6 120.4 88.9 118.5 63.1 143 73.5 144.8 48.7 166.9 62.5 170.9 37 191.2 53 199 29.1 217 47.5 227.1 25 243.2 44.7 256 23.4 269.4 44.9 284.5 25.6 294.8 48.6 313.4 30.7 320.6 55.7 340.9 40.4 345.3 65.6 366.8 53.5 367.7 79.6 391.3 69.5 389.1 96.1 414.7 89.5 408 114.1 432.8 111.8 424.1 135 450.6 136 438.1 158 463.1 159.9 447.6 180.1 472.4 187.4 455.5 205.8 478.5 216.7 459.6 232.8 481.3 247.1 461.2 258.8 481.3 275 458.8 285.6 477.4 304.9 453.4 312.6 468.7 332.9 444.4 338.4 458.6 362.4 431.6 362.3 442.6 385.7 417 384.8 424.9 409.2 400.3 403.8 405.8 429.3 380.4 422 382.3 446.4 358.8 436.8 358.3 463.3 336.5 449.7 331.8 473.8 311.5 458.8 303.5 482.7 284.6 464.8 275.4 487.5 258.3 467.9 245.6 488.9 231.8 467.1 216.3 486.4 206 462.4 188.1 481.1 180.4 455.9 159.7 472.4 156.2 446.4 135.2 459.6 135 433.7 110.7 443.5 113.8 418 87.8 424.5 94.8 400.3 69.2 403.4 77.7 379.5 51.7 378.2 64.4 354.5"/>
|
||||
<polygon class="st1" points="29.6 265.5 51.6 259.6 95.3 324.1 95.6 324 100.4 246.4 122 240.6 146.5 331.5 131.7 335.5 112.8 265.3 112.5 265.4 106.6 342.3 93.9 345.7 50.3 282.1 50 282.1 68.9 352.4 54 356.4 29.6 265.5"/>
|
||||
<path class="st1" d="M161.4,230l16.6-4.4,58.7,81.6-16.7,4.5-14.9-21.8-35.6,9.5-1.9,26.4-16.1,4.3,9.8-100.2ZM170.7,286.2l27.2-7.3-23.9-35.5h-.4c0,0-2.9,42.8-2.9,42.8Z"/>
|
||||
<polygon class="st1" points="189.5 222.5 261.7 203 265.4 216.8 237.1 224.4 257.8 301.5 242.2 305.8 221.4 228.6 193.3 236.2 189.5 222.5"/>
|
||||
<polygon class="st1" points="248.7 206.5 321 187.2 324.6 200.9 296.3 208.5 317 285.7 301.4 289.8 280.7 212.7 252.5 220.3 248.7 206.5"/>
|
||||
<polygon class="st1" points="326.7 185.7 388.7 168.9 392.5 182.6 346 195.2 352.4 219 395.3 207.4 398.7 220.4 355.9 232 363 258.5 410.3 245.8 414.1 259.6 351.2 276.5 326.7 185.7"/>
|
||||
<polygon class="st1" points="396.7 166.8 412.3 162.6 433.1 239.8 478.4 227.6 482.1 241.3 421.1 257.7 396.7 166.8"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 500 500">
|
||||
<!-- Generator: Adobe Illustrator 29.8.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 2) -->
|
||||
<defs>
|
||||
<style>
|
||||
.st0 {
|
||||
fill: #fff;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g>
|
||||
<path class="st0" d="M251,498.5c-66.3,0-128.7-25.8-175.6-72.7S2.7,316.6,2.7,250.2,28.6,121.6,75.5,74.7,184.7,2,251,2s128.7,25.8,175.6,72.7,72.7,109.2,72.7,175.6-25.8,128.7-72.7,175.6-109.2,72.7-175.6,72.7Z"/>
|
||||
<path d="M251,2.5c33.5,0,65.9,6.6,96.4,19.5,29.5,12.5,56,30.3,78.8,53.1,22.8,22.8,40.6,49.3,53.1,78.8,12.9,30.5,19.5,63,19.5,96.4s-6.6,65.9-19.5,96.4c-12.5,29.5-30.3,56-53.1,78.8-22.8,22.8-49.3,40.6-78.8,53.1-30.5,12.9-63,19.5-96.4,19.5s-65.9-6.6-96.4-19.5c-29.5-12.5-56-30.3-78.8-53.1-22.8-22.8-40.6-49.3-53.1-78.8-12.9-30.5-19.5-63-19.5-96.4s6.6-65.9,19.5-96.4c12.5-29.5,30.3-56,53.1-78.8,22.8-22.8,49.3-40.6,78.8-53.1,30.5-12.9,63-19.5,96.4-19.5M251,1.5C113.6,1.5,2.2,112.9,2.2,250.2s111.4,248.8,248.8,248.8,248.8-111.4,248.8-248.8S388.4,1.5,251,1.5h0Z"/>
|
||||
</g>
|
||||
<path d="M77,347.7c-13.6-.5-24.7-4.3-33.3-11.1-1.7-1.3-5.6-5.3-6.9-6.9-3.5-4.4-5.9-8.7-7.5-13.5-4.9-14.7-2.4-34,7.2-55.2,8.2-18.1,20.9-36.1,43.1-61,3.3-3.7,13-14.4,13-14.4s-.5.9-1.2,2c-5.8,9.6-10.7,21-13.4,30.9-4.3,15.8-3.8,29.3,1.5,39.8,3.7,7.2,10,13.5,17,17,12.4,6.1,30.5,6.6,52.6,1.5,1.5-.4,77-20.4,167.8-44.5,90.8-24.1,165-43.9,165.1-43.9,0,0-210.9,90.3-320.4,137.1-17.3,7.4-22,9.3-30.1,12.1-20.8,7.3-39.5,10.8-54.6,10.2h0Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 500 500">
|
||||
<!-- Generator: Adobe Illustrator 29.8.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 2) -->
|
||||
<defs>
|
||||
<style>
|
||||
.st0 {
|
||||
fill: #fff;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g>
|
||||
<path d="M251,498.5c-66.3,0-128.7-25.8-175.6-72.7S2.7,316.6,2.7,250.2,28.6,121.6,75.5,74.7,184.7,2,251,2s128.7,25.8,175.6,72.7,72.7,109.2,72.7,175.6-25.8,128.7-72.7,175.6-109.2,72.7-175.6,72.7Z"/>
|
||||
<path class="st0" d="M251,2.5c33.5,0,65.9,6.6,96.4,19.5,29.5,12.5,56,30.3,78.8,53.1,22.8,22.8,40.6,49.3,53.1,78.8,12.9,30.5,19.5,63,19.5,96.4s-6.6,65.9-19.5,96.4c-12.5,29.5-30.3,56-53.1,78.8-22.8,22.8-49.3,40.6-78.8,53.1-30.5,12.9-63,19.5-96.4,19.5s-65.9-6.6-96.4-19.5c-29.5-12.5-56-30.3-78.8-53.1-22.8-22.8-40.6-49.3-53.1-78.8-12.9-30.5-19.5-63-19.5-96.4s6.6-65.9,19.5-96.4c12.5-29.5,30.3-56,53.1-78.8,22.8-22.8,49.3-40.6,78.8-53.1,30.5-12.9,63-19.5,96.4-19.5M251,1.5C113.6,1.5,2.2,112.9,2.2,250.2s111.4,248.8,248.8,248.8,248.8-111.4,248.8-248.8S388.4,1.5,251,1.5h0Z"/>
|
||||
</g>
|
||||
<g id="g4">
|
||||
<path id="path1" class="st0" d="M231.8,78c-38.2,0-81.6,7.2-118.3,23.7-34.2,15.3-62.1,39.9-62.1,80.6,0,21.3,8.1,41.9,22.7,57.4,9.9,9.1,25.9,24.7,67.6,33.8,18.7,3.9,58.5,10.3,98.2,14.5,39.7,4.2,78.2,7.9,94,12.1,12.5,3.2,33.6,7.5,33.6,31.2s-22.2,30.8-26.1,32.3c-3.9,1.5-30.5,13.8-78.4,13.8-31.7-.9-63.2-6.3-93.3-16.1-17.9-6.4-36.6-14.8-54.1-36.1-7.1-10-11-22-11.2-34.2h-43.4v121.5h48.2v-16.4c0-3.8,3-6.9,6.8-6.9,1.3,0,2.5.3,3.6,1,22.7,9.9,46.3,17.5,70.5,22.8,25.3,5.3,41.7,9.1,73.2,9.1,33.2.6,66.3-4,98-13.8,21.1-5.8,40.8-15.5,58.2-28.7,19.8-15.2,31.4-38.9,31.2-63.9.2-23.4-8.9-46-25.2-62.9-9.1-8.8-19.6-16-31.1-21.3-12.4-5.6-25.2-10.1-38.3-13.4-25-6.1-81.2-13.8-108-16.4-28.2-2.9-77-7-96.5-13-5.9-1.9-18-7.6-18-21.6s5.5-18.4,16.4-25.2c17.3-10.8,52.3-17.6,88.7-17.6,35.3-.4,70.3,6.4,102.8,20.1,8.7,3.8,17,8.7,24.5,14.6,12.2,10.4,20.7,24.6,24.1,40.3h38.9v-105.7h-43.4v12.3c0,3.9-3.9,9.1-11.8,4.9-19.6-10.2-74.5-32.2-141.9-32.5Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="0 0 500 500">
|
||||
<!-- Generator: Adobe Illustrator 29.8.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 2) -->
|
||||
<defs>
|
||||
<style>
|
||||
.st0 {
|
||||
fill: #f50a23;
|
||||
}
|
||||
|
||||
.st1 {
|
||||
fill: url(#radial-gradient);
|
||||
}
|
||||
|
||||
.st2 {
|
||||
fill: #fff;
|
||||
}
|
||||
</style>
|
||||
<radialGradient id="radial-gradient" cx="-216" cy="349.6" fx="-216" fy="349.6" r="15.4" gradientTransform="translate(3634.5 -1969.1) rotate(-90) scale(10.7 -9.7)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#f5ff1e"/>
|
||||
<stop offset="1" stop-color="#f50a23"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<g>
|
||||
<path class="st2" d="M251,498.5c-66.3,0-128.7-25.8-175.6-72.7S2.7,316.6,2.7,250.2,28.6,121.6,75.5,74.7,184.7,2,251,2s128.7,25.8,175.6,72.7,72.7,109.2,72.7,175.6-25.8,128.7-72.7,175.6-109.2,72.7-175.6,72.7Z"/>
|
||||
<path d="M251,2.5c33.5,0,65.9,6.6,96.4,19.5,29.5,12.5,56,30.3,78.8,53.1,22.8,22.8,40.6,49.3,53.1,78.8,12.9,30.5,19.5,63,19.5,96.4s-6.6,65.9-19.5,96.4c-12.5,29.5-30.3,56-53.1,78.8-22.8,22.8-49.3,40.6-78.8,53.1-30.5,12.9-63,19.5-96.4,19.5s-65.9-6.6-96.4-19.5c-29.5-12.5-56-30.3-78.8-53.1-22.8-22.8-40.6-49.3-53.1-78.8-12.9-30.5-19.5-63-19.5-96.4s6.6-65.9,19.5-96.4c12.5-29.5,30.3-56,53.1-78.8,22.8-22.8,49.3-40.6,78.8-53.1,30.5-12.9,63-19.5,96.4-19.5M251,1.5C113.6,1.5,2.2,112.9,2.2,250.2s111.4,248.8,248.8,248.8,248.8-111.4,248.8-248.8S388.4,1.5,251,1.5h0Z"/>
|
||||
</g>
|
||||
<g id="layer1">
|
||||
<path id="path27" class="st1" d="M60.7,109.5l131,341.1c1,2.6,3.5,4.3,6.3,4.3h106.2c-2.8,0-5.3-1.7-6.3-4.3L166.9,109.5H60.7Z"/>
|
||||
<path id="path29" class="st0" d="M441.4,109.5l-131,341.1c-1,2.6-3.5,4.3-6.3,4.3h-106.2c2.8,0,5.3-1.7,6.3-4.3L335.1,109.5h106.2Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,476 @@
|
||||
/*
|
||||
* Default Trix editor styles. See Action Text overwrites below.
|
||||
*/
|
||||
|
||||
trix-editor {
|
||||
border: 1px solid #bbb;
|
||||
border-radius: 3px;
|
||||
margin: 0;
|
||||
padding: 0.4em 0.6em;
|
||||
min-height: 5em;
|
||||
outline: none; }
|
||||
|
||||
trix-toolbar * {
|
||||
box-sizing: border-box; }
|
||||
|
||||
trix-toolbar .trix-button-row {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: space-between;
|
||||
overflow-x: auto; }
|
||||
|
||||
trix-toolbar .trix-button-group {
|
||||
display: flex;
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid #bbb;
|
||||
border-top-color: #ccc;
|
||||
border-bottom-color: #888;
|
||||
border-radius: 3px; }
|
||||
trix-toolbar .trix-button-group:not(:first-child) {
|
||||
margin-left: 1.5vw; }
|
||||
@media (max-width: 768px) {
|
||||
trix-toolbar .trix-button-group:not(:first-child) {
|
||||
margin-left: 0; } }
|
||||
|
||||
trix-toolbar .trix-button-group-spacer {
|
||||
flex-grow: 1; }
|
||||
@media (max-width: 768px) {
|
||||
trix-toolbar .trix-button-group-spacer {
|
||||
display: none; } }
|
||||
|
||||
trix-toolbar .trix-button {
|
||||
position: relative;
|
||||
float: left;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
font-size: 0.75em;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
padding: 0 0.5em;
|
||||
margin: 0;
|
||||
outline: none;
|
||||
border: none;
|
||||
border-bottom: 1px solid #ddd;
|
||||
border-radius: 0;
|
||||
background: transparent; }
|
||||
trix-toolbar .trix-button:not(:first-child) {
|
||||
border-left: 1px solid #ccc; }
|
||||
trix-toolbar .trix-button.trix-active {
|
||||
background: #cbeefa;
|
||||
color: black; }
|
||||
trix-toolbar .trix-button:not(:disabled) {
|
||||
cursor: pointer; }
|
||||
trix-toolbar .trix-button:disabled {
|
||||
color: rgba(0, 0, 0, 0.125); }
|
||||
@media (max-width: 768px) {
|
||||
trix-toolbar .trix-button {
|
||||
letter-spacing: -0.01em;
|
||||
padding: 0 0.3em; } }
|
||||
|
||||
trix-toolbar .trix-button--icon {
|
||||
font-size: inherit;
|
||||
width: 2.6em;
|
||||
height: 1.6em;
|
||||
max-width: calc(0.8em + 4vw);
|
||||
text-indent: -9999px; }
|
||||
@media (max-width: 768px) {
|
||||
trix-toolbar .trix-button--icon {
|
||||
height: 2em;
|
||||
max-width: calc(0.8em + 3.5vw); } }
|
||||
trix-toolbar .trix-button--icon::before {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
opacity: 0.6;
|
||||
content: "";
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: contain; }
|
||||
@media (max-width: 768px) {
|
||||
trix-toolbar .trix-button--icon::before {
|
||||
right: 6%;
|
||||
left: 6%; } }
|
||||
trix-toolbar .trix-button--icon.trix-active::before {
|
||||
opacity: 1; }
|
||||
trix-toolbar .trix-button--icon:disabled::before {
|
||||
opacity: 0.125; }
|
||||
|
||||
/* Show the attach button in Trix toolbar */
|
||||
.trix-button-row > span.trix-button-group {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
[data-trix-button-group="file-tools"] {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
.trix-button-row > .trix-button-group-spacer {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
.trix-button--icon-attach {
|
||||
display: inline-block !important;
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button-group--file-tools {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button-group-spacer {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button--icon-attach,
|
||||
trix-editor .trix-button--icon-attach {
|
||||
display: inline-block !important;
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button--icon-attach::before {
|
||||
background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M10.5%2018V7.5c0-2.25%203-2.25%203%200V18c0%204.125-6%204.125-6%200V7.5c0-6.375%209-6.375%209%200V18%22%20stroke%3D%22%23000%22%20stroke-width%3D%222%22%20stroke-miterlimit%3D%2210%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3C%2Fsvg%3E");
|
||||
top: 8%;
|
||||
bottom: 4%; }
|
||||
|
||||
trix-toolbar .trix-button--icon-bold::before {
|
||||
background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M6.522%2019.242a.5.5%200%200%201-.5-.5V5.35a.5.5%200%200%201%20.5-.5h5.783c1.347%200%202.46.345%203.24.982.783.64%201.216%201.562%201.216%202.683%200%201.13-.587%202.129-1.476%202.71a.35.35%200%200%200%20.049.613c1.259.56%202.101%201.742%202.101%203.22%200%201.282-.483%202.334-1.363%203.063-.876.726-2.132%201.12-3.66%201.12h-5.89ZM9.27%207.347v3.362h1.97c.766%200%201.347-.17%201.733-.464.38-.291.587-.716.587-1.27%200-.53-.183-.928-.513-1.198-.334-.273-.838-.43-1.505-.43H9.27Zm0%205.606v3.791h2.389c.832%200%201.448-.177%201.853-.497.399-.315.614-.786.614-1.423%200-.62-.22-1.077-.63-1.385-.418-.313-1.053-.486-1.905-.486H9.27Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); }
|
||||
|
||||
trix-toolbar .trix-button--icon-italic::before {
|
||||
background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M9%205h6.5v2h-2.23l-2.31%2010H13v2H6v-2h2.461l2.306-10H9V5Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); }
|
||||
|
||||
trix-toolbar .trix-button--icon-link::before {
|
||||
background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M18.948%205.258a4.337%204.337%200%200%200-6.108%200L11.217%206.87a.993.993%200%200%200%200%201.41c.392.39%201.027.39%201.418%200l1.623-1.613a2.323%202.323%200%200%201%203.271%200%202.29%202.29%200%200%201%200%203.251l-2.393%202.38a3.021%203.021%200%200%201-4.255%200l-.05-.049a1.007%201.007%200%200%200-1.418%200%20.993.993%200%200%200%200%201.41l.05.049a5.036%205.036%200%200%200%207.091%200l2.394-2.38a4.275%204.275%200%200%200%200-6.072Zm-13.683%2013.6a4.337%204.337%200%200%200%206.108%200l1.262-1.255a.993.993%200%200%200%200-1.41%201.007%201.007%200%200%200-1.418%200L9.954%2017.45a2.323%202.323%200%200%201-3.27%200%202.29%202.29%200%200%201%200-3.251l2.344-2.331a2.579%202.579%200%200%201%203.631%200c.392.39%201.027.39%201.419%200a.993.993%200%200%200%200-1.41%204.593%204.593%200%200%200-6.468%200l-2.345%202.33a4.275%204.275%200%200%200%200%206.072Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); }
|
||||
|
||||
trix-toolbar .trix-button--icon-strike::before {
|
||||
background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M6%2014.986c.088%202.647%202.246%204.258%205.635%204.258%203.496%200%205.713-1.728%205.713-4.463%200-.275-.02-.536-.062-.781h-3.461c.398.293.573.654.573%201.123%200%201.035-1.074%201.787-2.646%201.787-1.563%200-2.773-.762-2.91-1.924H6ZM6.432%2010h3.763c-.632-.314-.914-.715-.914-1.273%200-1.045.977-1.739%202.432-1.739%201.475%200%202.52.723%202.617%201.914h2.764c-.05-2.548-2.11-4.238-5.39-4.238-3.145%200-5.392%201.719-5.392%204.316%200%20.363.04.703.12%201.02ZM4%2011a1%201%200%201%200%200%202h15a1%201%200%201%200%200-2H4Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); }
|
||||
|
||||
trix-toolbar .trix-button--icon-quote::before {
|
||||
background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M4.581%208.471c.44-.5%201.056-.834%201.758-.995C8.074%207.17%209.201%207.822%2010%208.752c1.354%201.578%201.33%203.555.394%205.277-.941%201.731-2.788%203.163-4.988%203.56a.622.622%200%200%201-.653-.317c-.113-.205-.121-.49.16-.764.294-.286.567-.566.791-.835.222-.266.413-.54.524-.815.113-.28.156-.597.026-.908-.128-.303-.39-.524-.72-.69a3.02%203.02%200%200%201-1.674-2.7c0-.905.283-1.59.72-2.088Zm9.419%200c.44-.5%201.055-.834%201.758-.995%201.734-.306%202.862.346%203.66%201.276%201.355%201.578%201.33%203.555.395%205.277-.941%201.731-2.789%203.163-4.988%203.56a.622.622%200%200%201-.653-.317c-.113-.205-.122-.49.16-.764.294-.286.567-.566.791-.835.222-.266.412-.54.523-.815.114-.28.157-.597.026-.908-.127-.303-.39-.524-.72-.69a3.02%203.02%200%200%201-1.672-2.701c0-.905.283-1.59.72-2.088Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); }
|
||||
|
||||
trix-toolbar .trix-button--icon-heading-1::before {
|
||||
background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M21.5%207.5v-3h-12v3H14v13h3v-13h4.5ZM9%2013.5h3.5v-3h-10v3H6v7h3v-7Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); }
|
||||
|
||||
trix-toolbar .trix-button--icon-code::before {
|
||||
background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M3.293%2011.293a1%201%200%200%200%200%201.414l4%204a1%201%200%201%200%201.414-1.414L5.414%2012l3.293-3.293a1%201%200%200%200-1.414-1.414l-4%204Zm13.414%205.414%204-4a1%201%200%200%200%200-1.414l-4-4a1%201%200%201%200-1.414%201.414L18.586%2012l-3.293%203.293a1%201%200%200%200%201.414%201.414Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); }
|
||||
|
||||
trix-toolbar .trix-button--icon-bullet-list::before {
|
||||
background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M5%207.5a1.5%201.5%200%201%200%200-3%201.5%201.5%200%200%200%200%203ZM8%206a1%201%200%200%201%201-1h11a1%201%200%201%201%200%202H9a1%201%200%200%201-1-1Zm1%205a1%201%200%201%200%200%202h11a1%201%200%201%200%200-2H9Zm0%206a1%201%200%201%200%200%202h11a1%201%200%201%200%200-2H9Zm-2.5-5a1.5%201.5%200%201%201-3%200%201.5%201.5%200%200%201%203%200ZM5%2019.5a1.5%201.5%200%201%200%200-3%201.5%201.5%200%200%200%200%203Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); }
|
||||
|
||||
trix-toolbar .trix-button--icon-number-list::before {
|
||||
background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M3%204h2v4H4V5H3V4Zm5%202a1%201%200%200%201%201-1h11a1%201%200%201%201%200%202H9a1%201%200%200%201-1-1Zm1%205a1%201%200%201%200%200%202h11a1%201%200%201%200%200-2H9Zm0%206a1%201%200%201%200%200%202h11a1%201%200%201%200%200-2H9Zm-3.5-7H6v1l-1.5%202H6v1H3v-1l1.667-2H3v-1h2.5ZM3%2017v-1h3v4H3v-1h2v-.5H4v-1h1V17H3Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); }
|
||||
|
||||
trix-toolbar .trix-button--icon-undo::before {
|
||||
background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M3%2014a1%201%200%200%200%201%201h6a1%201%200%201%200%200-2H6.257c2.247-2.764%205.151-3.668%207.579-3.264%202.589.432%204.739%202.356%205.174%205.405a1%201%200%200%200%201.98-.283c-.564-3.95-3.415-6.526-6.825-7.095C11.084%207.25%207.63%208.377%205%2011.39V8a1%201%200%200%200-2%200v6Zm2-1Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); }
|
||||
|
||||
trix-toolbar .trix-button--icon-redo::before {
|
||||
background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M21%2014a1%201%200%200%201-1%201h-6a1%201%200%201%201%200-2h3.743c-2.247-2.764-5.151-3.668-7.579-3.264-2.589.432-4.739%202.356-5.174%205.405a1%201%200%200%201-1.98-.283c.564-3.95%203.415-6.526%206.826-7.095%203.08-.513%206.534.614%209.164%203.626V8a1%201%200%201%201%202%200v6Zm-2-1Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); }
|
||||
|
||||
trix-toolbar .trix-button--icon-decrease-nesting-level::before {
|
||||
background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M5%206a1%201%200%200%201%201-1h12a1%201%200%201%201%200%202H6a1%201%200%200%201-1-1Zm4%205a1%201%200%201%200%200%202h9a1%201%200%201%200%200-2H9Zm-3%206a1%201%200%201%200%200%202h12a1%201%200%201%200%200-2H6Zm-3.707-5.707a1%201%200%200%200%200%201.414l2%202a1%201%200%201%200%201.414-1.414L4.414%2012l1.293-1.293a1%201%200%200%200-1.414-1.414l-2%202Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); }
|
||||
|
||||
trix-toolbar .trix-button--icon-increase-nesting-level::before {
|
||||
background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M5%206a1%201%200%200%201%201-1h12a1%201%200%201%201%200%202H6a1%201%200%200%201-1-1Zm4%205a1%201%200%201%200%200%202h9a1%201%200%201%200%200-2H9Zm-3%206a1%201%200%201%200%200%202h12a1%201%200%201%200%200-2H6Zm-2.293-2.293%202-2a1%201%200%200%200%200-1.414l-2-2a1%201%200%201%200-1.414%201.414L3.586%2012l-1.293%201.293a1%201%200%201%200%201.414%201.414Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); }
|
||||
|
||||
trix-toolbar .trix-dialogs {
|
||||
position: relative; }
|
||||
|
||||
trix-toolbar .trix-dialog {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
font-size: 0.75em;
|
||||
padding: 15px 10px;
|
||||
background: #fff;
|
||||
box-shadow: 0 0.3em 1em #ccc;
|
||||
border-top: 2px solid #888;
|
||||
border-radius: 5px;
|
||||
z-index: 5; }
|
||||
|
||||
trix-toolbar .trix-input--dialog {
|
||||
font-size: inherit;
|
||||
font-weight: normal;
|
||||
padding: 0.5em 0.8em;
|
||||
margin: 0 10px 0 0;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #bbb;
|
||||
background-color: #fff;
|
||||
box-shadow: none;
|
||||
outline: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none; }
|
||||
trix-toolbar .trix-input--dialog.validate:invalid {
|
||||
box-shadow: #F00 0px 0px 1.5px 1px; }
|
||||
|
||||
trix-toolbar .trix-button--dialog {
|
||||
font-size: inherit;
|
||||
padding: 0.5em;
|
||||
border-bottom: none; }
|
||||
|
||||
trix-toolbar .trix-dialog--link {
|
||||
max-width: 600px; }
|
||||
|
||||
trix-toolbar .trix-dialog__link-fields {
|
||||
display: flex;
|
||||
align-items: baseline; }
|
||||
trix-toolbar .trix-dialog__link-fields .trix-input {
|
||||
flex: 1; }
|
||||
trix-toolbar .trix-dialog__link-fields .trix-button-group {
|
||||
flex: 0 0 content;
|
||||
margin: 0; }
|
||||
|
||||
trix-editor [data-trix-mutable]:not(.attachment__caption-editor) {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none; }
|
||||
|
||||
trix-editor [data-trix-mutable]::-moz-selection,
|
||||
trix-editor [data-trix-cursor-target]::-moz-selection, trix-editor [data-trix-mutable] ::-moz-selection {
|
||||
background: none; }
|
||||
|
||||
trix-editor [data-trix-mutable]::selection,
|
||||
trix-editor [data-trix-cursor-target]::selection, trix-editor [data-trix-mutable] ::selection {
|
||||
background: none; }
|
||||
|
||||
trix-editor .attachment__caption-editor:focus[data-trix-mutable]::-moz-selection {
|
||||
background: highlight; }
|
||||
|
||||
trix-editor .attachment__caption-editor:focus[data-trix-mutable]::selection {
|
||||
background: highlight; }
|
||||
|
||||
trix-editor [data-trix-mutable].attachment.attachment--file {
|
||||
box-shadow: 0 0 0 2px highlight;
|
||||
border-color: transparent; }
|
||||
|
||||
trix-editor [data-trix-mutable].attachment img {
|
||||
box-shadow: 0 0 0 2px highlight; }
|
||||
|
||||
trix-editor .attachment {
|
||||
position: relative; }
|
||||
trix-editor .attachment:hover {
|
||||
cursor: default; }
|
||||
|
||||
trix-editor .attachment--preview .attachment__caption:hover {
|
||||
cursor: text; }
|
||||
|
||||
trix-editor .attachment__progress {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
height: 20px;
|
||||
top: calc(50% - 10px);
|
||||
left: 5%;
|
||||
width: 90%;
|
||||
opacity: 0.9;
|
||||
transition: opacity 200ms ease-in; }
|
||||
trix-editor .attachment__progress[value="100"] {
|
||||
opacity: 0; }
|
||||
|
||||
trix-editor .attachment__caption-editor {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
line-height: inherit;
|
||||
color: inherit;
|
||||
text-align: center;
|
||||
vertical-align: top;
|
||||
border: none;
|
||||
outline: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none; }
|
||||
|
||||
trix-editor .attachment__toolbar {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: -0.9em;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
text-align: center; }
|
||||
|
||||
trix-editor .trix-button-group {
|
||||
display: inline-flex; }
|
||||
|
||||
trix-editor .trix-button {
|
||||
position: relative;
|
||||
float: left;
|
||||
color: #666;
|
||||
white-space: nowrap;
|
||||
font-size: 80%;
|
||||
padding: 0 0.8em;
|
||||
margin: 0;
|
||||
outline: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent; }
|
||||
trix-editor .trix-button:not(:first-child) {
|
||||
border-left: 1px solid #ccc; }
|
||||
trix-editor .trix-button.trix-active {
|
||||
background: #cbeefa; }
|
||||
trix-editor .trix-button:not(:disabled) {
|
||||
cursor: pointer; }
|
||||
|
||||
trix-editor .trix-button--remove {
|
||||
text-indent: -9999px;
|
||||
display: inline-block;
|
||||
padding: 0;
|
||||
outline: none;
|
||||
width: 1.8em;
|
||||
height: 1.8em;
|
||||
line-height: 1.8em;
|
||||
border-radius: 50%;
|
||||
background-color: #fff;
|
||||
border: 2px solid highlight;
|
||||
box-shadow: 1px 1px 6px rgba(0, 0, 0, 0.25); }
|
||||
trix-editor .trix-button--remove::before {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
opacity: 0.7;
|
||||
content: "";
|
||||
background-image: url("data:image/svg+xml,%3Csvg%20height%3D%2224%22%20width%3D%2224%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M19%206.41%2017.59%205%2012%2010.59%206.41%205%205%206.41%2010.59%2012%205%2017.59%206.41%2019%2012%2013.41%2017.59%2019%2019%2017.59%2013.41%2012z%22%2F%3E%3Cpath%20d%3D%22M0%200h24v24H0z%22%20fill%3D%22none%22%2F%3E%3C%2Fsvg%3E");
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 90%; }
|
||||
trix-editor .trix-button--remove:hover {
|
||||
border-color: #333; }
|
||||
trix-editor .trix-button--remove:hover::before {
|
||||
opacity: 1; }
|
||||
|
||||
trix-editor .attachment__metadata-container {
|
||||
position: relative; }
|
||||
|
||||
trix-editor .attachment__metadata {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 2em;
|
||||
transform: translate(-50%, 0);
|
||||
max-width: 90%;
|
||||
padding: 0.1em 0.6em;
|
||||
font-size: 0.8em;
|
||||
color: #fff;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
border-radius: 3px; }
|
||||
trix-editor .attachment__metadata .attachment__name {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
vertical-align: bottom;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap; }
|
||||
trix-editor .attachment__metadata .attachment__size {
|
||||
margin-left: 0.2em;
|
||||
white-space: nowrap; }
|
||||
|
||||
.trix-content {
|
||||
line-height: 1.5;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word; }
|
||||
.trix-content * {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0; }
|
||||
.trix-content h1 {
|
||||
font-size: 1.2em;
|
||||
line-height: 1.2; }
|
||||
.trix-content blockquote {
|
||||
border: 0 solid #ccc;
|
||||
border-left-width: 0.3em;
|
||||
margin-left: 0.3em;
|
||||
padding-left: 0.6em; }
|
||||
.trix-content [dir=rtl] blockquote,
|
||||
.trix-content blockquote[dir=rtl] {
|
||||
border-width: 0;
|
||||
border-right-width: 0.3em;
|
||||
margin-right: 0.3em;
|
||||
padding-right: 0.6em; }
|
||||
.trix-content ul {
|
||||
list-style-type: disc;
|
||||
padding-left: 1.5em; }
|
||||
.trix-content ol {
|
||||
list-style-type: decimal;
|
||||
padding-left: 1.5em; }
|
||||
.trix-content li {
|
||||
margin-left: 1em; }
|
||||
.trix-content [dir=rtl] li {
|
||||
margin-right: 1em; }
|
||||
.trix-content pre {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
vertical-align: top;
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
padding: 0.5em;
|
||||
white-space: pre;
|
||||
background-color: #eee;
|
||||
overflow-x: auto; }
|
||||
.trix-content img {
|
||||
max-width: 100%;
|
||||
height: auto; }
|
||||
.trix-content .attachment {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
max-width: 100%; }
|
||||
.trix-content .attachment a {
|
||||
color: inherit;
|
||||
text-decoration: none; }
|
||||
.trix-content .attachment a:hover, .trix-content .attachment a:visited:hover {
|
||||
color: inherit; }
|
||||
.trix-content .attachment__caption {
|
||||
text-align: center; }
|
||||
.trix-content .attachment__caption .attachment__name + .attachment__size::before {
|
||||
content: ' \2022 '; }
|
||||
.trix-content .attachment--preview {
|
||||
width: 100%;
|
||||
text-align: center; }
|
||||
.trix-content .attachment--preview .attachment__caption {
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
line-height: 1.2; }
|
||||
.trix-content .attachment--file {
|
||||
color: #333;
|
||||
line-height: 1;
|
||||
margin: 0 2px 2px 2px;
|
||||
padding: 0.4em 1em;
|
||||
border: 1px solid #bbb;
|
||||
border-radius: 5px; }
|
||||
.trix-content .attachment-gallery {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
position: relative; }
|
||||
.trix-content .attachment-gallery .attachment {
|
||||
flex: 1 0 33%;
|
||||
padding: 0 0.5em;
|
||||
max-width: 33%; }
|
||||
.trix-content .attachment-gallery.attachment-gallery--2 .attachment, .trix-content .attachment-gallery.attachment-gallery--4 .attachment {
|
||||
flex-basis: 50%;
|
||||
max-width: 50%; }
|
||||
|
||||
/*
|
||||
* We need to override trix.css’s image gallery styles to accommodate the
|
||||
* <action-text-attachment> element we wrap around attachments. Otherwise,
|
||||
* images in galleries will be squished by the max-width: 33%; rule.
|
||||
*/
|
||||
.trix-content .attachment-gallery > action-text-attachment,
|
||||
.trix-content .attachment-gallery > .attachment {
|
||||
flex: 1 0 33%;
|
||||
padding: 0 0.5em;
|
||||
max-width: 33%;
|
||||
}
|
||||
|
||||
.trix-content .attachment-gallery.attachment-gallery--2 > action-text-attachment,
|
||||
.trix-content .attachment-gallery.attachment-gallery--2 > .attachment, .trix-content .attachment-gallery.attachment-gallery--4 > action-text-attachment,
|
||||
.trix-content .attachment-gallery.attachment-gallery--4 > .attachment {
|
||||
flex-basis: 50%;
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
.trix-content action-text-attachment .attachment {
|
||||
padding: 0 !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap");
|
||||
|
||||
/* Global color utilities */
|
||||
.text-green-up {
|
||||
color: #1EB500;
|
||||
}
|
||||
|
||||
.text-destructive {
|
||||
color: #EE4447;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
@layer components {
|
||||
.filter-tabs { @apply flex border-b border-gray-200 mr-8; }
|
||||
|
||||
.filter-tab,
|
||||
.filter-tab:link,
|
||||
.filter-tab:visited {
|
||||
@apply px-4 py-2 text-sm font-medium no-underline border-b-2 border-transparent transition;
|
||||
color: #4b5563 !important;
|
||||
}
|
||||
|
||||
.filter-tab:hover { background-color: #f9fafb !important; color: #374151 !important; }
|
||||
|
||||
.filter-tab:focus { outline: none !important; background-color: #f3f4f6 !important; color: #4b5563 !important; }
|
||||
|
||||
.filter-tab.active { border-bottom-color: #2563eb !important; color: #2563eb !important; }
|
||||
|
||||
.header-controls { @apply flex items-center justify-between mb-4; }
|
||||
.action-buttons { @apply flex gap-2; }
|
||||
|
||||
.form-select { @apply w-full rounded-md border border-input bg-transparent p-2 text-sm; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
@import "tailwindcss";
|
||||
@import "./shadcn.css";
|
||||
@import "./tailwind.config.css";
|
||||
@import "./navbar.css";
|
||||
@import "./tables.css";
|
||||
@import "./buttons.css";
|
||||
@import "./forms.css";
|
||||
@import "./admin.css";
|
||||
@@ -0,0 +1,41 @@
|
||||
.tw-btn-sell {
|
||||
@apply box-border content-stretch flex gap-2 h-9 items-center justify-center px-4 py-2 relative rounded-[16px] shrink-0 inline-flex bg-[var(--sitf-primary-blue)] text-white;
|
||||
}
|
||||
|
||||
.tw-btn-buy {
|
||||
@apply box-border content-stretch flex gap-2 h-9 items-center justify-center px-4 py-2 relative rounded-[16px] shrink-0 inline-flex bg-[var(--sitf-4)] text-white;
|
||||
}
|
||||
|
||||
.tw-btn-primary {
|
||||
@apply rounded-lg py-3 px-5 bg-[var(--sitf-primary-blue)] text-white block font-medium cursor-pointer hover:bg-blue-600 transition-colors
|
||||
}
|
||||
|
||||
.tw-btn-primary-disabled {
|
||||
@apply rounded-lg py-3 px-5 bg-gray-300 text-white block font-medium cursor-not-allowed
|
||||
}
|
||||
|
||||
.tw-btn-secondary {
|
||||
@apply rounded-md bg-[#BOEAE5] px-4 py-2 text-sm font-semibold text-white ring-1 ring-gray-300 ring-inset hover:bg-gray-50
|
||||
}
|
||||
|
||||
.tw-btn-tertiary {
|
||||
@apply ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium
|
||||
}
|
||||
|
||||
.tw-btn-danger {
|
||||
@apply rounded-lg py-3 ml-2 px-5 bg-red-100 text-red-500 inline-block font-medium hover:text-red-900
|
||||
}
|
||||
|
||||
/* Font Awesome icon styles */
|
||||
.fa-pencil, .fa-ban {
|
||||
font-size: 1.5rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.fa-pencil {
|
||||
color: #3b82f6; /* Blue color for edit */
|
||||
}
|
||||
|
||||
.fa-ban {
|
||||
color: #ef4444; /* Red color for cancel */
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
.tw-label-primary {
|
||||
@apply block text-sm/6 font-medium text-gray-900
|
||||
}
|
||||
|
||||
.required-indicator {
|
||||
@apply text-red-500 ml-1
|
||||
}
|
||||
|
||||
.tw-input-primary {
|
||||
@apply block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 border border-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
.navbar {
|
||||
@apply bg-white shadow-xs;
|
||||
}
|
||||
.navbar-container {
|
||||
@apply mx-auto max-w-7xl px-2 sm:px-6 lg:px-8;
|
||||
}
|
||||
.navbar-layout {
|
||||
@apply relative flex h-16 justify-between;
|
||||
}
|
||||
.navbar-mobile-button-container {
|
||||
@apply absolute inset-y-0 left-0 flex items-center sm:hidden;
|
||||
}
|
||||
.navbar-mobile-button {
|
||||
@apply relative inline-flex items-center justify-center rounded-md p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-500 focus:ring-2 focus:ring-indigo-500 focus:outline-hidden focus:ring-inset;
|
||||
}
|
||||
.navbar-svg-icon {
|
||||
@apply size-6;
|
||||
}
|
||||
.navbar-left-section {
|
||||
@apply flex flex-1 items-center justify-start sm:items-stretch sm:justify-start;
|
||||
}
|
||||
.navbar-logo-container {
|
||||
@apply flex shrink-0 items-center;
|
||||
}
|
||||
.navbar-logo {
|
||||
@apply h-8 w-auto;
|
||||
}
|
||||
.nav-user-link {
|
||||
@apply text-gray-900 font-medium;
|
||||
}
|
||||
.navbar-main-links-container {
|
||||
@apply hidden sm:ml-6 sm:flex sm:space-x-8;
|
||||
}
|
||||
.navbar-main-link {
|
||||
@apply inline-flex items-center border-b-2 px-1 pt-1 text-sm font-medium;
|
||||
}
|
||||
.navbar-main-link-active {
|
||||
@apply border-[#00B8B0] text-gray-900;
|
||||
}
|
||||
.navbar-main-link-default {
|
||||
@apply border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700;
|
||||
}
|
||||
.navbar-right-section {
|
||||
@apply absolute inset-y-0 right-0 flex items-center pr-2 sm:static sm:inset-auto sm:ml-6 sm:pr-0;
|
||||
}
|
||||
.navbar-user-links-container {
|
||||
@apply flex space-x-4;
|
||||
}
|
||||
.navbar-notification-button {
|
||||
@apply relative rounded-full bg-white p-1 text-gray-400 hover:text-gray-500 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-hidden;
|
||||
}
|
||||
|
||||
/* Stock ticker animation styles */
|
||||
.ticker-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ticker-content {
|
||||
display: flex;
|
||||
animation: scroll 20s linear infinite;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.ticker-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
margin-right: 2rem;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
@keyframes scroll {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-scroll {
|
||||
animation: scroll 20s linear infinite;
|
||||
}
|
||||
|
||||
/* Trading Floor collapse chevron toggle */
|
||||
details .icon-up {
|
||||
display: none;
|
||||
}
|
||||
|
||||
details[open] .icon-down {
|
||||
display: none;
|
||||
}
|
||||
|
||||
details[open] .icon-up {
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--destructive: 0 100% 50%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--success: 132, 95.3%, 33.3%, 0.74;
|
||||
--success-foreground: 109 55% 28%;
|
||||
|
||||
--info: 223 78% 42%;
|
||||
--info-foreground: 225 100% 50%;
|
||||
|
||||
--attention: 45 90% 45%;
|
||||
--attention-foreground: 60 98.4% 48.8% 0.82;
|
||||
|
||||
--ring: 215 20.2% 65.1%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
|
||||
/* SITF Color Palette */
|
||||
--sitf-background: #f7f9f3;
|
||||
--sitf-foreground: #000000;
|
||||
--sitf-primary-chart1: #00698c;
|
||||
--sitf-primary-secondary-foreground-card: #ffffff;
|
||||
--sitf-secondary-chart2: #1db8a6;
|
||||
--sitf-accent1-chart3: #D3DF44;
|
||||
--sitf-accent2-chart5: #F59E0B;
|
||||
--sitf-chart4: #EB4A97;
|
||||
--sitf-muted: #f0f0f0;
|
||||
--sitf-status-destructive: #ef4444;
|
||||
--sitf-input: #737373;
|
||||
--sitf-ring: #a5b4fc;
|
||||
|
||||
/* TODO: Legacy variables for backward compatibility - update to use standardized naming above */
|
||||
--sitf-primary-blue: #00698c;
|
||||
--sitf-secondary-teal: #00b8b0;
|
||||
--sitf-6: #161616;
|
||||
--sitf-7: #5c636a;
|
||||
--sitf-8: #a7adaf;
|
||||
--sitf-9: #dee0de;
|
||||
--sitf-dark-blue: #004f6b;
|
||||
--sitf-4: #80cec8;
|
||||
--sitf-5: #d3df44;
|
||||
--sitf-10: #eeefbe;
|
||||
--white: #ffffff;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 224 71% 4%;
|
||||
--foreground: 213 31% 91%;
|
||||
|
||||
--muted: 223 47% 11%;
|
||||
--muted-foreground: 215.4 16.3% 56.9%;
|
||||
|
||||
--accent: 216 34% 17%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
|
||||
--popover: 224 71% 4%;
|
||||
--popover-foreground: 215 20.2% 65.1%;
|
||||
|
||||
--border: 216 34% 17%;
|
||||
--input: 216 34% 17%;
|
||||
|
||||
--card: 224 71% 4%;
|
||||
--card-foreground: 213 31% 91%;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/* Table Styles */
|
||||
.table-container {
|
||||
@apply mb-4;
|
||||
}
|
||||
|
||||
.table-wrapper {
|
||||
@apply bg-white border-2 border-black rounded-[20px];
|
||||
}
|
||||
|
||||
.table-base {
|
||||
@apply w-full border-collapse font-sans bg-white rounded-[20px];
|
||||
}
|
||||
|
||||
.table-header-row {
|
||||
@apply border-b border-black bg-[#00B8B0];
|
||||
}
|
||||
|
||||
.table-header-cell {
|
||||
@apply h-10 px-7 py-3 font-medium text-base text-black border-b border-black;
|
||||
}
|
||||
|
||||
.table-header-cell-left {
|
||||
@apply text-left;
|
||||
}
|
||||
|
||||
.table-header-cell-left:nth-child(3) {
|
||||
@apply text-left px-4 lg:px-7;
|
||||
}
|
||||
|
||||
.table-header-cell-right {
|
||||
@apply text-right;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.table-header-cell-center {
|
||||
@apply text-center;
|
||||
}
|
||||
|
||||
.table-body-row {
|
||||
@apply h-14 border-b border-black;
|
||||
}
|
||||
|
||||
.table-body-cell {
|
||||
@apply px-7 py-2 border-b border-black;
|
||||
}
|
||||
|
||||
.table-body-cell:nth-child(3) {
|
||||
@apply px-4 lg:px-7;
|
||||
}
|
||||
|
||||
.table-body-cell-right {
|
||||
@apply text-right;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.table-body-cell-center {
|
||||
@apply text-center;
|
||||
}
|
||||
|
||||
.table-cell-content {
|
||||
@apply text-base text-black;
|
||||
}
|
||||
|
||||
.table-empty-cell {
|
||||
@apply px-7 py-4 text-center text-gray-500 border-b border-black;
|
||||
}
|
||||
|
||||
.table-no-permission {
|
||||
@apply text-gray-400;
|
||||
}
|
||||
|
||||
.table-action-link {
|
||||
@apply text-blue-600 hover:text-blue-800;
|
||||
}
|
||||
|
||||
.table-action-button {
|
||||
@apply text-red-600 hover:text-red-800 cursor-pointer;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
@theme {
|
||||
--default-dark-mode: class;
|
||||
|
||||
--container-center: true;
|
||||
--container-padding: 2rem;
|
||||
--breakpoint-2xl: 1400px;
|
||||
|
||||
--color-border: hsl(var(--border));
|
||||
--color-input: hsl(var(--input));
|
||||
--color-ring: hsl(var(--ring));
|
||||
--color-background: hsl(var(--background));
|
||||
--color-foreground: hsl(var(--foreground));
|
||||
|
||||
--color-primary: hsl(var(--primary));
|
||||
--color-primary-foreground: hsl(var(--primary-foreground));
|
||||
|
||||
--color-secondary: hsl(var(--secondary));
|
||||
--color-secondary-foreground: hsl(var(--secondary-foreground));
|
||||
|
||||
--color-destructive: hsl(var(--destructive));
|
||||
--color-destructive-foreground: hsl(var(--destructive-foreground));
|
||||
|
||||
--color-success: hsl(var(--success));
|
||||
--color-success-foreground: hsl(var(--success-foreground));
|
||||
|
||||
--color-info: hsl(var(--info));
|
||||
--color-info-foreground: hsl(var(--info-foreground));
|
||||
|
||||
--color-attention: var(--yellow-50);
|
||||
--color-attention-foreground: hsl(var(--attention-foreground));
|
||||
|
||||
--color-muted: hsl(var(--muted));
|
||||
--color-muted-foreground: hsl(var(--muted-foreground));
|
||||
|
||||
--color-accent: hsl(var(--accent));
|
||||
--color-accent-foreground: hsl(var(--accent-foreground));
|
||||
|
||||
--color-popover: hsl(var(--popover));
|
||||
--color-popover-foreground: hsl(var(--popover-foreground));
|
||||
|
||||
--color-card: hsl(var(--card));
|
||||
--color-card-foreground: hsl(var(--card-foreground));
|
||||
|
||||
/* Border radius */
|
||||
--radius-lg: var(--radius);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
|
||||
/* Font family */
|
||||
--font-sans:
|
||||
Inter, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji",
|
||||
"Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
|
||||
/* Keyframes */
|
||||
--animate-accordion-down: accordion-down 0.2s ease-out;
|
||||
--animate-accordion-up: accordion-up 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes accordion-down {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes accordion-up {
|
||||
from {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module ApplicationCable
|
||||
class Channel < ActionCable::Channel::Base
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module ApplicationCable
|
||||
class Connection < ActionCable::Connection::Base
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,56 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Shadcn
|
||||
class FormBuilder < ActionView::Helpers::FormBuilder
|
||||
def label(method, options = {})
|
||||
error_class = @object.errors[method].any? ? "error" : ""
|
||||
options[:class] = @template.tw("#{options[:class]} #{error_class}")
|
||||
@template.render_label(name: "#{object_name}[#{method}]", label: label_for(@object, method), **options)
|
||||
end
|
||||
|
||||
def text_field(method, options = {})
|
||||
error_class = @object.errors[method].any? ? "error" : ""
|
||||
options[:class] = @template.tw("#{options[:class]} #{error_class}")
|
||||
@template.render_input(
|
||||
name: "#{object_name}[#{method}]",
|
||||
id: "#{object_name}_#{method}",
|
||||
value: @object.send(method),
|
||||
type: "text", **options
|
||||
)
|
||||
end
|
||||
|
||||
def password_field(method, options = {})
|
||||
error_class = @object.errors[method].any? ? "error" : ""
|
||||
options[:class] = @template.tw("#{options[:class]} #{error_class}")
|
||||
@template.render_input(
|
||||
name: "#{object_name}[#{method}]",
|
||||
id: "#{object_name}_#{method}",
|
||||
value: @object.send(method),
|
||||
type: "password", **options
|
||||
)
|
||||
end
|
||||
|
||||
def email_field(method, options = {})
|
||||
error_class = @object.errors[method].any? ? "error" : ""
|
||||
options[:class] = @template.tw("#{options[:class]} #{error_class}")
|
||||
@template.render_input(
|
||||
name: "#{object_name}[#{method}]",
|
||||
id: "#{object_name}_#{method}",
|
||||
value: @object.send(method),
|
||||
type: "email", **options
|
||||
)
|
||||
end
|
||||
|
||||
def submit(value = nil, options = {})
|
||||
@template.render_button(value, **options)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def label_for(object, method)
|
||||
return method.capitalize if object.nil?
|
||||
|
||||
object.class.human_attribute_name(method)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,83 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Admin
|
||||
class AnnouncementsController < BaseController
|
||||
before_action :set_announcement, only: %i[show edit update destroy]
|
||||
|
||||
def index
|
||||
sort_column = params[:sort].presence || "created_at"
|
||||
sort_direction = params[:direction] == "desc" ? :desc : :asc
|
||||
|
||||
@announcements = Announcement.reorder(sort_column => sort_direction)
|
||||
|
||||
@breadcrumbs = [
|
||||
{ label: "Announcements" }
|
||||
]
|
||||
end
|
||||
|
||||
def show
|
||||
@breadcrumbs = [
|
||||
{ label: "Announcements", path: admin_announcements_path },
|
||||
{ label: @announcement.title }
|
||||
]
|
||||
end
|
||||
|
||||
def new
|
||||
@announcement = Announcement.new
|
||||
@breadcrumbs = [
|
||||
{ label: "Announcements", path: admin_announcements_path },
|
||||
{ label: "New Announcement" }
|
||||
]
|
||||
end
|
||||
|
||||
def edit
|
||||
@breadcrumbs = [
|
||||
{ label: "Announcements", path: admin_announcements_path },
|
||||
{ label: @announcement.title, path: admin_announcement_path(@announcement) },
|
||||
{ label: "Edit" }
|
||||
]
|
||||
end
|
||||
|
||||
def create
|
||||
@announcement = Announcement.new(announcement_params)
|
||||
|
||||
if @announcement.save
|
||||
redirect_to admin_announcement_path(@announcement), notice: t(".notice")
|
||||
else
|
||||
@breadcrumbs = [
|
||||
{ label: "Announcements", path: admin_announcements_path },
|
||||
{ label: "New Announcement" }
|
||||
]
|
||||
render :new, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
if @announcement.update(announcement_params)
|
||||
redirect_to admin_announcement_path(@announcement), notice: t(".notice")
|
||||
else
|
||||
@breadcrumbs = [
|
||||
{ label: "Announcements", path: admin_announcements_path },
|
||||
{ label: @announcement.title, path: admin_announcement_path(@announcement) },
|
||||
{ label: "Edit" }
|
||||
]
|
||||
render :edit, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@announcement.destroy
|
||||
redirect_to admin_announcements_path, notice: t(".notice")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_announcement
|
||||
@announcement = Announcement.find(params.expect(:id))
|
||||
end
|
||||
|
||||
def announcement_params
|
||||
params.expect(announcement: %i[title content featured])
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,27 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Base controller for in-house admin.
|
||||
# All Admin controllers inherit from this controller.
|
||||
module Admin
|
||||
class BaseController < ApplicationController
|
||||
layout "admin"
|
||||
|
||||
before_action :authenticate_admin
|
||||
|
||||
private
|
||||
|
||||
def authenticate_admin
|
||||
redirect_to root_path, alert: t("application.access_denied.admin_required") unless current_user&.admin?
|
||||
end
|
||||
|
||||
# Apply sorting to a collection based on params
|
||||
# @param collection [ActiveRecord::Relation] The base collection to sort
|
||||
# @param default [String] The default column to sort by
|
||||
# @return [ActiveRecord::Relation] The sorted collection
|
||||
def apply_sorting(collection, default:)
|
||||
sort_column = params[:sort].presence || default
|
||||
sort_direction = params[:direction] == "desc" ? :desc : :asc
|
||||
collection.reorder(sort_column => sort_direction)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,83 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Admin
|
||||
class ClassroomsController < BaseController
|
||||
before_action :set_classroom, only: %i[show edit update toggle_archive]
|
||||
|
||||
def index
|
||||
@classrooms = apply_sorting(Classroom.all, default: "name")
|
||||
|
||||
@breadcrumbs = [
|
||||
{ label: "Classrooms" }
|
||||
]
|
||||
end
|
||||
|
||||
def show
|
||||
@breadcrumbs = [
|
||||
{ label: "Classrooms", path: admin_classrooms_path },
|
||||
{ label: @classroom.name }
|
||||
]
|
||||
end
|
||||
|
||||
def new
|
||||
@classroom = Classroom.new
|
||||
|
||||
@breadcrumbs = [
|
||||
{ label: "Classrooms", path: admin_classrooms_path },
|
||||
{ label: "New Classroom" }
|
||||
]
|
||||
end
|
||||
|
||||
def edit
|
||||
@breadcrumbs = [
|
||||
{ label: "Classrooms", path: admin_classrooms_path },
|
||||
{ label: @classroom.name, path: admin_classroom_path(@classroom) },
|
||||
{ label: "Edit" }
|
||||
]
|
||||
end
|
||||
|
||||
def create
|
||||
@classroom = Classroom.new(classroom_params)
|
||||
|
||||
if @classroom.save
|
||||
redirect_to admin_classroom_path(@classroom), notice: t(".notice")
|
||||
else
|
||||
@breadcrumbs = [
|
||||
{ label: "Classrooms", path: admin_classrooms_path },
|
||||
{ label: "New Classroom" }
|
||||
]
|
||||
render :new, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
if @classroom.update(classroom_params)
|
||||
redirect_to admin_classroom_path(@classroom), notice: t(".notice")
|
||||
else
|
||||
@breadcrumbs = [
|
||||
{ label: "Classrooms", path: admin_classrooms_path },
|
||||
{ label: @classroom.name, path: admin_classroom_path(@classroom) },
|
||||
{ label: "Edit" }
|
||||
]
|
||||
render :edit, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
def toggle_archive
|
||||
authorize @classroom, :toggle_archive?
|
||||
@classroom.update!(archived: !@classroom.archived)
|
||||
flash[:notice] = @classroom.archived? ? "Classroom has been archived." : "Classroom has been activated."
|
||||
redirect_to admin_classroom_path(@classroom)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_classroom
|
||||
@classroom = Classroom.find(params.expect(:id))
|
||||
end
|
||||
|
||||
def classroom_params
|
||||
params.expect(classroom: [:name, :trading_enabled, :school_year_id, { grade_ids: [] }])
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,80 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Admin
|
||||
class ComponentDemoController < BaseController
|
||||
# rubocop:disable Metrics/MethodLength
|
||||
def index
|
||||
# Demo data for table component - apply sorting before limit
|
||||
@users = User.all
|
||||
|
||||
# Apply sorting if params present
|
||||
if params[:sort].present?
|
||||
direction = params[:direction] == "desc" ? :desc : :asc
|
||||
@users = @users.order(params[:sort] => direction)
|
||||
end
|
||||
|
||||
@users = @users.limit(10)
|
||||
|
||||
# Demo data for show component
|
||||
@sample_user = User.first || User.new(
|
||||
email: "demo@example.com",
|
||||
name: "Demo User",
|
||||
admin: true,
|
||||
created_at: Time.current
|
||||
)
|
||||
|
||||
# Demo breadcrumbs
|
||||
@breadcrumbs = [
|
||||
{ label: "Components", path: admin_component_demo_index_path },
|
||||
{ label: "Demo" }
|
||||
]
|
||||
|
||||
# Demo table columns
|
||||
@columns = [
|
||||
{ attribute: :id, label: "ID", sortable: true },
|
||||
{ attribute: :email, label: "Email", sortable: true },
|
||||
{ attribute: :name, label: "Name", sortable: true },
|
||||
{ attribute: :admin, label: "Admin", sortable: true },
|
||||
{ attribute: :created_at, label: "Created", sortable: true }
|
||||
]
|
||||
|
||||
# Demo filters
|
||||
@filters = [
|
||||
{
|
||||
name: :type,
|
||||
label: "User Type",
|
||||
options: [["All", ""], ["Admin", "admin"], ["Teacher", "teacher"], ["Student", "student"]]
|
||||
},
|
||||
{
|
||||
name: :status,
|
||||
label: "Status",
|
||||
options: [["All", ""], ["Active", "active"], ["Inactive", "inactive"]]
|
||||
}
|
||||
]
|
||||
end
|
||||
# rubocop:enable Metrics/MethodLength
|
||||
|
||||
def show
|
||||
@user = User.find(params.expect(:id))
|
||||
@breadcrumbs = [
|
||||
{ label: "Components", path: admin_component_demo_index_path },
|
||||
{ label: "User Details" }
|
||||
]
|
||||
end
|
||||
|
||||
def form
|
||||
@user = User.first || User.new(email: "", name: "", admin: false)
|
||||
|
||||
# Add validation errors for demo purposes if requested
|
||||
if params[:show_errors] == "true"
|
||||
@user.errors.add(:email, "can't be blank")
|
||||
@user.errors.add(:name, "is too short (minimum is 3 characters)")
|
||||
end
|
||||
|
||||
@breadcrumbs = [
|
||||
{ label: "Components", path: admin_component_demo_index_path },
|
||||
{ label: "Form Builder Demo" }
|
||||
]
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Admin
|
||||
class DashboardController < BaseController
|
||||
def index
|
||||
# Dashboard will show overview of admin resources
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,90 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Admin
|
||||
class PortfolioTransactionsController < BaseController
|
||||
before_action :set_portfolio_transaction, only: %i[show edit update destroy]
|
||||
|
||||
def show
|
||||
# TODO: FIX
|
||||
# authorize @portfolio_transaction
|
||||
|
||||
@breadcrumbs = [
|
||||
{ label: "Portfolio Transaction ##{@portfolio_transaction.id}" }
|
||||
]
|
||||
end
|
||||
|
||||
def new
|
||||
@portfolio_transaction = PortfolioTransaction.new
|
||||
# TODO: FIX
|
||||
# authorize @portfolio_transaction
|
||||
|
||||
@breadcrumbs = [
|
||||
{ label: "Portfolio Transactions", path: "#" },
|
||||
{ label: "New" }
|
||||
]
|
||||
end
|
||||
|
||||
def edit
|
||||
# TODO: Determine if explicit authorization is needed since BaseController already restricts to admins
|
||||
# authorize @portfolio_transaction
|
||||
|
||||
@breadcrumbs = [
|
||||
{ label: "Portfolio Transaction ##{@portfolio_transaction.id}",
|
||||
path: admin_portfolio_transaction_path(@portfolio_transaction) },
|
||||
{ label: "Edit" }
|
||||
]
|
||||
end
|
||||
|
||||
def create
|
||||
@portfolio_transaction = PortfolioTransaction.new(portfolio_transaction_params)
|
||||
# TODO: Determine if explicit authorization is needed since BaseController already restricts to admins
|
||||
# authorize @portfolio_transaction
|
||||
|
||||
if @portfolio_transaction.save
|
||||
redirect_to admin_portfolio_transaction_path(@portfolio_transaction),
|
||||
notice: t(".notice")
|
||||
else
|
||||
@breadcrumbs = [
|
||||
{ label: "Portfolio Transactions", path: "#" },
|
||||
{ label: "New" }
|
||||
]
|
||||
render :new, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
# TODO: Determine if explicit authorization is needed since BaseController already restricts to admins
|
||||
# authorize @portfolio_transaction
|
||||
|
||||
if @portfolio_transaction.update(portfolio_transaction_params)
|
||||
redirect_to admin_portfolio_transaction_path(@portfolio_transaction),
|
||||
notice: t(".notice")
|
||||
else
|
||||
@breadcrumbs = [
|
||||
{ label: "Portfolio Transaction ##{@portfolio_transaction.id}",
|
||||
path: admin_portfolio_transaction_path(@portfolio_transaction) },
|
||||
{ label: "Edit" }
|
||||
]
|
||||
render :edit, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
# TODO: Determine if explicit authorization is needed since BaseController already restricts to admins
|
||||
# authorize @portfolio_transaction
|
||||
@portfolio_transaction.destroy
|
||||
|
||||
redirect_to admin_root_path, notice: t(".notice")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_portfolio_transaction
|
||||
@portfolio_transaction = PortfolioTransaction.find(params.expect(:id))
|
||||
end
|
||||
|
||||
def portfolio_transaction_params
|
||||
params.expect(portfolio_transaction: %i[portfolio_id transaction_type reason description amount_cents])
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,111 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Admin
|
||||
class SchoolYearsController < BaseController
|
||||
before_action :set_school_year, only: %i[show edit update destroy]
|
||||
|
||||
def index
|
||||
@school_years = apply_sorting(SchoolYear.includes(:school, :year), default: "id")
|
||||
|
||||
@breadcrumbs = [
|
||||
{ label: "School Years" }
|
||||
]
|
||||
end
|
||||
|
||||
def show
|
||||
@breadcrumbs = [
|
||||
{ label: "School Years", path: admin_school_years_path },
|
||||
{ label: @school_year.to_s }
|
||||
]
|
||||
end
|
||||
|
||||
def new
|
||||
@school_year = SchoolYear.new
|
||||
|
||||
@breadcrumbs = [
|
||||
{ label: "School Years", path: admin_school_years_path },
|
||||
{ label: "New School Year" }
|
||||
]
|
||||
end
|
||||
|
||||
def edit
|
||||
@breadcrumbs = [
|
||||
{ label: "School Years", path: admin_school_years_path },
|
||||
{ label: @school_year.to_s, path: admin_school_year_path(@school_year) },
|
||||
{ label: "Edit" }
|
||||
]
|
||||
end
|
||||
|
||||
def create
|
||||
school = School.find_by(id: school_year_params[:school_id])
|
||||
year = Year.find_by(id: school_year_params[:year_id])
|
||||
|
||||
unless school && year
|
||||
@school_year = SchoolYear.new(school_year_params)
|
||||
@school_year.valid?
|
||||
return render_new_with_errors
|
||||
end
|
||||
|
||||
@school_year = SchoolYear.create!(school:, year:)
|
||||
|
||||
if @school_year.persisted?
|
||||
redirect_to admin_school_year_path(@school_year), notice: t(".notice")
|
||||
else
|
||||
render_new_with_errors
|
||||
end
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
save_error(school_year_params)
|
||||
render_new_with_errors
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
@school_year = e.record
|
||||
render_new_with_errors
|
||||
end
|
||||
|
||||
def update
|
||||
if @school_year.update(school_year_params)
|
||||
redirect_to admin_school_year_path(@school_year), notice: t(".notice")
|
||||
else
|
||||
@breadcrumbs = [
|
||||
{ label: "School Years", path: admin_school_years_path },
|
||||
{ label: @school_year.to_s, path: admin_school_year_path(@school_year) },
|
||||
{ label: "Edit" }
|
||||
]
|
||||
render :edit, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
if @school_year.destroy
|
||||
redirect_to admin_school_years_path, notice: t(".notice")
|
||||
else
|
||||
redirect_to admin_school_years_path, alert: t(".delete_restricted")
|
||||
end
|
||||
rescue ActiveRecord::DeleteRestrictionError
|
||||
redirect_to admin_school_years_path, alert: t(".delete_restricted")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_school_year
|
||||
@school_year = SchoolYear.find(params.expect(:id))
|
||||
end
|
||||
|
||||
def school_year_params
|
||||
params.expect(school_year: %i[school_id year_id])
|
||||
end
|
||||
|
||||
def render_new_with_errors
|
||||
@breadcrumbs = [
|
||||
{ label: "School Years", path: admin_school_years_path },
|
||||
{ label: "New School Year" }
|
||||
]
|
||||
render :new, status: :unprocessable_content
|
||||
end
|
||||
|
||||
def save_error(params)
|
||||
@school_year = SchoolYear.new(params)
|
||||
@school_year.valid?
|
||||
@school_year.errors.add(:base, "A School year with this school and year already exists.")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,97 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Admin
|
||||
class SchoolsController < BaseController
|
||||
before_action :set_school, only: %i[show edit update destroy]
|
||||
|
||||
def index
|
||||
sort_column = params[:sort].presence || "name"
|
||||
sort_direction = params[:direction] == "desc" ? :desc : :asc
|
||||
|
||||
@schools = School.reorder(sort_column => sort_direction)
|
||||
|
||||
@breadcrumbs = [
|
||||
{ label: "Schools" }
|
||||
]
|
||||
end
|
||||
|
||||
def show
|
||||
@breadcrumbs = [
|
||||
{ label: "Schools", path: admin_schools_path },
|
||||
{ label: @school.name }
|
||||
]
|
||||
end
|
||||
|
||||
def new
|
||||
@school = School.new
|
||||
set_form_data
|
||||
@breadcrumbs = [
|
||||
{ label: "Schools", path: admin_schools_path },
|
||||
{ label: "New School" }
|
||||
]
|
||||
end
|
||||
|
||||
def edit
|
||||
set_form_data
|
||||
@breadcrumbs = [
|
||||
{ label: "Schools", path: admin_schools_path },
|
||||
{ label: @school.name, path: admin_school_path(@school) },
|
||||
{ label: "Edit" }
|
||||
]
|
||||
end
|
||||
|
||||
def create
|
||||
year_ids = school_params[:year_ids]&.reject(&:blank?)
|
||||
@school = School.new(school_params.except(:year_ids))
|
||||
@school.year_ids = year_ids if year_ids.present?
|
||||
|
||||
if @school.save
|
||||
redirect_to admin_school_path(@school), notice: t(".notice")
|
||||
else
|
||||
set_form_data
|
||||
@breadcrumbs = [
|
||||
{ label: "Schools", path: admin_schools_path },
|
||||
{ label: "New School" }
|
||||
]
|
||||
render :new, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
year_ids = school_params[:year_ids]&.reject(&:blank?)
|
||||
update_params = school_params.except(:year_ids)
|
||||
update_params[:year_ids] = year_ids || []
|
||||
|
||||
if @school.update(update_params)
|
||||
redirect_to admin_school_path(@school), notice: t(".notice")
|
||||
else
|
||||
set_form_data
|
||||
@breadcrumbs = [
|
||||
{ label: "Schools", path: admin_schools_path },
|
||||
{ label: @school.name, path: admin_school_path(@school) },
|
||||
{ label: "Edit" }
|
||||
]
|
||||
render :edit, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@school.destroy
|
||||
redirect_to admin_schools_path, notice: t(".notice")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_school
|
||||
@school = School.find(params.expect(:id))
|
||||
end
|
||||
|
||||
def set_form_data
|
||||
@years = Year.ordered_by_start_year
|
||||
end
|
||||
|
||||
def school_params
|
||||
params.expect(school: [:name, { year_ids: [] }])
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,108 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Admin
|
||||
class StocksController < BaseController
|
||||
before_action :set_stock, only: %i[show edit update destroy]
|
||||
|
||||
def index
|
||||
@stocks = apply_sorting(Stock.all, default: "ticker")
|
||||
|
||||
@breadcrumbs = [
|
||||
{ label: "Stocks" }
|
||||
]
|
||||
end
|
||||
|
||||
def show
|
||||
@breadcrumbs = [
|
||||
{ label: "Stocks", path: admin_stocks_path },
|
||||
{ label: @stock.ticker }
|
||||
]
|
||||
end
|
||||
|
||||
def new
|
||||
@stock = Stock.new
|
||||
|
||||
@breadcrumbs = [
|
||||
{ label: "Stocks", path: admin_stocks_path },
|
||||
{ label: "New Stock" }
|
||||
]
|
||||
end
|
||||
|
||||
def edit
|
||||
@breadcrumbs = [
|
||||
{ label: "Stocks", path: admin_stocks_path },
|
||||
{ label: @stock.ticker, path: admin_stock_path(@stock) },
|
||||
{ label: "Edit" }
|
||||
]
|
||||
end
|
||||
|
||||
def create
|
||||
@stock = Stock.new(stock_params)
|
||||
|
||||
if @stock.save
|
||||
redirect_to admin_stock_path(@stock), notice: t(".notice")
|
||||
else
|
||||
@breadcrumbs = [
|
||||
{ label: "Stocks", path: admin_stocks_path },
|
||||
{ label: "New Stock" }
|
||||
]
|
||||
render :new, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
if @stock.update(stock_params)
|
||||
redirect_to admin_stock_path(@stock), notice: t(".notice")
|
||||
else
|
||||
@breadcrumbs = [
|
||||
{ label: "Stocks", path: admin_stocks_path },
|
||||
{ label: @stock.ticker, path: admin_stock_path(@stock) },
|
||||
{ label: "Edit" }
|
||||
]
|
||||
render :edit, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@stock.destroy
|
||||
redirect_to admin_stocks_path, notice: t(".notice")
|
||||
rescue ActiveRecord::DeleteRestrictionError => e
|
||||
redirect_to admin_stocks_path, alert: "Cannot delete stock: #{e.message}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_stock
|
||||
@stock = Stock.find(params.expect(:id))
|
||||
end
|
||||
|
||||
# rubocop:disable Metrics/MethodLength
|
||||
def stock_params
|
||||
params.expect(
|
||||
stock: %i[
|
||||
ticker
|
||||
company_name
|
||||
company_website
|
||||
stock_exchange
|
||||
description
|
||||
price_cents
|
||||
yesterday_price_cents
|
||||
employees
|
||||
industry
|
||||
cash_flow
|
||||
debt
|
||||
debt_to_equity
|
||||
profit_margin
|
||||
sales_growth
|
||||
industry_avg_debt_to_equity
|
||||
industry_avg_profit_margin
|
||||
industry_avg_sales_growth
|
||||
management
|
||||
competitor_names
|
||||
archived
|
||||
]
|
||||
)
|
||||
end
|
||||
# rubocop:enable Metrics/MethodLength
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,235 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Admin
|
||||
# rubocop:disable Metrics/ClassLength
|
||||
class StudentsController < BaseController
|
||||
include SoftDeletableFiltering
|
||||
|
||||
before_action :set_student, only: %i[show edit update destroy add_transaction]
|
||||
before_action :set_discarded_student, only: %i[restore]
|
||||
|
||||
def index
|
||||
@students = apply_sorting(scoped_by_discard_status(Student), default: "username")
|
||||
|
||||
@breadcrumbs = [
|
||||
{ label: "Students" }
|
||||
]
|
||||
end
|
||||
|
||||
def show
|
||||
@attendance_entries = AttendanceEntryPresenter.for_student(@student)
|
||||
@earnings_summary = EarningsSummary.new(@student.portfolio) if @student.portfolio.present?
|
||||
|
||||
@breadcrumbs = [
|
||||
{ label: "Students", path: admin_students_path },
|
||||
{ label: @student.username }
|
||||
]
|
||||
end
|
||||
|
||||
def new
|
||||
@student = Student.new
|
||||
|
||||
@breadcrumbs = [
|
||||
{ label: "Students", path: admin_students_path },
|
||||
{ label: "New Student" }
|
||||
]
|
||||
end
|
||||
|
||||
def edit
|
||||
@breadcrumbs = [
|
||||
{ label: "Students", path: admin_students_path },
|
||||
{ label: @student.username, path: admin_student_path(@student) },
|
||||
{ label: "Edit" }
|
||||
]
|
||||
end
|
||||
|
||||
def create
|
||||
@student = Student.new(student_params)
|
||||
|
||||
# Generate a memorable password if not provided
|
||||
if @student.password.blank?
|
||||
generated_password = MemorablePasswordGenerator.generate
|
||||
@student.password = generated_password
|
||||
@student.password_confirmation = generated_password
|
||||
else
|
||||
generated_password = @student.password
|
||||
end
|
||||
|
||||
if @student.save
|
||||
redirect_to admin_student_path(@student),
|
||||
notice: t(".notice", username: @student.username, password: generated_password)
|
||||
else
|
||||
@breadcrumbs = [
|
||||
{ label: "Students", path: admin_students_path },
|
||||
{ label: "New Student" }
|
||||
]
|
||||
render :new, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
if @student.update(student_params)
|
||||
redirect_to admin_student_path(@student), notice: t(".notice")
|
||||
else
|
||||
@breadcrumbs = [
|
||||
{ label: "Students", path: admin_students_path },
|
||||
{ label: @student.username, path: admin_student_path(@student) },
|
||||
{ label: "Edit" }
|
||||
]
|
||||
render :edit, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
username = @student.username
|
||||
@student.discard
|
||||
redirect_to admin_students_path, notice: t(".notice", username: username)
|
||||
end
|
||||
|
||||
def restore
|
||||
username = @student.username
|
||||
@student.undiscard
|
||||
redirect_to admin_students_path(discarded: true), notice: t(".notice", username: username)
|
||||
end
|
||||
|
||||
def add_transaction
|
||||
errors = validate_transaction_params
|
||||
|
||||
if errors.present?
|
||||
redirect_to edit_admin_student_path(@student), alert: errors.join(", ")
|
||||
else
|
||||
transaction = PortfolioTransaction.new(
|
||||
portfolio: @student.portfolio,
|
||||
amount_cents: transaction_amount_cents,
|
||||
transaction_type: transaction_type,
|
||||
reason: transaction_reason,
|
||||
description: transaction_description
|
||||
)
|
||||
|
||||
if transaction.save
|
||||
redirect_to admin_student_path(@student), notice: t(".notice")
|
||||
else
|
||||
redirect_to edit_admin_student_path(@student), alert: transaction.errors.full_messages.join(", ")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def import
|
||||
return redirect_with_missing_file_error if params[:csv_file].blank?
|
||||
|
||||
begin
|
||||
results = BulkStudentImportService.import_from_csv(params.expect(:csv_file).path)
|
||||
redirect_with_import_results(results)
|
||||
rescue CSV::MalformedCSVError => e
|
||||
redirect_to admin_students_path, alert: "Invalid CSV format: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
def template
|
||||
send_data BulkStudentImportService.generate_csv_template,
|
||||
filename: "student_import_template.csv",
|
||||
type: "text/csv",
|
||||
disposition: "attachment"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_discarded_student
|
||||
@student = Student.with_discarded.find(params.expect(:id))
|
||||
end
|
||||
|
||||
def set_student
|
||||
@student = Student.find(params.expect(:id))
|
||||
end
|
||||
|
||||
def student_params
|
||||
params.expect(student: %i[username classroom_id password password_confirmation])
|
||||
end
|
||||
|
||||
def transaction_params
|
||||
params.permit(:add_fund_amount, :transaction_type, :transaction_reason, :transaction_description)
|
||||
end
|
||||
|
||||
def transaction_amount_cents
|
||||
amount = transaction_params[:add_fund_amount]
|
||||
amount.present? ? (amount.to_f * 100).to_i : nil
|
||||
end
|
||||
|
||||
def transaction_type
|
||||
transaction_params[:transaction_type]
|
||||
end
|
||||
|
||||
def transaction_reason
|
||||
transaction_params[:transaction_reason]
|
||||
end
|
||||
|
||||
def transaction_description
|
||||
transaction_params[:transaction_description]
|
||||
end
|
||||
|
||||
def validate_transaction_params
|
||||
errors = []
|
||||
errors << t("admin.students.add_transaction.errors.transaction_type_blank") if transaction_type.blank?
|
||||
errors << t("admin.students.add_transaction.errors.amount_blank") if transaction_amount_cents.blank?
|
||||
errors << t("admin.students.add_transaction.errors.reason_blank") if transaction_reason.blank?
|
||||
errors
|
||||
end
|
||||
|
||||
def redirect_with_missing_file_error
|
||||
redirect_to admin_students_path, alert: t(".errors.no_file")
|
||||
end
|
||||
|
||||
def redirect_with_import_results(results)
|
||||
return redirect_with_no_results_error if results.empty?
|
||||
|
||||
created, skipped, failed = partition_results(results)
|
||||
success_messages = build_success_messages(created, skipped)
|
||||
|
||||
if failed.any?
|
||||
redirect_with_mixed_results(success_messages, failed)
|
||||
else
|
||||
redirect_to admin_students_path, notice: success_messages.join(". ")
|
||||
end
|
||||
end
|
||||
|
||||
def redirect_with_no_results_error
|
||||
redirect_to admin_students_path, alert: t(".errors.no_students")
|
||||
end
|
||||
|
||||
def partition_results(results)
|
||||
[
|
||||
results.select(&:created?),
|
||||
results.select(&:skipped?),
|
||||
results.select(&:failed?)
|
||||
]
|
||||
end
|
||||
|
||||
def build_success_messages(created, skipped)
|
||||
messages = []
|
||||
messages << build_created_message(created) if created.any?
|
||||
messages << build_skipped_message(skipped) if skipped.any?
|
||||
messages
|
||||
end
|
||||
|
||||
def build_created_message(created)
|
||||
usernames = created.map { |item| item.student.username }
|
||||
"Successfully created #{created.count} students: #{usernames.join(', ')}"
|
||||
end
|
||||
|
||||
def build_skipped_message(skipped)
|
||||
"Skipped #{skipped.count} existing usernames"
|
||||
end
|
||||
|
||||
def redirect_with_mixed_results(success_messages, failed)
|
||||
error_messages = failed.map { |item| "Row #{item.line_number}: #{item.error_message}" }
|
||||
alert_message = "#{failed.count} errors occurred: #{error_messages.join(', ')}"
|
||||
|
||||
if success_messages.any?
|
||||
redirect_to admin_students_path, notice: success_messages.join(". "), alert: alert_message
|
||||
else
|
||||
redirect_to admin_students_path, alert: alert_message
|
||||
end
|
||||
end
|
||||
end
|
||||
# rubocop:enable Metrics/ClassLength
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Admin
|
||||
module Teachers
|
||||
class DeactivationsController < BaseController
|
||||
before_action :set_teacher
|
||||
|
||||
def create
|
||||
username = @teacher.username
|
||||
@teacher.discard
|
||||
redirect_back_or_to admin_teachers_path, notice: t(".notice", username: username)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_teacher
|
||||
@teacher = Teacher.find(params.expect(:teacher_id))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Admin
|
||||
module Teachers
|
||||
class ReactivationsController < BaseController
|
||||
before_action :set_teacher
|
||||
|
||||
def create
|
||||
username = @teacher.username
|
||||
@teacher.undiscard
|
||||
redirect_back_or_to admin_teachers_path, notice: t(".notice", username: username)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_teacher
|
||||
@teacher = Teacher.with_discarded.find(params.expect(:teacher_id))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,115 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Admin
|
||||
class TeachersController < BaseController
|
||||
include SoftDeletableFiltering
|
||||
|
||||
before_action :set_teacher, only: %i[show edit update destroy]
|
||||
before_action :require_deactivated, only: %i[destroy]
|
||||
|
||||
def index
|
||||
@teachers = apply_sorting(scoped_by_discard_status(Teacher), default: "username")
|
||||
|
||||
@breadcrumbs = [
|
||||
{ label: "Teachers" }
|
||||
]
|
||||
end
|
||||
|
||||
def show
|
||||
@breadcrumbs = [
|
||||
{ label: "Teachers", path: admin_teachers_path },
|
||||
{ label: @teacher.username }
|
||||
]
|
||||
end
|
||||
|
||||
def new
|
||||
@teacher = Teacher.new
|
||||
set_form_data
|
||||
|
||||
@breadcrumbs = [
|
||||
{ label: "Teachers", path: admin_teachers_path },
|
||||
{ label: "New Teacher" }
|
||||
]
|
||||
end
|
||||
|
||||
def edit
|
||||
set_form_data
|
||||
@breadcrumbs = [
|
||||
{ label: "Teachers", path: admin_teachers_path },
|
||||
{ label: @teacher.username, path: admin_teacher_path(@teacher) },
|
||||
{ label: "Edit" }
|
||||
]
|
||||
end
|
||||
|
||||
def create
|
||||
temp_password = Devise.friendly_token.first(20)
|
||||
classroom_ids = teacher_params[:classroom_ids]&.reject(&:blank?)
|
||||
|
||||
@teacher = Teacher.new(teacher_params.except(:classroom_ids).merge(password: temp_password))
|
||||
@teacher.classroom_ids = classroom_ids if classroom_ids.present?
|
||||
|
||||
if @teacher.save
|
||||
@teacher.send_reset_password_instructions
|
||||
redirect_to admin_teacher_path(@teacher), notice: t(".notice")
|
||||
else
|
||||
set_form_data
|
||||
@breadcrumbs = [
|
||||
{ label: "Teachers", path: admin_teachers_path },
|
||||
{ label: "New Teacher" }
|
||||
]
|
||||
render :new, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
classroom_ids = teacher_params[:classroom_ids]&.reject(&:blank?)
|
||||
update_params = teacher_params.except(:classroom_ids)
|
||||
update_params[:classroom_ids] = classroom_ids if classroom_ids.present?
|
||||
|
||||
if @teacher.update(update_params)
|
||||
redirect_to admin_teacher_path(@teacher), notice: t(".notice")
|
||||
else
|
||||
set_form_data
|
||||
@breadcrumbs = [
|
||||
{ label: "Teachers", path: admin_teachers_path },
|
||||
{ label: @teacher.username, path: admin_teacher_path(@teacher) },
|
||||
{ label: "Edit" }
|
||||
]
|
||||
render :edit, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
username = @teacher.username
|
||||
@teacher.really_destroy!
|
||||
redirect_to admin_teachers_path, notice: t(".notice", username: username)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_teacher
|
||||
@teacher = Teacher.find(params.expect(:id))
|
||||
end
|
||||
|
||||
def require_deactivated
|
||||
return if @teacher.discarded?
|
||||
|
||||
redirect_to edit_admin_teacher_path(@teacher), alert: t("admin.teachers.destroy.must_be_deactivated")
|
||||
end
|
||||
|
||||
def teacher_params
|
||||
params.expect(teacher: [:email, :name, { classroom_ids: [] }])
|
||||
end
|
||||
|
||||
def set_form_data
|
||||
active_years = Year.current_school_year(Date.current)
|
||||
@schools = School.joins(:school_years).where(school_years: { year_id: active_years.ids }).distinct.order(:name)
|
||||
|
||||
@selected_school_id = params[:school_id] || @teacher.classrooms.first&.school&.id
|
||||
|
||||
@classrooms = Classroom.active.joins(:school_year).where(school_years: { year_id: active_years.ids })
|
||||
@classrooms = @classrooms.where(school_years: { school_id: @selected_school_id }) if @selected_school_id.present?
|
||||
@classrooms = @classrooms.order(:name)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,81 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Admin
|
||||
class UsersController < BaseController
|
||||
before_action :set_user, only: %i[show edit update destroy]
|
||||
|
||||
def index
|
||||
@users = apply_sorting(User.all, default: "username")
|
||||
|
||||
@breadcrumbs = [
|
||||
{ label: "Users" }
|
||||
]
|
||||
end
|
||||
|
||||
def show
|
||||
@breadcrumbs = [
|
||||
{ label: "Users", path: admin_users_path },
|
||||
{ label: @user.username }
|
||||
]
|
||||
end
|
||||
|
||||
def new
|
||||
@user = User.new
|
||||
|
||||
@breadcrumbs = [
|
||||
{ label: "Users", path: admin_users_path },
|
||||
{ label: "New User" }
|
||||
]
|
||||
end
|
||||
|
||||
def edit
|
||||
@breadcrumbs = [
|
||||
{ label: "Users", path: admin_users_path },
|
||||
{ label: @user.username, path: admin_user_path(@user) },
|
||||
{ label: "Edit" }
|
||||
]
|
||||
end
|
||||
|
||||
def create
|
||||
@user = User.new(user_params)
|
||||
|
||||
if @user.save
|
||||
redirect_to admin_user_path(@user), notice: t(".notice")
|
||||
else
|
||||
@breadcrumbs = [
|
||||
{ label: "Users", path: admin_users_path },
|
||||
{ label: "New User" }
|
||||
]
|
||||
render :new, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
if @user.update(user_params)
|
||||
redirect_to admin_user_path(@user), notice: t(".notice")
|
||||
else
|
||||
@breadcrumbs = [
|
||||
{ label: "Users", path: admin_users_path },
|
||||
{ label: @user.username, path: admin_user_path(@user) },
|
||||
{ label: "Edit" }
|
||||
]
|
||||
render :edit, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@user.destroy
|
||||
redirect_to admin_users_path, notice: t(".notice")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_user
|
||||
@user = User.find(params.expect(:id))
|
||||
end
|
||||
|
||||
def user_params
|
||||
params.expect(user: %i[username email type admin classroom_id password password_confirmation])
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AnnouncementsController < ApplicationController
|
||||
before_action :set_announcement, only: %i[show]
|
||||
|
||||
def show; end
|
||||
|
||||
private
|
||||
|
||||
def set_announcement
|
||||
@announcement = Announcement.find(params.expect(:id))
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
redirect_to root_path, alert: t("announcements.not_found")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,41 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ApplicationController < ActionController::Base
|
||||
include Pundit::Authorization
|
||||
|
||||
before_action :authenticate_user!
|
||||
before_action :configure_permitted_parameters, if: :devise_controller?
|
||||
before_action :set_navbar_stocks
|
||||
|
||||
protected
|
||||
|
||||
def ensure_teacher_or_admin
|
||||
authorize :application, :teacher_or_admin_required?
|
||||
end
|
||||
|
||||
def ensure_admin
|
||||
authorize :application, :admin_required?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_navbar_stocks
|
||||
@navbar_stocks = policy_scope(Stock).active
|
||||
end
|
||||
|
||||
def configure_permitted_parameters
|
||||
devise_parameter_sanitizer.permit(:sign_up, keys: %i[username type classroom_id])
|
||||
devise_parameter_sanitizer.permit(:account_update, keys: %i[username email])
|
||||
end
|
||||
|
||||
rescue_from Pundit::NotAuthorizedError do
|
||||
if current_user.nil?
|
||||
# go to the login page
|
||||
redirect_to new_user_session_path, alert: t("devise.failure.unauthenticated")
|
||||
elsif current_user.student?
|
||||
redirect_to current_user&.portfolio_path, alert: t("application.access_denied.no_access")
|
||||
else
|
||||
redirect_to root_url, alert: t("application.access_denied.no_access")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,59 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Manages student enrollments in classrooms
|
||||
class ClassroomEnrollmentsController < ApplicationController
|
||||
before_action :authenticate_user!
|
||||
before_action :ensure_teacher_or_admin
|
||||
before_action :set_classroom
|
||||
before_action :set_enrollment, only: %i[destroy unenroll]
|
||||
|
||||
# POST /classrooms/:classroom_id/classroom_enrollments
|
||||
def create
|
||||
student = Student.find(enrollment_params[:student_id])
|
||||
|
||||
enrollment = student.enroll_in!(
|
||||
@classroom,
|
||||
primary: enrollment_params[:primary] == "true"
|
||||
)
|
||||
|
||||
if enrollment.persisted?
|
||||
redirect_to classroom_path(@classroom),
|
||||
notice: "#{student.username} enrolled in #{@classroom.name}"
|
||||
else
|
||||
redirect_to classroom_path(@classroom),
|
||||
alert: "Failed to enroll student: #{enrollment.errors.full_messages.join(', ')}"
|
||||
end
|
||||
end
|
||||
|
||||
# DELETE /classrooms/:classroom_id/classroom_enrollments/:id
|
||||
def destroy
|
||||
student = @enrollment.student
|
||||
@enrollment.destroy
|
||||
|
||||
redirect_to classroom_path(@classroom),
|
||||
notice: "Removed #{student.username}'s enrollment from #{@classroom.name}"
|
||||
end
|
||||
|
||||
# PATCH /classrooms/:classroom_id/classroom_enrollments/:id/unenroll
|
||||
def unenroll
|
||||
student = @enrollment.student
|
||||
@enrollment.unenroll!
|
||||
|
||||
redirect_to classroom_path(@classroom),
|
||||
notice: "#{student.username} unenrolled from #{@classroom.name}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_classroom
|
||||
@classroom = Classroom.find(params.expect(:classroom_id))
|
||||
end
|
||||
|
||||
def set_enrollment
|
||||
@enrollment = @classroom.classroom_enrollments.find(params.expect(:id))
|
||||
end
|
||||
|
||||
def enrollment_params
|
||||
params.expect(classroom_enrollment: %i[student_id primary])
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,110 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ClassroomsController < ApplicationController
|
||||
before_action :set_classroom, only: %i[show edit update toggle_trading]
|
||||
before_action :authorize_classroom, except: %i[edit update toggle_trading]
|
||||
before_action :authorize_classroom_instance, only: %i[edit update toggle_trading]
|
||||
before_action :authenticate_user!
|
||||
before_action :ensure_teacher_or_admin, except: %i[index show]
|
||||
before_action :check_classroom_eligibility, only: :show
|
||||
|
||||
def index
|
||||
@classrooms = policy_scope(Classroom).includes(:teachers, students: :portfolio)
|
||||
@classrooms = Classroom.apply_sorting(@classrooms, params[:sort], params[:direction])
|
||||
end
|
||||
|
||||
def show
|
||||
facade = ClassroomFacade.new(@classroom)
|
||||
@students = facade.students
|
||||
@can_manage_students = current_user.teacher_or_admin?
|
||||
@classroom_stats = facade.stats if @can_manage_students
|
||||
end
|
||||
|
||||
def new
|
||||
@classroom = Classroom.new
|
||||
dropdown_data
|
||||
end
|
||||
|
||||
def edit
|
||||
dropdown_data
|
||||
end
|
||||
|
||||
def create
|
||||
@classroom = Classroom.new(classroom_params.except(:school_id, :year_id))
|
||||
assign_school_year_to_classroom
|
||||
|
||||
if @classroom.save
|
||||
redirect_to classroom_url(@classroom), notice: t(".notice")
|
||||
else
|
||||
dropdown_data
|
||||
render :new, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
assign_school_year_to_classroom
|
||||
|
||||
if @classroom.update(classroom_params.except(:school_id, :year_id))
|
||||
redirect_to classroom_url(@classroom), notice: t(".notice")
|
||||
else
|
||||
dropdown_data
|
||||
render :edit, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
def toggle_trading
|
||||
if @classroom.update(trading_enabled: !@classroom.trading_enabled)
|
||||
notice_key = @classroom.trading_enabled? ? :enabled : :disabled
|
||||
redirect_to classroom_url(@classroom), notice: t(".#{notice_key}")
|
||||
else
|
||||
redirect_to classroom_url(@classroom), alert: t(".alert")
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_classroom
|
||||
@classroom = Classroom.includes(users: :portfolio).find(params.expect(:id).to_i)
|
||||
end
|
||||
|
||||
def authorize_classroom
|
||||
authorize Classroom
|
||||
end
|
||||
|
||||
def authorize_classroom_instance
|
||||
authorize @classroom
|
||||
end
|
||||
|
||||
def classroom_params
|
||||
params.expect(classroom: [:name, :trading_enabled, :school_id, :year_id, { grade_ids: [] }, { teacher_ids: [] }])
|
||||
end
|
||||
|
||||
def dropdown_data
|
||||
@schools = School.order(:name)
|
||||
@years = Year.order(:name)
|
||||
@teachers = Teacher.all.sort_by(&:display_name)
|
||||
end
|
||||
|
||||
def assign_school_year_to_classroom
|
||||
return unless classroom_params[:school_id].present? && classroom_params[:year_id].present?
|
||||
|
||||
school = School.find(classroom_params[:school_id])
|
||||
year = Year.find(classroom_params[:year_id])
|
||||
school_year = SchoolYear.find_or_create_by!(school: school, year: year)
|
||||
@classroom.school_year = school_year
|
||||
end
|
||||
|
||||
def check_classroom_eligibility
|
||||
# Redirect if classroom is archived and user is not an admin
|
||||
if @classroom.archived? && !current_user.admin?
|
||||
redirect_to root_path, alert: t("classrooms.archived.alert")
|
||||
return
|
||||
end
|
||||
|
||||
return if current_user.admin? ||
|
||||
(current_user.teacher? &&
|
||||
@classroom.teachers.include?(current_user))
|
||||
|
||||
redirect_to root_path, alert: t("application.access_denied.no_access")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,26 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module SoftDeletableFiltering
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
# Scopes a resource class based on discard status query parameters
|
||||
#
|
||||
# @param resource_class [ActiveRecord::Base] The model class to scope
|
||||
# @return [ActiveRecord::Relation] Scoped collection based on params
|
||||
#
|
||||
# Query parameters:
|
||||
# - discarded (any value): Returns only discarded records
|
||||
# - all (any value): Returns all records (including discarded)
|
||||
# - (none): Returns only kept (non-discarded) records
|
||||
def scoped_by_discard_status(resource_class)
|
||||
if params[:discarded].present?
|
||||
resource_class.discarded
|
||||
elsif params[:all].present?
|
||||
resource_class.with_discarded
|
||||
else
|
||||
resource_class.kept
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,58 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# app/controllers/grade_books_controller.rb
|
||||
class GradeBooksController < ApplicationController
|
||||
before_action :set_classroom_and_grade_book
|
||||
before_action :authorize_grade_book
|
||||
def show; end
|
||||
|
||||
def update
|
||||
GradeEntry.transaction do
|
||||
grade_entry_params.each do |id, attrs|
|
||||
entry = @grade_book.grade_entries.find(id)
|
||||
entry.update!(attrs)
|
||||
end
|
||||
end
|
||||
|
||||
@grade_book.grade_entries.reload
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to classroom_grade_book_path(@classroom, @grade_book), notice: t(".notice") }
|
||||
format.turbo_stream
|
||||
end
|
||||
end
|
||||
|
||||
def finalize
|
||||
if @grade_book.completed?
|
||||
redirect_to classroom_grade_book_path(@classroom, @grade_book),
|
||||
alert: t(".already_completed")
|
||||
else
|
||||
@grade_book.verified!
|
||||
DistributeEarnings.execute(@grade_book)
|
||||
redirect_to classroom_grade_book_path(@classroom, @grade_book),
|
||||
notice: t(".notice")
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def authorize_grade_book
|
||||
authorize @grade_book
|
||||
end
|
||||
|
||||
def set_classroom_and_grade_book
|
||||
@classroom = Classroom.find(params.expect(:classroom_id))
|
||||
@grade_book = @classroom.grade_books.includes(grade_entries: :user).find(params.expect(:id))
|
||||
|
||||
# Redirect if classroom is archived and user is not an admin
|
||||
return unless @classroom.archived? && !current_user.admin?
|
||||
|
||||
redirect_to root_path, alert: t("classrooms.archived.alert")
|
||||
end
|
||||
|
||||
def grade_entry_params
|
||||
params.require(:grade_entries).transform_values do |entry|
|
||||
entry.permit(:math_grade, :reading_grade, :attendance_days, :is_perfect_attendance)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class HomeController < ApplicationController
|
||||
def index
|
||||
@current_announcement = Announcement.current
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,113 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class OrdersController < ApplicationController
|
||||
before_action :set_order, only: %i[edit update cancel]
|
||||
before_action :set_stock, only: %i[new]
|
||||
before_action :set_shared_owned, only: %i[new edit]
|
||||
before_action :authenticate_user!
|
||||
|
||||
def index
|
||||
@orders = policy_scope(Order)
|
||||
@orders = Order.apply_sorting(@orders, params[:sort], params[:direction])
|
||||
end
|
||||
|
||||
def new
|
||||
@order = Order.new(action: params[:transaction_type], stock: @stock)
|
||||
end
|
||||
|
||||
def edit; end
|
||||
|
||||
def create
|
||||
@order = Order.new(order_params.merge(user: current_user))
|
||||
|
||||
respond_to do |format|
|
||||
if @order.save
|
||||
format.html { redirect_to orders_url, notice: t(".notice") }
|
||||
format.turbo_stream { redirect_to orders_url, notice: t(".notice") }
|
||||
format.json { render :show, status: :created, location: @order }
|
||||
else
|
||||
setup_error_data
|
||||
format.html { render :new, status: :unprocessable_content }
|
||||
format.turbo_stream { render :new, status: :unprocessable_content }
|
||||
format.json { render json: @order.errors, status: :unprocessable_content }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
respond_to do |format|
|
||||
if @order.update(order_params)
|
||||
format.html { redirect_to orders_url, notice: t(".notice") }
|
||||
format.json { render :show, status: :ok, location: @order }
|
||||
else
|
||||
format.html { render :edit, status: :unprocessable_content }
|
||||
format.json { render json: @order.errors, status: :unprocessable_content }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def cancel
|
||||
authorize @order
|
||||
|
||||
if @order.pending?
|
||||
cancel_order
|
||||
else
|
||||
invalid_order_response
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Strong parameters
|
||||
def order_params
|
||||
params.expect(order: %i[stock_id shares action])
|
||||
end
|
||||
|
||||
def set_order
|
||||
@order = Order.find(params.expect(:id))
|
||||
end
|
||||
|
||||
def set_stock
|
||||
@stock = Stock.find(params.expect(:stock_id))
|
||||
end
|
||||
|
||||
def set_shared_owned
|
||||
@shares_owned = current_user.portfolio&.shares_owned(@stock&.id)
|
||||
end
|
||||
|
||||
def setup_error_data
|
||||
@stock = @order.stock
|
||||
@shares_owned = current_user.portfolio&.shares_owned(@stock&.id)
|
||||
end
|
||||
|
||||
def unauthorized_response
|
||||
respond_to do |format|
|
||||
format.html { redirect_to orders_url, alert: t(".unauthorized") }
|
||||
format.json { render json: { error: t(".unauthorized") }, status: :forbidden }
|
||||
end
|
||||
end
|
||||
|
||||
def cancel_order
|
||||
@order.cancel!
|
||||
respond_to do |format|
|
||||
format.html { redirect_to orders_url, notice: t(".success") }
|
||||
format.json { head :no_content }
|
||||
end
|
||||
end
|
||||
|
||||
def invalid_order_response
|
||||
respond_to do |format|
|
||||
format.html { redirect_to orders_url, alert: t(".pending_only") }
|
||||
format.json { render json: { error: t(".pending_only") }, status: :unprocessable_content }
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@order.destroy!
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to orders_url, notice: t(".notice") }
|
||||
format.json { head :no_content }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,18 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class PortfoliosController < ApplicationController
|
||||
before_action :set_portfolio
|
||||
before_action :authenticate_user!
|
||||
|
||||
def show
|
||||
authorize @portfolio
|
||||
@stocks = @portfolio.stocks
|
||||
@earnings_summary = EarningsSummary.new(@portfolio)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_portfolio
|
||||
@portfolio = Portfolio.find(params.expect(:id))
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,72 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SchoolsController < ApplicationController
|
||||
before_action :set_school, only: %i[show edit update destroy]
|
||||
before_action :authenticate_user!
|
||||
|
||||
# GET /schools or /schools.json
|
||||
def index
|
||||
@schools = School.all
|
||||
end
|
||||
|
||||
# GET /schools/1 or /schools/1.json
|
||||
def show; end
|
||||
|
||||
# GET /schools/new
|
||||
def new
|
||||
@school = School.new
|
||||
end
|
||||
|
||||
# GET /schools/1/edit
|
||||
def edit; end
|
||||
|
||||
# POST /schools or /schools.json
|
||||
def create
|
||||
@school = School.new(school_params)
|
||||
|
||||
respond_to do |format|
|
||||
if @school.save
|
||||
format.html { redirect_to school_url(@school), notice: t(".notice") }
|
||||
format.json { render :show, status: :created, location: @school }
|
||||
else
|
||||
format.html { render :new, status: :unprocessable_content }
|
||||
format.json { render json: @school.errors, status: :unprocessable_content }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# PATCH/PUT /schools/1 or /schools/1.json
|
||||
def update
|
||||
respond_to do |format|
|
||||
if @school.update(school_params)
|
||||
format.html { redirect_to school_url(@school), notice: t(".notice") }
|
||||
format.json { render :show, status: :ok, location: @school }
|
||||
else
|
||||
format.html { render :edit, status: :unprocessable_content }
|
||||
format.json { render json: @school.errors, status: :unprocessable_content }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# DELETE /schools/1 or /schools/1.json
|
||||
def destroy
|
||||
@school.destroy!
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to schools_url, notice: t(".notice") }
|
||||
format.json { head :no_content }
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Use callbacks to share common setup or constraints between actions.
|
||||
def set_school
|
||||
@school = School.find(params.expect(:id))
|
||||
end
|
||||
|
||||
# Only allow a list of trusted parameters through.
|
||||
def school_params
|
||||
params.expect(school: [:name])
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,26 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class StocksController < ApplicationController
|
||||
before_action :set_stock, only: %i[show]
|
||||
before_action :authenticate_user!
|
||||
before_action :set_portfolio
|
||||
|
||||
def index
|
||||
@stocks = policy_scope(Stock).includes(portfolio_stocks: :portfolio)
|
||||
@portfolio = current_user.portfolio if current_user.student?
|
||||
end
|
||||
|
||||
def show
|
||||
authorize @stock
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_portfolio
|
||||
@portfolio = current_user.portfolio
|
||||
end
|
||||
|
||||
def set_stock
|
||||
@stock = Stock.find(params.expect(:id))
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,80 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class StudentsController < ApplicationController
|
||||
before_action :authenticate_user!
|
||||
before_action :ensure_teacher_or_admin
|
||||
before_action :set_classroom
|
||||
before_action :set_student, except: %i[new create]
|
||||
|
||||
def new
|
||||
@student = Student.new(classroom: @classroom)
|
||||
end
|
||||
|
||||
def edit; end
|
||||
|
||||
def create
|
||||
@student = Student.new(student_params)
|
||||
@student.classroom = @classroom
|
||||
@student.password = generate_memorable_password
|
||||
|
||||
if @student.save
|
||||
redirect_to classroom_path(@classroom),
|
||||
notice: t(".notice", username: @student.username, password: @student.password)
|
||||
else
|
||||
render :new, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
if @student.update(student_params)
|
||||
redirect_to classroom_path(@classroom), notice: t(".notice")
|
||||
else
|
||||
render :edit, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
username = @student.username
|
||||
@student.discard
|
||||
redirect_to classroom_path(@classroom), notice: t(".notice", username: username)
|
||||
end
|
||||
|
||||
def reset_password
|
||||
new_password = generate_memorable_password
|
||||
@student.update!(password: new_password)
|
||||
redirect_to classroom_path(@classroom),
|
||||
notice: t(".notice", username: @student.username, password: new_password)
|
||||
end
|
||||
|
||||
def generate_password
|
||||
new_password = generate_memorable_password
|
||||
@student.update!(password: new_password)
|
||||
redirect_to classroom_path(@classroom),
|
||||
notice: t(".notice", username: @student.username, password: new_password)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_classroom
|
||||
@classroom = Classroom.find(params.expect(:classroom_id))
|
||||
|
||||
# Redirect if classroom is archived and user is not an admin
|
||||
return unless @classroom.archived? && !current_user.admin?
|
||||
|
||||
redirect_to root_path, alert: t("classrooms.archived.alert")
|
||||
end
|
||||
|
||||
def set_student
|
||||
@student = @classroom.users.students.kept.find(params.expect(:id))
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
redirect_to classroom_path(@classroom), alert: t("students.not_found")
|
||||
end
|
||||
|
||||
def student_params
|
||||
params.expect(student: %i[username email])
|
||||
end
|
||||
|
||||
def generate_memorable_password
|
||||
MemorablePasswordGenerator.generate
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,42 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Facade for classroom presentation logic
|
||||
class ClassroomFacade
|
||||
attr_reader :classroom
|
||||
|
||||
def initialize(classroom)
|
||||
@classroom = classroom
|
||||
end
|
||||
|
||||
# Get all students for this classroom (both via enrollments and legacy classroom_id)
|
||||
#
|
||||
# @return [ActiveRecord::Relation<Student>]
|
||||
def students
|
||||
enrolled_student_ids = classroom.current_students.pluck(:id)
|
||||
legacy_student_ids = classroom.users.students.kept.pluck(:id)
|
||||
all_student_ids = (enrolled_student_ids + legacy_student_ids).uniq
|
||||
|
||||
Student.kept.where(id: all_student_ids).includes(
|
||||
:portfolio,
|
||||
:orders,
|
||||
portfolio: :portfolio_transactions
|
||||
)
|
||||
end
|
||||
|
||||
def stats
|
||||
return {} unless classroom
|
||||
|
||||
students = classroom.users.students.kept
|
||||
{
|
||||
total_students: students.count,
|
||||
active_students: students.joins(:orders).distinct.count,
|
||||
total_portfolio_value: students.includes(:portfolio).sum do |student|
|
||||
student.portfolio&.calculate_total_value || 0
|
||||
end,
|
||||
recent_orders_count: Order.joins(:user).where(users: { classroom: classroom }).where(
|
||||
"orders.created_at > ?",
|
||||
1.week.ago
|
||||
).count
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,409 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Admin
|
||||
# rubocop:disable Metrics/ClassLength
|
||||
class FormBuilder < ActionView::Helpers::FormBuilder
|
||||
# Tailwind CSS classes for form elements
|
||||
INPUT_CLASSES = "block w-full rounded-md border-0 py-2 px-3 text-gray-900 ring-1 ring-inset ring-gray-300 " \
|
||||
"placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 " \
|
||||
"sm:text-sm sm:leading-6"
|
||||
|
||||
INPUT_ERROR_CLASSES = "block w-full rounded-md border-0 py-2 px-3 text-red-900 ring-1 ring-inset " \
|
||||
"ring-red-300 placeholder:text-red-300 focus:ring-2 focus:ring-inset " \
|
||||
"focus:ring-red-500 sm:text-sm sm:leading-6"
|
||||
|
||||
LABEL_CLASSES = "block text-sm font-medium leading-6 text-gray-900"
|
||||
|
||||
ERROR_CLASSES = "mt-2 text-sm text-red-600"
|
||||
|
||||
HINT_CLASSES = "mt-1 text-sm text-gray-500"
|
||||
|
||||
# Override text_field to include Tailwind styling and error handling
|
||||
def text_field(attribute, options = {})
|
||||
field_wrapper(attribute, options) do
|
||||
super(attribute, input_options(attribute, options))
|
||||
end
|
||||
end
|
||||
|
||||
# Email field with proper type and styling
|
||||
def email_field(attribute, options = {})
|
||||
field_wrapper(attribute, options) do
|
||||
super(attribute, input_options(attribute, options))
|
||||
end
|
||||
end
|
||||
|
||||
# Password field
|
||||
def password_field(attribute, options = {})
|
||||
field_wrapper(attribute, options) do
|
||||
super(attribute, input_options(attribute, options))
|
||||
end
|
||||
end
|
||||
|
||||
# Number field with optional step
|
||||
def number_field(attribute, options = {})
|
||||
field_wrapper(attribute, options) do
|
||||
options[:step] ||= "any" unless options.key?(:step)
|
||||
super(attribute, input_options(attribute, options))
|
||||
end
|
||||
end
|
||||
|
||||
# Currency field with proper formatting
|
||||
# Usage: f.currency_field :price_cents, multiplier: 0.01, decimals: 2
|
||||
def currency_field(attribute, options = {})
|
||||
multiplier = options.delete(:multiplier) || 0.01
|
||||
decimals = options.delete(:decimals) || 2
|
||||
|
||||
field_wrapper(attribute, options) do
|
||||
value = object.public_send(attribute)
|
||||
display_value = value ? (value * multiplier).round(decimals) : nil
|
||||
|
||||
number_field(
|
||||
attribute,
|
||||
options.merge(
|
||||
value: display_value,
|
||||
step: (1.0 / (10**decimals)),
|
||||
class: input_class(attribute),
|
||||
data: { currency_multiplier: (1.0 / multiplier).to_i }
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# Text area with proper styling
|
||||
def text_area(attribute, options = {})
|
||||
field_wrapper(attribute, options) do
|
||||
options[:rows] ||= 4
|
||||
options[:class] = "#{input_class(attribute)} #{options[:class] || ''}"
|
||||
super(attribute, options)
|
||||
end
|
||||
end
|
||||
|
||||
# Rich text area for ActionText fields
|
||||
def rich_text_area(attribute, options = {})
|
||||
field_wrapper(attribute, options) do
|
||||
options[:class] = "trix-content #{options[:class] || ''}"
|
||||
super(attribute, options)
|
||||
end
|
||||
end
|
||||
|
||||
# Override select to support label and hint options
|
||||
def select(attribute, choices = nil, options = {}, html_options = {}, &)
|
||||
# Extract custom options if provided as hash (when called with label/hint)
|
||||
return super if choices.is_a?(Hash) && options.empty? && html_options.empty?
|
||||
return super unless options.is_a?(Hash) && (options.key?(:label) || options.key?(:hint))
|
||||
|
||||
# Called as: select(:attr, choices, label: "...", hint: "...", include_blank: ...)
|
||||
render_select_with_wrapper(attribute, choices, options, html_options, &)
|
||||
end
|
||||
|
||||
# Select field with collection
|
||||
# Usage: f.select_field :status, collection: [['Active', 'active'], ['Inactive', 'inactive']]
|
||||
# Usage: f.select_field :grade, collection: (1..12).map { |g| [g.ordinalize, g] }
|
||||
def select_field(attribute, options = {})
|
||||
collection = options.delete(:collection) || []
|
||||
include_blank = options.delete(:include_blank)
|
||||
|
||||
field_wrapper(attribute, options) do
|
||||
select(
|
||||
attribute, collection,
|
||||
{ include_blank: include_blank },
|
||||
input_options(attribute, options)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# Boolean field (checkbox) with proper styling
|
||||
def boolean_field(attribute, options = {})
|
||||
hint = options.delete(:hint)
|
||||
label_text = options.delete(:label) || attribute.to_s.humanize
|
||||
|
||||
@template.content_tag(:div, class: "relative flex items-start py-4") do
|
||||
@template.content_tag(:div, class: "flex h-6 items-center") do
|
||||
check_box(
|
||||
attribute,
|
||||
class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-600"
|
||||
)
|
||||
end +
|
||||
@template.content_tag(:div, class: "ml-3 text-sm leading-6") do
|
||||
label(attribute, label_text, class: "font-medium text-gray-900") +
|
||||
(hint ? @template.content_tag(:p, hint, class: "text-gray-500") : "".html_safe)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Collection checkboxes with proper styling
|
||||
def collection_check_boxes(attribute, collection, value_method, text_method, options = {})
|
||||
label_text = options.delete(:label) || attribute.to_s.humanize
|
||||
hint = options.delete(:hint)
|
||||
wrapper_class = options.delete(:wrapper_class) || ""
|
||||
|
||||
@template.content_tag(:div, class: "mb-6 #{wrapper_class}") do
|
||||
build_checkbox_collection_label(label_text, hint) +
|
||||
build_checkbox_collection_items(attribute, collection, value_method, text_method) +
|
||||
error_message(attribute)
|
||||
end
|
||||
end
|
||||
|
||||
# Date field
|
||||
def date_field(attribute, options = {})
|
||||
field_wrapper(attribute, options) do
|
||||
super(attribute, input_options(attribute, options))
|
||||
end
|
||||
end
|
||||
|
||||
# DateTime field
|
||||
def datetime_field(attribute, options = {})
|
||||
field_wrapper(attribute, options) do
|
||||
options[:class] = "#{input_class(attribute)} #{options[:class] || ''}"
|
||||
super(attribute, options)
|
||||
end
|
||||
end
|
||||
|
||||
# Association select field (for BelongsTo associations)
|
||||
# Usage: f.association_select :classroom_id, collection: Classroom.all
|
||||
# Usage: f.association_select :school_year_id, collection: SchoolYear.all, label_method: :display_name
|
||||
def association_select(attribute, options = {})
|
||||
collection = options.delete(:collection)
|
||||
label_method = options.delete(:label_method) || :to_s
|
||||
value_method = options.delete(:value_method) || :id
|
||||
include_blank = options.delete(:include_blank) || "Select..."
|
||||
|
||||
raise ArgumentError, "collection is required for association_select" unless collection
|
||||
|
||||
choices = collection.map do |item|
|
||||
build_select_choice(item, label_method, value_method)
|
||||
end
|
||||
|
||||
field_wrapper(attribute, options) do
|
||||
select(
|
||||
attribute, choices,
|
||||
{ include_blank: include_blank },
|
||||
input_options(attribute, options)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# Read-only field (displays value, not an input)
|
||||
# Usage: f.read_only_field :created_at
|
||||
# Usage: f.read_only_field :total_amount, value: "$#{object.total_amount}"
|
||||
def read_only_field(attribute, options = {})
|
||||
label_text = options.delete(:label) || attribute.to_s.humanize
|
||||
value = options.delete(:value) || format_value(object.public_send(attribute))
|
||||
hint = options.delete(:hint)
|
||||
|
||||
@template.content_tag(:div, class: "py-4") do
|
||||
@template.content_tag(:dt, label_text, class: "text-sm font-medium text-gray-500") +
|
||||
@template.content_tag(:dd, class: "mt-1 text-sm text-gray-900") do
|
||||
value.to_s.html_safe # rubocop:disable Rails/OutputSafety
|
||||
end +
|
||||
(hint ? @template.content_tag(:p, hint, class: HINT_CLASSES) : "".html_safe)
|
||||
end
|
||||
end
|
||||
|
||||
# Submit button with Tailwind styling
|
||||
def submit_button(text = "Save", options = {})
|
||||
options[:class] = "inline-flex justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold " \
|
||||
"text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 " \
|
||||
"focus-visible:outline-offset-2 focus-visible:outline-blue-600 " \
|
||||
"#{options[:class]}"
|
||||
|
||||
submit(text, options)
|
||||
end
|
||||
|
||||
# Cancel button (link styled as button)
|
||||
def cancel_button(text = "Cancel", url:, options: {})
|
||||
options[:class] = "inline-flex justify-center rounded-md bg-white px-4 py-2 text-sm font-semibold " \
|
||||
"text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 " \
|
||||
"#{options[:class]}"
|
||||
|
||||
@template.link_to(text, url, options)
|
||||
end
|
||||
|
||||
# Displays base errors (errors not tied to a specific attribute)
|
||||
def base_errors
|
||||
return "".html_safe unless object&.errors && object.errors[:base].any?
|
||||
|
||||
@template.content_tag(:div, class: "mb-6 p-4 bg-red-50 border border-red-200 rounded-lg") do
|
||||
@template.content_tag(:p, class: "text-sm text-red-600") do
|
||||
@template.content_tag(:i, "", class: "fas fa-exclamation-circle mr-1") +
|
||||
object.errors[:base].join(", ")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_select_choice(item, label_method, value_method)
|
||||
label = if label_method.respond_to?(:call)
|
||||
label_method.call(item)
|
||||
else
|
||||
item.public_send(label_method)
|
||||
end
|
||||
|
||||
value = if value_method.respond_to?(:call)
|
||||
value_method.call(item)
|
||||
else
|
||||
item.public_send(value_method)
|
||||
end
|
||||
|
||||
[label, value]
|
||||
end
|
||||
|
||||
# Build checkbox collection label and hint
|
||||
def build_checkbox_collection_label(label_text, hint)
|
||||
@template.content_tag(:label, label_text, class: LABEL_CLASSES) +
|
||||
(hint ? @template.content_tag(:p, hint, class: HINT_CLASSES) : "".html_safe)
|
||||
end
|
||||
|
||||
# Build checkbox collection items
|
||||
def build_checkbox_collection_items(attribute, collection, value_method, text_method)
|
||||
@template.content_tag(:div, class: "mt-2 space-y-2") do
|
||||
collection.map do |item|
|
||||
build_single_checkbox(attribute, item, value_method, text_method)
|
||||
end.join.html_safe # rubocop:disable Rails/OutputSafety
|
||||
end
|
||||
end
|
||||
|
||||
# Build a single checkbox item
|
||||
def build_single_checkbox(attribute, item, value_method, text_method)
|
||||
value = item.send(value_method)
|
||||
text = item.send(text_method)
|
||||
checkbox_id = "#{object_name}_#{attribute}_#{value}"
|
||||
checked = Array(object.send(attribute)).include?(value)
|
||||
|
||||
@template.content_tag(:div, class: "relative flex items-start") do
|
||||
build_checkbox_input(attribute, value, checked, checkbox_id) +
|
||||
build_checkbox_label(checkbox_id, text)
|
||||
end
|
||||
end
|
||||
|
||||
# Build checkbox input element
|
||||
def build_checkbox_input(attribute, value, checked, checkbox_id)
|
||||
@template.content_tag(:div, class: "flex h-6 items-center") do
|
||||
@template.check_box_tag(
|
||||
"#{object_name}[#{attribute}][]",
|
||||
value,
|
||||
checked,
|
||||
id: checkbox_id,
|
||||
class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-600"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# Build checkbox label element
|
||||
def build_checkbox_label(checkbox_id, text)
|
||||
@template.content_tag(:div, class: "ml-3 text-sm leading-6") do
|
||||
@template.label_tag(checkbox_id, text, class: "font-medium text-gray-900")
|
||||
end
|
||||
end
|
||||
|
||||
# Renders select field with label and hint wrapper
|
||||
def render_select_with_wrapper(attribute, choices, options, html_options, &)
|
||||
label_text = options.delete(:label)
|
||||
hint = options.delete(:hint)
|
||||
wrapper_class = options.delete(:wrapper_class)
|
||||
|
||||
# Separate standard select options from html options
|
||||
select_options = extract_select_options(options)
|
||||
remaining_html_options = html_options.merge(options)
|
||||
|
||||
@template.content_tag(:div, class: "mb-6 #{wrapper_class}") do
|
||||
build_label(attribute, label_text) +
|
||||
build_hint(hint) +
|
||||
build_select_field(attribute, choices, select_options, remaining_html_options, &) +
|
||||
error_message(attribute)
|
||||
end
|
||||
end
|
||||
|
||||
# Build label element
|
||||
def build_label(attribute, label_text)
|
||||
return "".html_safe unless label_text
|
||||
|
||||
label(attribute, label_text, class: LABEL_CLASSES)
|
||||
end
|
||||
|
||||
# Build hint element
|
||||
def build_hint(hint)
|
||||
return "".html_safe unless hint
|
||||
|
||||
@template.content_tag(:p, hint, class: HINT_CLASSES)
|
||||
end
|
||||
|
||||
# Build select field element
|
||||
def build_select_field(attribute, choices, select_options, html_options, &)
|
||||
@template.content_tag(:div, class: "mt-2") do
|
||||
ActionView::Helpers::FormBuilder.instance_method(:select).bind(self).call(
|
||||
attribute, choices, select_options, html_options.merge(class: input_class(attribute)), &
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# Extract standard select options from the options hash
|
||||
def extract_select_options(options)
|
||||
select_options = {}
|
||||
select_options[:include_blank] = options.delete(:include_blank) if options.key?(:include_blank)
|
||||
select_options[:prompt] = options.delete(:prompt) if options.key?(:prompt)
|
||||
select_options[:disabled] = options.delete(:disabled) if options.key?(:disabled)
|
||||
select_options[:selected] = options.delete(:selected) if options.key?(:selected)
|
||||
select_options
|
||||
end
|
||||
|
||||
# Wraps a field with label, input, and error message
|
||||
def field_wrapper(attribute, options = {}, &)
|
||||
label_text = options.delete(:label) || attribute.to_s.humanize
|
||||
hint = options.delete(:hint)
|
||||
wrapper_class = options.delete(:wrapper_class) || ""
|
||||
|
||||
@template.content_tag(:div, class: "mb-6 #{wrapper_class}") do
|
||||
label(attribute, label_text, class: LABEL_CLASSES) +
|
||||
(hint ? @template.content_tag(:p, hint, class: HINT_CLASSES) : "".html_safe) +
|
||||
@template.content_tag(:div, class: "mt-2", &) +
|
||||
error_message(attribute)
|
||||
end
|
||||
end
|
||||
|
||||
# Returns the appropriate CSS class for an input based on validation state
|
||||
def input_class(attribute)
|
||||
if object&.errors && object.errors[attribute].any?
|
||||
INPUT_ERROR_CLASSES
|
||||
else
|
||||
INPUT_CLASSES
|
||||
end
|
||||
end
|
||||
|
||||
# Merges input options with default classes
|
||||
def input_options(attribute, options = {})
|
||||
options[:class] = "#{input_class(attribute)} #{options[:class] || ''}"
|
||||
options
|
||||
end
|
||||
|
||||
# Displays validation error for an attribute
|
||||
def error_message(attribute)
|
||||
return "".html_safe unless object&.errors && object.errors[attribute].any?
|
||||
|
||||
@template.content_tag(:p, class: ERROR_CLASSES, id: "#{attribute}-error") do
|
||||
@template.content_tag(:i, "", class: "fas fa-exclamation-circle mr-1") +
|
||||
object.errors[attribute].first
|
||||
end
|
||||
end
|
||||
|
||||
# Formats a value for display in read-only fields
|
||||
def format_value(value)
|
||||
case value
|
||||
when TrueClass
|
||||
'<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ' \
|
||||
'bg-green-100 text-green-800">Yes</span>'
|
||||
when FalseClass
|
||||
'<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ' \
|
||||
'bg-gray-100 text-gray-800">No</span>'
|
||||
when Time, DateTime, Date
|
||||
value.strftime("%B %d, %Y")
|
||||
when nil
|
||||
'<span class="text-gray-400">—</span>'
|
||||
else
|
||||
value.to_s
|
||||
end
|
||||
end
|
||||
end
|
||||
# rubocop:enable Metrics/ClassLength
|
||||
end
|
||||
@@ -0,0 +1,43 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Admin
|
||||
module FormHelper
|
||||
# Creates a form using the Admin::FormBuilder
|
||||
# Usage: admin_form_for @resource do |f|
|
||||
# f.text_field :name
|
||||
# f.email_field :email
|
||||
# f.submit_button
|
||||
# end
|
||||
def admin_form_for(record, options = {}, &)
|
||||
options[:builder] = Admin::FormBuilder
|
||||
options[:html] ||= {}
|
||||
options[:html][:class] = "space-y-6 #{options[:html][:class]}"
|
||||
|
||||
form_with(model: record, scope: model_scope(record), url: form_url(record), **options, &)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Determines the model scope for the form
|
||||
def model_scope(record)
|
||||
return record.first.model_name.param_key if record.is_a?(Array)
|
||||
|
||||
record.model_name.param_key
|
||||
end
|
||||
|
||||
# Determines the form URL based on the record (handles both new and existing records)
|
||||
def form_url(record)
|
||||
if record.is_a?(Array)
|
||||
resource = record.last
|
||||
if resource.persisted?
|
||||
[:admin, resource]
|
||||
else
|
||||
record.first.is_a?(Symbol) ? [record.first, :admin, resource] : [:admin, resource]
|
||||
end
|
||||
else
|
||||
resource = record
|
||||
resource.persisted? ? [:admin, resource] : [:admin, resource.class.model_name.collection]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,250 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# rubocop:disable Metrics/ModuleLength
|
||||
module AdminHelper
|
||||
# Renders a table for index pages with sortable columns
|
||||
# @param collection [ActiveRecord::Relation] The records to display
|
||||
# @param columns [Array<Hash>] Column definitions with :attribute, :label, :sortable keys
|
||||
# @param options [Hash] Additional options for the table
|
||||
def admin_table(collection, columns: [], **options)
|
||||
title = options.delete(:title)
|
||||
actions = options.delete(:actions)
|
||||
render "admin/shared/table",
|
||||
collection: collection,
|
||||
columns: columns,
|
||||
options: options,
|
||||
title: title,
|
||||
actions: actions
|
||||
end
|
||||
|
||||
# Renders attribute rows for show pages
|
||||
# @param resource [ActiveRecord::Base] The record to display
|
||||
# @param attributes [Array<Symbol>] Attributes to display
|
||||
def admin_show_attributes(resource, attributes: [])
|
||||
render "admin/shared/show_attributes", resource: resource, attributes: attributes
|
||||
end
|
||||
|
||||
# Renders breadcrumbs for navigation
|
||||
# @param breadcrumbs [Array<Hash>] Breadcrumb items with :label and :path keys
|
||||
def admin_breadcrumbs(breadcrumbs = [])
|
||||
render "admin/shared/breadcrumbs", breadcrumbs: breadcrumbs
|
||||
end
|
||||
|
||||
# Renders action buttons (Edit, Delete, Custom)
|
||||
# @param resource [ActiveRecord::Base] The record for actions
|
||||
# @param actions [Array<Symbol>] Actions to include (:edit, :delete, :custom)
|
||||
# @param custom_actions [Array<Hash>] Custom action definitions
|
||||
def admin_actions(resource, actions: %i[edit delete], custom_actions: [])
|
||||
render "admin/shared/actions", resource: resource, actions: actions, custom_actions: custom_actions
|
||||
end
|
||||
|
||||
# Formats an attribute value for display
|
||||
# @param resource [ActiveRecord::Base] The record
|
||||
# @param attribute [Symbol] The attribute name
|
||||
# @return [String] Formatted value
|
||||
def format_attribute(resource, attribute)
|
||||
value = resource.send(attribute)
|
||||
|
||||
case value
|
||||
when TrueClass, FalseClass
|
||||
boolean_badge(value)
|
||||
when Time, DateTime, Date
|
||||
value.strftime("%B %d, %Y")
|
||||
when ActiveRecord::Base
|
||||
format_association(value)
|
||||
when nil
|
||||
content_tag(:span, "—", class: "text-gray-400")
|
||||
else
|
||||
value.to_s
|
||||
end
|
||||
end
|
||||
|
||||
# Renders a boolean badge
|
||||
# @param value [Boolean] The boolean value
|
||||
# @return [String] HTML badge
|
||||
def boolean_badge(value)
|
||||
if value
|
||||
content_tag(
|
||||
:span, "Yes",
|
||||
class: "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"
|
||||
)
|
||||
else
|
||||
content_tag(
|
||||
:span, "No",
|
||||
class: "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# Generates a sort link for table headers
|
||||
# @param column [Symbol] The column name
|
||||
# @param label [String] The display label
|
||||
# @return [String] HTML link
|
||||
def sort_link(column, label)
|
||||
direction = params[:sort] == column.to_s && params[:direction] == "asc" ? "desc" : "asc"
|
||||
icon = sort_icon(column)
|
||||
|
||||
# Build URL with query parameters
|
||||
url = url_for(sort: column, direction: direction, only_path: true)
|
||||
|
||||
link_to url, class: "group inline-flex items-center" do
|
||||
safe_join(
|
||||
[
|
||||
label,
|
||||
content_tag(
|
||||
:span, icon,
|
||||
class: "ml-2 flex-none rounded text-gray-900 group-hover:text-gray-900"
|
||||
)
|
||||
]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# Returns the sort icon for a column
|
||||
# @param column [Symbol] The column name
|
||||
# @return [String] Icon HTML
|
||||
def sort_icon(column)
|
||||
if params[:sort] == column.to_s
|
||||
params[:direction] == "asc" ? "↑" : "↓"
|
||||
else
|
||||
"⇅"
|
||||
end
|
||||
end
|
||||
|
||||
# Renders search and filter form
|
||||
# @param filters [Array<Hash>] Filter definitions with :name, :label, :options keys
|
||||
# @param search_placeholder [String] Placeholder text for search field
|
||||
def admin_search_filter(filters: [], search_placeholder: "Search...")
|
||||
render "admin/shared/search_filter", filters: filters, search_placeholder: search_placeholder
|
||||
end
|
||||
|
||||
# Determines the current discard filter state based on query parameters
|
||||
# @return [Symbol] :active, :discarded, or :all
|
||||
def current_discard_filter
|
||||
if params[:discarded].present?
|
||||
:discarded
|
||||
elsif params[:all].present?
|
||||
:all
|
||||
else
|
||||
:active
|
||||
end
|
||||
end
|
||||
|
||||
# Returns the correct model for routing purposes
|
||||
# Handles STI (Single Table Inheritance) by returning the base class
|
||||
# @param record [ActiveRecord::Base] The record
|
||||
# @return [ActiveRecord::Base] The record or its base class for routing
|
||||
def route_model(record)
|
||||
if record.class.base_class == record.class
|
||||
record
|
||||
else
|
||||
record.becomes(record.class.base_class)
|
||||
end
|
||||
end
|
||||
|
||||
# Renders the archive/activate toggle button for a classroom
|
||||
# @param classroom [Classroom] The classroom record
|
||||
# @return [String] HTML button
|
||||
def classroom_archive_toggle_button(classroom)
|
||||
if classroom.archived?
|
||||
activate_button(classroom)
|
||||
else
|
||||
archive_button(classroom)
|
||||
end
|
||||
end
|
||||
|
||||
# Renders the discard/restore action button for a soft-deletable resource
|
||||
# @param resource [ActiveRecord::Base] The resource record (must respond_to :discarded?)
|
||||
# @return [String] HTML button or link
|
||||
def discard_restore_action(resource)
|
||||
if resource.discarded?
|
||||
restore_button(resource)
|
||||
else
|
||||
discard_link(resource)
|
||||
end
|
||||
end
|
||||
|
||||
# Returns the appropriate show path for a user based on their type
|
||||
# @param user [User] The user record
|
||||
# @return [String] Path to the type-specific show page
|
||||
def user_show_path(user)
|
||||
case user.type
|
||||
when "Student"
|
||||
admin_student_path(user)
|
||||
when "Teacher"
|
||||
admin_teacher_path(user)
|
||||
else
|
||||
admin_user_path(user)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def format_association(value)
|
||||
# Use presenter if available, otherwise fall back to to_s
|
||||
presenter_class = "#{value.class.name}Presenter".safe_constantize
|
||||
display_value = if presenter_class
|
||||
presenter_class.new(value).display_name
|
||||
else
|
||||
value.to_s
|
||||
end
|
||||
|
||||
# Try to link to the resource, but fall back to text if route doesn't exist
|
||||
begin
|
||||
link_to display_value, [:admin, route_model(value)]
|
||||
rescue NoMethodError, ActionController::UrlGenerationError
|
||||
display_value
|
||||
end
|
||||
end
|
||||
|
||||
def restore_button(resource)
|
||||
resource_name = resource.class.name.underscore
|
||||
restore_path = send("restore_admin_#{resource_name}_path", resource)
|
||||
|
||||
button_to "Restore", restore_path,
|
||||
method: :patch,
|
||||
data: { turbo_confirm: "Are you sure you want to restore this #{resource_name.humanize.downcase}?" },
|
||||
class: "text-green-600 hover:text-green-800",
|
||||
form: { style: "display: inline;" }
|
||||
end
|
||||
|
||||
def discard_link(resource)
|
||||
resource_name = resource.class.name.underscore
|
||||
resource_path = send("admin_#{resource_name}_path", resource)
|
||||
|
||||
link_to "Archive", resource_path,
|
||||
data: { turbo_method: :delete, turbo_confirm: "Are you sure you want to archive this #{resource_name.humanize.downcase}?" }, # rubocop:disable Layout/LineLength
|
||||
class: "text-red-600 hover:text-red-800"
|
||||
end
|
||||
|
||||
def activate_button(classroom)
|
||||
button_class = "inline-flex items-center px-4 py-2 border border-green-300 shadow-sm " \
|
||||
"text-sm font-medium rounded-md text-green-700 bg-white hover:bg-green-50"
|
||||
link_to toggle_archive_admin_classroom_path(classroom),
|
||||
data: { turbo_method: :patch, turbo_confirm: "Are you sure you want to activate this classroom?" },
|
||||
class: button_class do
|
||||
safe_join(
|
||||
[
|
||||
content_tag(:i, "", class: "fas fa-check-circle -ml-1 mr-2 h-5 w-5 text-green-500"),
|
||||
"Activate"
|
||||
]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def archive_button(classroom)
|
||||
button_class = "inline-flex items-center px-4 py-2 border border-yellow-300 shadow-sm " \
|
||||
"text-sm font-medium rounded-md text-yellow-700 bg-white hover:bg-yellow-50"
|
||||
link_to toggle_archive_admin_classroom_path(classroom),
|
||||
data: { turbo_method: :patch, turbo_confirm: "Are you sure you want to archive this classroom?" },
|
||||
class: button_class do
|
||||
safe_join(
|
||||
[
|
||||
content_tag(:i, "", class: "fas fa-archive -ml-1 mr-2 h-5 w-5 text-yellow-500"),
|
||||
"Archive"
|
||||
]
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
# rubocop:enable Metrics/ModuleLength
|
||||
@@ -0,0 +1,53 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module ApplicationHelper
|
||||
def ticker_stocks
|
||||
Stock.active.order(:ticker)
|
||||
end
|
||||
|
||||
def format_money(cents)
|
||||
format("$%.2f", cents / 100.0)
|
||||
end
|
||||
|
||||
def safe_url(url)
|
||||
uri = URI.parse(url)
|
||||
%w[http https].include?(uri.scheme) ? url : nil
|
||||
rescue URI::InvalidURIError
|
||||
nil
|
||||
end
|
||||
|
||||
# Generates a sort link for table headers
|
||||
# @param column [Symbol] The column name
|
||||
# @param label [String] The display label
|
||||
# @return [String] HTML link
|
||||
def sort_link(column, label)
|
||||
direction = params[:sort] == column.to_s && params[:direction] == "asc" ? "desc" : "asc"
|
||||
icon = sort_icon(column)
|
||||
|
||||
# Build URL with query parameters
|
||||
url = url_for(sort: column, direction: direction, only_path: true)
|
||||
|
||||
link_to url, class: "group inline-flex items-center" do
|
||||
safe_join(
|
||||
[
|
||||
label,
|
||||
content_tag(
|
||||
:span, icon,
|
||||
class: "ml-2 flex-none rounded text-gray-900 group-hover:text-gray-900"
|
||||
)
|
||||
]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# Returns the sort icon for a column
|
||||
# @param column [Symbol] The column name
|
||||
# @return [String] Icon HTML
|
||||
def sort_icon(column)
|
||||
if params[:sort] == column.to_s
|
||||
params[:direction] == "asc" ? "↑" : "↓"
|
||||
else
|
||||
"⇅"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,4 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module ClassroomsHelper
|
||||
end
|
||||
@@ -0,0 +1,35 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Components
|
||||
module ButtonHelper
|
||||
def render_button(label = "", text: nil, variant: :default, as: :button, href: nil, data: {}, **options, &block)
|
||||
base_classes = base_class_styling
|
||||
variant_classes = case variant.to_sym
|
||||
when :default
|
||||
" bg-primary text-primary-foreground hover:bg-primary/90 "
|
||||
when :secondary
|
||||
" bg-secondary text-secondary-foreground hover:bg-secondary/80 "
|
||||
when :error, :danger, :alert, :destructive
|
||||
" bg-destructive text-destructive-foreground hover:bg-destructive/90 "
|
||||
when :outline
|
||||
" border border-input bg-background hover:bg-accent hover:text-accent-foreground"
|
||||
when :ghost
|
||||
" hover:bg-accent hover:text-accent-foreground "
|
||||
end
|
||||
button_classes = "#{base_classes} #{variant_classes} #{options[:class]}"
|
||||
button_classes = tw(button_classes)
|
||||
text = label if label.present?
|
||||
text = capture(&block) if block
|
||||
render "components/ui/button", text:, button_classes:, as:, href:, data:, **options
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def base_class_styling
|
||||
" inline-flex items-center justify-center rounded-md text-sm font-medium " \
|
||||
"ring-offset-background transition-colors focus-visible:outline-hidden " \
|
||||
"focus-visible:ring-2 focus-visible:ring-3 focus-visible:ring-offset-2 " \
|
||||
"disabled:pointer-events-none disabled:opacity-50 h-10 px-4 py-2 "
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Components
|
||||
module CheckboxHelper
|
||||
def render_checkbox(label:, name:, **options)
|
||||
render "components/ui/checkbox", name: name, label: label, options: options
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,13 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Components
|
||||
module FormsHelper
|
||||
def render_form_with(**, &)
|
||||
form_with(**, builder: Shadcn::FormBuilder, &)
|
||||
end
|
||||
|
||||
def render_form_for(obj, **opts, &)
|
||||
form_for(obj, **opts, builder: Shadcn::FormBuilder, html: opts, &)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,55 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Components
|
||||
module InputHelper
|
||||
def render_input(name:, label: false, id: nil, type: :text, value: nil, **options)
|
||||
options[:class] = border_styling(options[:class])
|
||||
|
||||
options[:class] << case options[:variant]
|
||||
when :borderless
|
||||
borderless_variant_styling
|
||||
else
|
||||
border_variant_default_styling
|
||||
end
|
||||
options[:class] = tw(options[:class])
|
||||
|
||||
options.reverse_merge!(
|
||||
label: options[:lable] || false,
|
||||
required: options[:required] || false,
|
||||
disabled: options[:disabled] || false,
|
||||
readonly: options[:readonly] || false,
|
||||
placeholder: options[:placeholder] || "",
|
||||
autocomplete: options[:autocomplete] || "",
|
||||
autocapitalize: options[:autocapitalize] || nil,
|
||||
autocorrect: options[:autocorrect] || nil
|
||||
)
|
||||
render partial: "components/ui/input", locals: {
|
||||
type:,
|
||||
label:,
|
||||
name:,
|
||||
value:,
|
||||
id:,
|
||||
options: options
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def border_styling(options_class)
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 " \
|
||||
"py-2 text-sm transition-colors ring-offset-background file:border-0 " \
|
||||
"file:bg-transparent file:text-sm file:font-medium " \
|
||||
"placeholder:text-muted-foreground disabled:cursor-not-allowed " \
|
||||
"disabled:opacity-50 #{options_class} "
|
||||
end
|
||||
|
||||
def borderless_variant_styling
|
||||
" border-0 focus-visible:outline-hidden focus-visible:shadow-none focus-visible:ring-transparent"
|
||||
end
|
||||
|
||||
def border_variant_default_styling
|
||||
"shadow-xs focus-visible:outline-hidden focus-visible:ring-2a " \
|
||||
"focus-visible:ring-3 focus-visible:ring-offset-2 focus-visible:border-muted"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Components
|
||||
module LabelHelper
|
||||
def render_label(name:, label:, **options)
|
||||
render "components/ui/label", name: name, label: label, options: options
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,19 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Components
|
||||
module TextareaHelper
|
||||
def render_textarea(name:, label: false, id: nil, value: nil, **options)
|
||||
options.reverse_merge!(
|
||||
rows: 3, required: false, disabled: false,
|
||||
readonly: false, class: "", label: false, placeholder: "Type here..."
|
||||
)
|
||||
render partial: "components/ui/textarea", locals: {
|
||||
label:,
|
||||
name:,
|
||||
value:,
|
||||
id:,
|
||||
options: options
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,26 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "tailwind_merge"
|
||||
|
||||
module ComponentsHelper
|
||||
def tw(*classes)
|
||||
TailwindMerge::Merger.new.merge(classes.join(" "))
|
||||
end
|
||||
|
||||
PRIMARY_CLASSES = " bg-primary text-primary-foreground hover:bg-primary/80 "
|
||||
SECONDARY_CLASSES = " bg-secondary text-secondary-foreground hover:bg-secondary/80 "
|
||||
OUTLINE_CLASSES = " border border-input bg-background hover:bg-accent hover:text-accent-foreground "
|
||||
GHOST_CLASSES = " hover:bg-accent hover:text-accent-foreground "
|
||||
DESTRUCTIVE_CLASSES = " bg-destructive text-destructive-foreground hover:bg-destructive/90 "
|
||||
|
||||
module Button
|
||||
PRIMARY = ComponentsHelper::PRIMARY_CLASSES
|
||||
SECONDARY = ComponentsHelper::SECONDARY_CLASSES
|
||||
OUTLINE = ComponentsHelper::OUTLINE_CLASSES
|
||||
GHOST = ComponentsHelper::GHOST_CLASSES
|
||||
DESTRUCTIVE = ComponentsHelper::DESTRUCTIVE_CLASSES
|
||||
end
|
||||
|
||||
module Alert
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,4 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module OrdersHelper
|
||||
end
|
||||
@@ -0,0 +1,4 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module SchoolsHelper
|
||||
end
|
||||
@@ -0,0 +1,6 @@
|
||||
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
|
||||
import "@hotwired/turbo-rails"
|
||||
import "controllers"
|
||||
|
||||
import "trix"
|
||||
import "@rails/actiontext"
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
// Connects to data-controller="admin-sidebar"
|
||||
export default class extends Controller {
|
||||
static targets = ["mobileSidebar", "overlay"]
|
||||
|
||||
toggle() {
|
||||
this.mobileSidebarTarget.classList.toggle("-translate-x-full")
|
||||
|
||||
// Toggle overlay visibility
|
||||
if (this.overlayTarget.classList.contains("hidden")) {
|
||||
this.overlayTarget.classList.remove("hidden")
|
||||
this.overlayTarget.style.display = "block"
|
||||
} else {
|
||||
this.overlayTarget.classList.add("hidden")
|
||||
this.overlayTarget.style.display = "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Application } from "@hotwired/stimulus"
|
||||
|
||||
const application = Application.start()
|
||||
|
||||
// Configure Stimulus development experience
|
||||
application.debug = false
|
||||
window.Stimulus = application
|
||||
|
||||
export { application }
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
// Connects to data-controller="autosave"
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
interval: { type: Number, default: 30000 } // we'll default to 30 seconds here
|
||||
}
|
||||
|
||||
static targets = ["form", "button", "status"]
|
||||
|
||||
connect() {
|
||||
this.startAutosave()
|
||||
this.bindTurboEvents()
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.stopAutosave()
|
||||
}
|
||||
|
||||
startAutosave() {
|
||||
this.timer = setInterval(() => this.save(), this.intervalValue)
|
||||
}
|
||||
|
||||
stopAutosave() {
|
||||
if (this.timer) clearInterval(this.timer)
|
||||
}
|
||||
|
||||
save() {
|
||||
if (!this.hasButtonTarget) return
|
||||
this.buttonTarget.click()
|
||||
}
|
||||
|
||||
bindTurboEvents() {
|
||||
this.formTarget.addEventListener("turbo:submit-start", () => {
|
||||
this.buttonTarget.textContent = "Saving..."
|
||||
})
|
||||
this.formTarget.addEventListener("turbo:submit-end", () => {
|
||||
const now = new Date()
|
||||
const formatted = now.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: true
|
||||
})
|
||||
this.statusTarget.textContent = `Last saved at ${formatted}`
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
// Connects to data-controller="clickable-row"
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
url: String
|
||||
}
|
||||
|
||||
navigate(event) {
|
||||
// Don't navigate if clicking on a link, button, or interactive element
|
||||
const targetElement = event.target
|
||||
const isInteractiveElement = targetElement.closest("a, button, input, select, textarea")
|
||||
|
||||
if (isInteractiveElement) {
|
||||
return
|
||||
}
|
||||
|
||||
// Navigate to the show page
|
||||
if (this.urlValue) {
|
||||
Turbo.visit(this.urlValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
// Connects to data-controller="form-filter"
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
frameId: String
|
||||
}
|
||||
|
||||
change(event) {
|
||||
const value = event.target.value
|
||||
const frame = document.getElementById(this.frameIdValue)
|
||||
|
||||
if (frame) {
|
||||
const url = new URL(frame.src || window.location.href)
|
||||
url.searchParams.set(event.target.name, value)
|
||||
frame.src = url.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Import and register all your controllers from the importmap under controllers/*
|
||||
import { application } from "controllers/application"
|
||||
|
||||
// Eager load all controllers defined in the import map under controllers/**/*_controller
|
||||
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
|
||||
eagerLoadControllersFrom("controllers", application)
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["overlay"]
|
||||
|
||||
connect() {
|
||||
this.boundShowModal = this._show.bind(this)
|
||||
document.addEventListener("turbo:frame-load", this.boundShowModal)
|
||||
|
||||
this._keydownHandler = (e) => {
|
||||
if (e.key === "Escape") {
|
||||
this.close()
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", this._keydownHandler)
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.boundShowModal) {
|
||||
document.removeEventListener("turbo:frame-load", this.boundShowModal)
|
||||
}
|
||||
if (this._keydownHandler) {
|
||||
document.removeEventListener("keydown", this._keydownHandler)
|
||||
}
|
||||
}
|
||||
|
||||
_show(event) {
|
||||
const frame = event.target
|
||||
if (frame.id === "modal_frame" && frame.innerHTML.trim() !== "") {
|
||||
this.show()
|
||||
}
|
||||
}
|
||||
|
||||
show() {
|
||||
this.overlayTarget.classList.remove("hidden")
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.hasOverlayTarget) {
|
||||
this.overlayTarget.classList.add("hidden")
|
||||
|
||||
const frame = this.overlayTarget.querySelector("turbo-frame#modal_frame")
|
||||
if (frame) {
|
||||
frame.innerHTML = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
backdropClick(event) {
|
||||
if (event.target === this.overlayTarget) {
|
||||
this.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["shares", "totalCost", "currentPrice"]
|
||||
static values = {
|
||||
currentPrice: Number,
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.calculateTotal()
|
||||
}
|
||||
|
||||
calculateTotal() {
|
||||
let shares = parseInt(this.sharesTarget.value, 10) || 0
|
||||
|
||||
if (shares > 999_999_999_999) {
|
||||
shares = 999_999_999_999
|
||||
}
|
||||
|
||||
this.sharesTarget.value = shares.toString()
|
||||
|
||||
const price = this.currentPriceValue
|
||||
const total = shares * price
|
||||
|
||||
this.totalCostTarget.textContent = `$${total.toFixed(2)}`
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
data: Array
|
||||
}
|
||||
|
||||
async connect() {
|
||||
await this.initializeChart()
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.chart) {
|
||||
this.chart.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
async initializeChart() {
|
||||
// Import Chart.js and wait for it to load
|
||||
const chartModule = await import("chart.js")
|
||||
|
||||
// Access Chart from the imported module - try multiple possible export patterns
|
||||
const Chart = chartModule.default || chartModule.Chart || window.Chart
|
||||
|
||||
if (!Chart) {
|
||||
console.error("Chart.js not loaded properly")
|
||||
return
|
||||
}
|
||||
|
||||
const ctx = this.element.getContext("2d")
|
||||
|
||||
// Extract labels and data from chartData
|
||||
const labels = this.dataValue.map(point => point.label)
|
||||
const values = this.dataValue.map(point => point.value)
|
||||
|
||||
this.chart = new Chart(ctx, {
|
||||
type: "line",
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: "Portfolio Value",
|
||||
data: values,
|
||||
borderColor: "#000000",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.1)",
|
||||
borderWidth: 2.5,
|
||||
pointRadius: 6,
|
||||
pointBackgroundColor: "#4F46E5",
|
||||
pointBorderColor: "#4F46E5",
|
||||
pointBorderWidth: 0,
|
||||
pointHoverRadius: 8,
|
||||
tension: 0.4,
|
||||
fill: false
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
layout: {
|
||||
padding: {
|
||||
top: 5,
|
||||
right: 5,
|
||||
bottom: 2,
|
||||
left: 2
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: "rgba(0, 0, 0, 0.8)",
|
||||
padding: 12,
|
||||
titleFont: {
|
||||
size: 14,
|
||||
family: "Inter, sans-serif"
|
||||
},
|
||||
bodyFont: {
|
||||
size: 13,
|
||||
family: "Inter, sans-serif"
|
||||
},
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
return "Value: $" + context.parsed.y.toFixed(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
display: false
|
||||
},
|
||||
ticks: {
|
||||
font: {
|
||||
size: 12,
|
||||
family: "'Open Sans', system-ui, sans-serif"
|
||||
},
|
||||
color: "#000000",
|
||||
padding: 3
|
||||
},
|
||||
border: {
|
||||
display: false
|
||||
},
|
||||
offset: true
|
||||
},
|
||||
y: {
|
||||
grid: {
|
||||
display: true,
|
||||
color: "rgba(0, 0, 0, 0.1)",
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
font: {
|
||||
size: 12,
|
||||
family: "'Open Sans', system-ui, sans-serif"
|
||||
},
|
||||
color: "#000000",
|
||||
padding: 4,
|
||||
callback: function(value) {
|
||||
return "$" + value.toFixed(0)
|
||||
}
|
||||
},
|
||||
border: {
|
||||
display: false
|
||||
},
|
||||
grace: '2%'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
navigateLink(event) {
|
||||
// Prevent the summary from toggling when clicking the link
|
||||
event.stopPropagation()
|
||||
|
||||
// Close the mobile menu by unchecking the checkbox
|
||||
const menuCheckbox = document.getElementById('mobile-menu-toggle')
|
||||
if (menuCheckbox) {
|
||||
menuCheckbox.checked = false
|
||||
}
|
||||
}
|
||||
|
||||
toggleChevron(event) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
this.element.open = !this.element.open
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ApplicationJob < ActiveJob::Base
|
||||
# Automatically retry jobs that encountered a deadlock
|
||||
# retry_on ActiveRecord::Deadlocked
|
||||
|
||||
# Most jobs are safe to ignore if the underlying records are no longer available
|
||||
# discard_on ActiveJob::DeserializationError
|
||||
end
|
||||
@@ -0,0 +1,33 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class MonthlyPortfolioSnapshotJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(target_date = Date.current, batch_size = 1000)
|
||||
Rails.logger.info "[RECURRING JOB] MonthlyPortfolioSnapshotJob starting at #{Time.current} (#{Time.zone})"
|
||||
Rails.logger.info "[RECURRING JOB] Current environment: #{Rails.env}"
|
||||
Rails.logger.info "[RECURRING JOB] Target date: #{target_date}"
|
||||
|
||||
Portfolio.includes(:portfolio_stocks, :stocks)
|
||||
.find_in_batches(batch_size: batch_size) do |batch|
|
||||
batch.each { |portfolio| create_snapshot_for_portfolio(portfolio, target_date) }
|
||||
end
|
||||
|
||||
Rails.logger.info "[RECURRING JOB] MonthlyPortfolioSnapshotJob completed successfully"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_snapshot_for_portfolio(portfolio, date)
|
||||
return if portfolio.portfolio_snapshots.exists?(date: date)
|
||||
|
||||
total_value_cents = portfolio.calculate_total_value_cents
|
||||
|
||||
portfolio.portfolio_snapshots.create!(
|
||||
date: date,
|
||||
worth_cents: total_value_cents
|
||||
)
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.error "Failed to create snapshot for portfolio #{portfolio.id}: #{e.message}"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,53 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class OrderExecutionJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
retry_on StandardError, wait: :exponentially_longer, attempts: 3
|
||||
|
||||
def perform
|
||||
Rails.logger.info "[RECURRING JOB] OrderExecutionJob starting at #{Time.current} (#{Time.zone})"
|
||||
Rails.logger.info "[RECURRING JOB] Current environment: #{Rails.env}"
|
||||
|
||||
pending_orders = Order.pending
|
||||
log_pending_orders_count(pending_orders)
|
||||
|
||||
process_pending_orders(pending_orders) unless pending_orders.empty?
|
||||
|
||||
Rails.logger.info "Order execution job completed successfully"
|
||||
rescue StandardError => e
|
||||
log_execution_error(e)
|
||||
raise # Re-raise to trigger retry mechanism
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def log_pending_orders_count(pending_orders)
|
||||
Rails.logger.info "Found #{pending_orders.count} pending orders to execute"
|
||||
end
|
||||
|
||||
def process_pending_orders(pending_orders)
|
||||
execute_orders(pending_orders)
|
||||
process_transaction_fees(pending_orders)
|
||||
log_successful_execution(pending_orders)
|
||||
end
|
||||
|
||||
def execute_orders(pending_orders)
|
||||
pending_orders.each do |pending_order|
|
||||
ExecuteOrder.execute(pending_order)
|
||||
end
|
||||
end
|
||||
|
||||
def process_transaction_fees(pending_orders)
|
||||
TransactionFeeProcessor.execute(pending_orders)
|
||||
end
|
||||
|
||||
def log_successful_execution(pending_orders)
|
||||
Rails.logger.info "Successfully executed #{pending_orders.count} orders"
|
||||
end
|
||||
|
||||
def log_execution_error(error)
|
||||
Rails.logger.error "Order execution job failed: #{error.message}"
|
||||
Rails.logger.error error.backtrace.join("\n")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,13 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class StockAttributeUpdateJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform
|
||||
Stock.find_each do |stock|
|
||||
Rails.logger.info "Processing stock attribute update for #{stock.ticker}"
|
||||
|
||||
StockAttributeUpdate.execute(stock)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,99 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class StockPricesUpdateJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
retry_on StandardError, wait: :exponentially_longer, attempts: 3
|
||||
|
||||
def perform(...)
|
||||
Rails.logger.info "Starting stock prices update job at #{Time.current}"
|
||||
@api_client = AlphaVantageApiClient.new
|
||||
|
||||
stock_count = Stock.count
|
||||
Rails.logger.info "Found #{stock_count} stocks to update"
|
||||
|
||||
return if stock_count.zero?
|
||||
|
||||
updated_count = 0
|
||||
Stock.find_each do |stock|
|
||||
updated_count += 1 if update_stock_price(stock)
|
||||
sleep(1.1) # To rate limit
|
||||
end
|
||||
|
||||
Rails.logger.info "Stock prices update job completed: #{updated_count}/#{stock_count} stocks updated successfully"
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Stock prices update job failed: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
raise
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_stock_price(stock)
|
||||
symbol = stock.ticker
|
||||
return false unless valid_stock_symbol?(stock, symbol)
|
||||
|
||||
Rails.logger.debug { "Updating stock price for #{symbol}" }
|
||||
update_stock_with_transaction(stock, symbol)
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.error "Failed to save #{symbol}: #{e.message}"
|
||||
false
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Failed to update stock price for #{symbol}: #{e.message}"
|
||||
false
|
||||
end
|
||||
|
||||
def valid_stock_symbol?(stock, symbol)
|
||||
return true if symbol.present?
|
||||
|
||||
Rails.logger.warn "Skipping stock with blank ticker: ID #{stock.id}"
|
||||
false
|
||||
end
|
||||
|
||||
def update_stock_with_transaction(stock, symbol)
|
||||
Stock.transaction do
|
||||
stock.yesterday_price_cents = stock.price_cents
|
||||
process_price_update(stock, symbol)
|
||||
end
|
||||
end
|
||||
|
||||
def process_price_update(stock, symbol)
|
||||
price_data = @api_client.fetch_quote(symbol)
|
||||
return persist_yesterday_price_with_warning(stock, symbol) unless price_data
|
||||
|
||||
unless should_update?(stock, price_data[:trading_day])
|
||||
Rails.logger.info "Skipping #{symbol}: no trading since #{stock.last_trading_day}"
|
||||
return false
|
||||
end
|
||||
|
||||
save_updated_price(stock, symbol, price_data[:price], price_data[:trading_day])
|
||||
end
|
||||
|
||||
def persist_yesterday_price_with_warning(stock, symbol)
|
||||
stock.save!
|
||||
Rails.logger.warn "Could not fetch new price for #{symbol}, saved yesterday price only"
|
||||
stock
|
||||
end
|
||||
|
||||
def should_update?(stock, latest_trading_day)
|
||||
stock.last_trading_day.nil? || latest_trading_day.to_date > stock.last_trading_day
|
||||
end
|
||||
|
||||
def save_updated_price(stock, symbol, price, trading_day)
|
||||
stock.price_cents = convert_to_cents(price)
|
||||
stock.last_trading_day = trading_day.to_date
|
||||
stock.save!
|
||||
log_successful_update(stock, symbol)
|
||||
stock # Return the stock object (truthy)
|
||||
end
|
||||
|
||||
def convert_to_cents(price)
|
||||
(price.to_f * 100).to_i
|
||||
end
|
||||
|
||||
def log_successful_update(stock, symbol)
|
||||
yesterday_price = stock.yesterday_price_cents.to_f / 100.0
|
||||
current_price = stock.price_cents.to_f / 100.0
|
||||
Rails.logger.info "Updated #{symbol}: $#{yesterday_price} -> $#{current_price}"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ApplicationMailer < ActionMailer::Base
|
||||
default from: "from@example.com"
|
||||
layout "mailer"
|
||||
end
|
||||