chore: init commit

in worker.../repo/GITFOLDER.zip is the .git folder.
This commit is contained in:
2026-08-11 14:44:09 -04:00
parent 0012380fd3
commit 392781f7aa
781 changed files with 65944 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 34 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 22 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 KiB

View File

@@ -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

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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; }
}

View File

@@ -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";

View File

@@ -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 */
}

View File

@@ -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
}

View File

@@ -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;
}

View File

@@ -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%;
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,6 @@
# frozen_string_literal: true
module ApplicationCable
class Channel < ActionCable::Channel::Base
end
end

View File

@@ -0,0 +1,6 @@
# frozen_string_literal: true
module ApplicationCable
class Connection < ActionCable::Connection::Base
end
end

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,7 @@
# frozen_string_literal: true
class HomeController < ApplicationController
def index
@current_announcement = Announcement.current
end
end

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,4 @@
# frozen_string_literal: true
module ClassroomsHelper
end

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,4 @@
# frozen_string_literal: true
module OrdersHelper
end

View File

@@ -0,0 +1,4 @@
# frozen_string_literal: true
module SchoolsHelper
end

View File

@@ -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"

View File

@@ -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"
}
}
}

View File

@@ -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 }

View File

@@ -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}`
})
}
}

View File

@@ -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)
}
}
}

View File

@@ -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()
}
}
}

View File

@@ -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)

View File

@@ -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()
}
}
}

View File

@@ -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)}`
}
}

View File

@@ -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%'
}
}
}
})
}
}

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,6 @@
# frozen_string_literal: true
class ApplicationMailer < ActionMailer::Base
default from: "from@example.com"
layout "mailer"
end

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