From 8cd54459ecd79108bbb8c490acd1b7ed005a3d1f Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 6 Jul 2026 06:52:18 +0000 Subject: [PATCH] Adds website theme serialization system --- .devcontainer/devcontainer.json | 3 +- angular.json | 2 +- .../app/__pycache__/config.cpython-312.pyc | Bin 2076 -> 2243 bytes backend/app/__pycache__/main.cpython-312.pyc | Bin 13898 -> 14509 bytes .../app/__pycache__/models.cpython-312.pyc | Bin 14879 -> 14910 bytes .../app/__pycache__/schemas.cpython-312.pyc | Bin 15439 -> 15763 bytes .../__pycache__/theme_export.cpython-312.pyc | Bin 0 -> 6390 bytes .../api/__pycache__/community.cpython-312.pyc | Bin 5020 -> 5019 bytes .../api/__pycache__/events.cpython-312.pyc | Bin 4820 -> 4819 bytes .../api/__pycache__/history.cpython-312.pyc | Bin 4924 -> 4923 bytes .../__pycache__/mobile_builds.cpython-312.pyc | Bin 32720 -> 33976 bytes .../app/api/__pycache__/shows.cpython-312.pyc | Bin 6916 -> 6914 bytes .../station_config.cpython-312.pyc | Bin 2879 -> 3343 bytes .../app/api/__pycache__/team.cpython-312.pyc | Bin 4898 -> 4897 bytes .../app/api/__pycache__/theme.cpython-312.pyc | Bin 0 -> 2324 bytes .../__pycache__/underwriters.cpython-312.pyc | Bin 4920 -> 4919 bytes backend/app/api/mobile_builds.py | 22 +++ backend/app/api/station_config.py | 12 ++ backend/app/api/theme.py | 52 +++++++ backend/app/config.py | 4 + backend/app/main.py | 11 +- backend/app/schemas.py | 8 ++ backend/app/theme_export.py | 134 ++++++++++++++++++ src/app/admin/admin.component.html | 29 +++- src/app/admin/admin.component.scss | 54 +++++++ src/app/admin/admin.component.ts | 35 +++++ src/app/interfaces/mobile-build.ts | 7 + src/app/services/mobile-build.service.ts | 14 ++ theme.json | 35 +++++ 29 files changed, 418 insertions(+), 4 deletions(-) create mode 100644 backend/app/__pycache__/theme_export.cpython-312.pyc create mode 100644 backend/app/api/__pycache__/theme.cpython-312.pyc create mode 100644 backend/app/api/theme.py create mode 100644 backend/app/theme_export.py create mode 100644 theme.json diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 8794749..8f9e7e5 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -13,7 +13,8 @@ "extensions": [ "Angular.ng-template", "magnobiet.sass-extension-pack", - "Anthropic.claude-code" + "Anthropic.claude-code", + "ms-azuretools.vscode-containers" ] } } diff --git a/angular.json b/angular.json index 36dfc82..3856bdd 100644 --- a/angular.json +++ b/angular.json @@ -74,7 +74,7 @@ { "type": "anyComponentStyle", "maximumWarning": "4kB", - "maximumError": "8kB" + "maximumError": "10kB" } ], "outputHashing": "all" diff --git a/backend/app/__pycache__/config.cpython-312.pyc b/backend/app/__pycache__/config.cpython-312.pyc index 0f0569ae15fa4bb3f2676f569cfb05a5a085ad29..290075e6c6266a4343ddd849ee69368dff9c7f9f 100644 GIT binary patch delta 364 zcmbOua9EJu2ZYm*$luX6B{k%w>E#!t-{KB;bqe+j zag7fR@~Pt2Pb?_VFUd&FP1VaP&dl*JB?C%#J;27d@iz_v+EWV&9H7&E^ z7OS^!h+n*)rsCwetUDZv6oGCjQUVgUcyjYoQgh;y^YhX&(^oQl28uEK($O!^FUl@1 zNK8&G2D+pK>InU$#N_PMycGS(IczeF8k;-WxEYl`urWxOHh51EzRY5BLt3Z7e?stO z7M%wo3JcUP2x~R^6zNS~z;5iR3$jrUMCb#FTO2k(drNat?TUC%v f?=on8VN+(*?x^{~0Hi+pFf#CnbXzrYfb|0aX-aGu delta 180 zcmX>sI7fi*G%qg~0}%B2duB~xoyb?qn6$AcmXRw(JCiX=u2M%+cQZfJ6z0kASw$J; zC$qBc5WmHfo1c=J6Q7))mzJ5nlHoH*`!C+ff7xUhg*HpEb2IWh5K&m5enD8P(Wgjf zvO9;dr#4VYkq(H^1roP7Y=B0T=A_yc834JAKwK;bBt9@RGBVy}(E7qAu|^MwIO ReRN@D;1TJzYUBXx2LP1=F3|u0 diff --git a/backend/app/__pycache__/main.cpython-312.pyc b/backend/app/__pycache__/main.cpython-312.pyc index 226b83e9c5a9cf2bd9421aa7894112068cb8e0d7..8e895146cc909227d6f53ef180d41588a14f5fc3 100644 GIT binary patch delta 2932 zcma)8ZERat8NTP<>+8=rPV6|};W~DlIF231P0}`P6Aa1+8?g-y9}ODD?Y#|l9os#= zS&}MQ+9`humb&Vo4cIEA{%r?H{=v|uDNUP|Hfrp_pv)UWd`R%KWP_n0AO}^57)!c-xU+unodfq+jMvP=!XJ)%4 z6U82>hN4GCl3CWAWu@A6{szpW*K9A#$QsEa)k*bo?@j9s{=d3c_9jp5@q;L9Wm9sN zIJ8z0L-s~*^0|Z2JubHqk#F!cN=Z`c5tMPWWTD(Jz ze}k>(s6WZF4aC9Q$PcRKi)#MEs&R)JZ{Vt}*Qv%&R*gF$?&*;{|5x=c)vZ|7txJv9 z@YQzXrg6JX>VIl6Q}SEzki3C&hZzRDB}N)ZwagF94pe;%1F%qIP&)La(=xlvdIm|J zFCi0oPLtA>HOjZ;dAV>;Gs2^x0&jTJfLvt5)`%Vk~i#~C{_5teuhKCg|U{m5lWDMx4weB~ zRlPDr+Ffn-&&V_iBx=& z(F)tY(EQC#CGHfn zh*V9d=Kn&)msNa$W_2}Rd7Q)M*Hv9naSo!6r32BVlTpl-9l03XyviyKB-7K$(~TtP z1{$m(Iu+$VP;pVkFl?n3N4Wzq|6 zbQMwj<7-5mfmRovIT;Cykfq3JQOSrGf};4f(3its2v3fP6Y)e!mOxg0MAgR+J|g|2 z&4ZPN?1)&{aQ~DIu2bhSN82AmGnVz^OK0RPbeD(25W|ZgBRK~@8nhW{Pha;8p$+m| z&u@^2{G_+>_ztp%?GiAdWEXV2g$uL3Lqz#3&kT$)d$V+%PMmxVbw6O&Hk@6y0iA2oUa zkgZE4&dRxLJdLl@;YG;v(G&VB^UsUS#{y%qUs<`la)tQ%KJ-7*X*5&lcei6*H&J<0i^6tcQwJ%VCL^7<*@(i;{AN=<@Lm)S3Y%W= z+w_lxN4eWR6dn?8`v)n%(}}`IggegK@ERQiVe*|5D13~+GYXTpEGS|Vw(4N=maR6@ z&TTnSq*2&%@xXf^f9nv6wD4PlJn*BI$T0Jc9z_oE?-)ejy#cD+ z58Az9vt~ypP<_X&p~{XG4q&Gl(fpk@A>6Osp~ZEXcAQ$^y?W4x5G{J=h>FM5%uyjy zr!DemEuu9QHCh;$AhpZ&ke>xEq3eaC15>E( zf}ua2P4ugsggfZlNiGFDJAO=wCrBGKlUC?I0jb1k3|)<{0sN5M3IVO#gMH6@!ZK)r{e)^2 z{7B`yf(?zMUH>FP!@D!A3Hcs%Fl@sk_KOG^zOUV7;QkT!So+&H3*Q^Oh)8*;mDq-Z HJS_bmJ)V9A delta 2456 zcmZ`)ZA?>F7{2elxA(TC1qF(Hl&=;FZJ|~^{MrODI_3gxvbpGV4v~9BYAtwr9gv7R zV=_%hCOMhg;{5otKSMK^8y8b?kLn3+P4dO4%Bm%_jXurFqdibi z^lE#F2Q^*>?krM|+=oheK#eHOE$OqIeqFGtU>i$Bvlvu7Z|g}Tg=G%d7pq#?jqEacgZ#f_M>&Gd~byU25gO!QGzWez|3&noT| zKl;0l-_YXf1LgeaV}%>x5A%aC$PEeaaD$*{ALw|8{@`*}@}s{l%G}j6r*%|x>_Als zKe}^~>P+6rW1%f&9J_E#A9ZTmBSXWj9TS>eYHloNY1zP0z3J5e= z;tewLH7J@BflYW$*3pc1b5A$ z?q0!mZ+}E%e=y;`-2@K-`3&JS{j13jmDJt*y-nZl`WDu=EbbOEO#f|etwjs+HUi$s zscrS@{(j|PSZyI!SPbv%Xd&IRvaaPJcle(E`H4F`FXh7~nD?~y#X diff --git a/backend/app/__pycache__/models.cpython-312.pyc b/backend/app/__pycache__/models.cpython-312.pyc index 46affd95f825b7058d4ddf7f8e3d42adc7f64111..6fb50acbd43f476405a03d6a086f0ca5ef3dc78b 100644 GIT binary patch delta 57 zcmbPVvaf{eG%qg~0}#v$-pKWgNkK=yJijQrxF9h(wOBtpwq0Y=X5rcCxx-cE7{3;?v<_{2wQJFF~ z=OmCb9F5bd7~LydX1BP9o6#*JX7RDW9}JyK+~VT0Kiop%A6w$?_Y@t`*rcDHdtUe4 zb059DmA0k)pw}xg@-voJyK#B(Cn;xjc8|BKO>1d?DbN^f_qMclY+4^`>k4Y#b%Dl~ z;3m}@xuiQ87kficz?~K8PYjF7V0=)g7FWwOES21sJolwiNUB%$HH-R$?lO0I#IJ0L z^-s)bYzwt%4P6_WJA(HHq2{Iy9TUk>OKVVV?rgnhS{IT<&%RkRn&Wfx?p@3s!dRfxGqueo2KKH zNWZdaI$o31l~2?0>bixyl%Ae>-Kf>ejM@vp8epx2yMhYR|4e((LJs4s)CM>Kt@7L6 z6)BswAU1AP=cM91OKz^KXIX#YP{n{}ROcFZ2Y9nlJ`V zQcvb8(Zs25XLdZCw!>7TG{YPCm_|%xVGNw1 z+ga5j#Mu{{k7S@J(SkUn4MUawF`GpzUALN|iKevwT60r3!vXkCe%n`($CgWPWX}_u zIpeR{uRS7TPfnfEj#%I#jpr;8?XtByg-S;)mC6>FfUES2#U-|IHk)-=nq?R1XRB38 za(?P@B#9fcLj}}Rl0s{34$;lNw==WlE?LQKveRESUpjiB6#yj?E9Ag8=Rq*5f?SQ0m6SqSI?$GOwgEsr3F5YuH%nvzYZTnnF;x5JKsr0+cOuO^;x;XSfU&K#eSqHCd7t}x^EqB(4 zU6HVJwh(*h$NYsNhi17NL?2yFHYvUE1B^82dRFxEB1SxJDk?A}?}71Ei8?KatgZnl z0H=`Kt(U?pF;kC#qVKJV-jM;$@pi#gxkp1JVrAa-hShx6ZrVNjw@ zI{@)5AQn)8AqjUfPO**6+&T7Pvj;w5U;q`;eovC9qV}TI;vmoPThUG1{TZfBz`@+M zA-YvuTJcC9o`4+-@0BxcKIIUUz*E#zGAIu5q()B{m6RsUd=6bolt$U5Z0nfLO0}hS z8u6?UUEI!L1Ouz5wNugWt{KlTjpo5<-bLalpN5a%0;uHk<Ik0HCPntlh4Fq-*M#>+<+EZ zR^}I{kSTJo%qrZU!@>=)L;3>v65#FNgZveSXL-8Q5P?p*TmHN_E%VHyaH5Sqt=J{_ zBq`_M0_>!fm40!Kiyp2VNH`C}w^ZeKiSH@kw@vw9fEw6Khy4r01upH5zfd?MuyB3< zSH-oQf#GZ5EIm1Usklgevzun{S>f3)aIIJ101T34&M|R?3;AhIw+K^?(WG318TgPo z=i0Ud$>N do}rq_82K}~QIrbTkb5#l{!FfqJofk!@gFD8A=Ll? delta 1908 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 literal 0 HcmV?d00001 diff --git a/backend/app/api/__pycache__/community.cpython-312.pyc b/backend/app/api/__pycache__/community.cpython-312.pyc index 07b797db6bb1d24e0c57d51c5a1088d869242874..53e4fc022a57c54c0bb9dde556c2152a05896562 100644 GIT binary patch delta 76 zcmbQEK3kplG%qg~0}!;8dS)Hm$eYT{$gsJDc{v}S(~Ob}Ds~s;?Hi;nGdfJx6f$6R goE#!RiEagY;!apUE49%ou$qKNs2x0A!gM6951J delta 77 zcmbQOK1ZGRG%qg~0}$l=cFa1wkvEl@iBW%ZG4pahKIa827gX&pDmXMqU1oHgtRZB; h=rlP}$eTO(f}-^a;Rfl;jJ}iC3z;$cO@1b{6##wS7+C-S diff --git a/backend/app/api/__pycache__/events.cpython-312.pyc b/backend/app/api/__pycache__/events.cpython-312.pyc index bc50720b341219af62fb3386994d684dbcbf5eed..fed23f2d114bc303cd25ea49b2ef20230e716492 100644 GIT binary patch delta 74 zcmcbjdRdkCG%qg~0}xCs^~`#*k=Kx!kzum~vm76<(~Ob@z8B?<8l)~W8c)s^G+;ED eJV(%tD`)}l4&w&t%Z&Du-w2v9I!sm;+6VwSXBNi* delta 75 zcmcbtdPSA@G%qg~0}$l=cFcOVk=Kx!iBW&EJ+mAiuk!+y1+f<;G!>C21`lV1y(F*;6G650p=Q!f`D diff --git a/backend/app/api/__pycache__/history.cpython-312.pyc b/backend/app/api/__pycache__/history.cpython-312.pyc index 101cf1531b74d66fdb1cae61f98ad5981e8a12bf..19f6c14c7e25830d4e27cd7e13468b235469f27b 100644 GIT binary patch delta 76 zcmdm^wp)$&G%qg~0}#w9^~~C}k=K`*kzsQTa|$1y(~Ob}Di#;zEgPgRGg?hPCuqQE gJ^8<&2Y1i~1(O5H4bqnx-6jVKnK8Og?iAV#0Dx2&VgLXD delta 77 zcmdn3wnvTkG%qg~0}$l=cFfwdk=K`*iBW%ZG;<0cpYsBi3#yhE6|5ShE;CwBJ}YRz hXfyerpa*yG1x3>X#tqV!8Qmub3Yjr_Ozsfc3;>i)7?J=0 diff --git a/backend/app/api/__pycache__/mobile_builds.cpython-312.pyc b/backend/app/api/__pycache__/mobile_builds.cpython-312.pyc index 8d5e91d42ee911a94c3e5bf490719564c5d88081..786bccc211cf802bf8fe08c9160a4e58ffa97068 100644 GIT binary patch delta 2703 zcmaJ>4NP0t6@K^q`41cOW2Yqiyg&mu6lj8p17rlp*dYXn329i;Y!2l<2!z=1{0Ip% zgQZ#3X|0;C~*N)JfKwZAm6IJNFr= z(~3R$e*VtA=R4=zbKiaOd+74FSlhc+tC@kvm$i5BcK3PP18DoJW$&>)`Eo6GV$^5E zThH=`_uyhV50}U#xJowToIDU<>QS@om4V&h)rw2y8ePTXTCn%Z)q~%o!S8?YHrX}n zAst*%_4c#E%<|&l_BFigh`2*y4KLNuI#8XrP-UNIj}|- z+GY^#Ur{@p?@Iqg+>xeyGFD5qJ~vg*sO^m2k%mwU{R zOQnvS3k>FBEMVIFjc&&z%m^372|>+TjlruqtDIYj7Zdu~ahP7QKVQcY$TlHP7-Aw8 zk9!zKv8j%gDA*7)TwpG))^-N1?rt)*J>&n|o`-qrER3x%Oh4hqkjpgg?eYaB^_YA@ zt{RL+!jc-Hmh{-@zz~+kBFb@Dk;=o7%E+)Z&?of`49OCX$kA|_D#@n?qN=NE^kJy( zQFW5W_)2=Ha72~*BBNm}{dMYDDSCWhcz7V(FO3clMS5_D5(1g>F2J~@`m+KQ+UxOx zKo4ZXw3?KATHJbReAA=4;J$2=DaVv3Rk+Kd@H9y_KqB)%i@)6;YVF$B5ej+_v?w>} z(l;p#P{0&Epb(?*7=@z%E~7F*`PV6Q10>9P^3ZUPdQABno&Au)WP;+K0HQ_!Y57sA z!APtNrK$_1q-rFTs!;hDR^9LtM`{xD$D$>F z-je?ZOW}1}-V0^Zp7=vwU9g1~jiIkX1ZQjE!ZJk$CR#RQq*j@pS#qaxNX# zir%b@KOCGd2`)H07ww(j*fZ7qWXY1j^5n>w)7Ne5;|2AXYc6k!Z)jSudE-X!b+hAK z{|jUB@_h^D;36NK=Yz>C#&i_jPMVpl{qQF;LMOT#bwjmh>U{?EI`Sd@SKb_;b9N;8 zIDRf`Gc_8JPvGZvh`_IMh@t>UZZtl~7IH+~dCvfsF~-3M^L)_BBSy_6h3-6ZZbJdt z+Vlu=kg$6*d97(TT1W0SxsaW_KAQhvI8(JlX8jplzrsHIpU6-P2=>a=-MuVgdf5qa zjPWq%&{G?9G{bxU9~c^pAHaAVh8so!`KR}nD3knjR}uP{yt6BY{y_r1Uh^%8yqg}* zJ4EwEb8m@^rFgpRN1jW~3+B2-zAnz!X{F5$jPHw67tvZ7mdJvnCsuh|AGg2qZrI967o0jS2pHQ?neGb{z`2w@HrC-Ab!s5 z6~MTfi2@vd)t*gxF$xI$)e;H#TO0})__uhk9gJ(aC}85RIUb1l&)> z1Y9vwumHID)I}KuNrLO`>sLe~byW)4u=agzf7eH>unwq3nhq`+?tzoSA+50ceZ(R} z(4lEd9*v~mwe@87kXtAM``6mdL;WnvBGPfB(0+%i=;vI?pkO7jBke*lxGibFKT<1- zaOjM(k(heFE$XgT@?!6oQIdSp>(4KwYp;RKm8CpE)h|-GK%s;jz+2XpgG0hR6zT05 z3Wa-4$jYDTS{```w+LHc?L+M&oGBPK(l}7hy#Y_r=j)+MPgB*8DI|$BQbuBfU6xu{ zD2ESQ^d>|8GBTwdJ{}MXmjf|OKna6;BmM9t#F0^XRL07`$s5DP$V1*A-t@&?J4d#l zQqnSV&_a(#d7FZpyfRWD)I*{s?T;f7p0=#@WHTxw&z!9IVu;_KtU(Rr)06GOPLM=M zaO`*JASpbRhg{nBQ@=rt^uwgk3!vyZE>cEMQlV#<;D;koRiOu@(Do~|KMLKYLMu>q zlDhE%a~DwfG12F=qvOZeNjvI9OV+$3N68IqQBt4;?ux~hH0adGh~`gBIxQ_h2UFgZ x=-!loccwG$ES delta 1553 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 WZET7Dl&L!40e9vHM->^SZ~XyY>B}hq diff --git a/backend/app/api/__pycache__/team.cpython-312.pyc b/backend/app/api/__pycache__/team.cpython-312.pyc index b1b33b57b3ff88cb6b2dd8fc6f1bb19a365f38e2..5c8996d81a020dc2257bba9ab1a0178d7ea8eb48 100644 GIT binary patch delta 80 zcmZ3awor}tG%qg~0}yO3^~{>Nk=K=(kzsQPa|j=w(~Ob}DrOhu%^RdHGg?gECTPHD kIr+JuJ9p3p1)~GZ4bqnxT_!&i5}oWPq{!$xxj<+W0L6qE4FCWD delta 81 zcmZ3ewn&ZlG%qg~0}$l=cFdZ+k=K=(iBW%ZFmnhWpYsBi3##T96)YO0E;CwA-YRIo lXf^qnpgVW)1x4cn!VS`w8C@qo5E7m2E2PNiHaTBt69CiG86^M! diff --git a/backend/app/api/__pycache__/theme.cpython-312.pyc b/backend/app/api/__pycache__/theme.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..12ebd339134dcca6a0c9fefecb6eeedd9d5074c1 GIT binary patch literal 2324 zcmbW2TWl0n7=X{2+ukp|u++9}j#f=Kv|9`z8e%nTC=nqAwpHUMI$3AVwo`UzH)m$E zYz0y-(I)z$4^T8DO?-iXhG*Y+Gx4RS58zm$#2DmlX?YO!!T-$crC4~tlgycO`7d++ z^Zoz)5Qzj4wA-95_2NvbFnCu9K1EpXt=#J%Ls1(kHOOaf}oh!v?DVB?2Bp@nZZMw)hsnq4k8Q)+0BbYsC8{q z=o_(yQ+R$X>slbL=QQ_13PzDOD$-RO$phthj^Oee2zG94d`jxs9 z-h2ku4MI_PtS;#OKz}Zv2MP)gu7-B3NIM={60DvHMX-Y&If4Sq?h($zz&k?VF^4z{ zdGL{l(0TEK_^~XbljsDRLNBAwlp$m{*+J-e#D-2{8?wx6Hn;A!xmKpN7T~Q5C-7J3 zl5kCI(RT*x_#{4sYq*Avi|?Ttc0(u4f7=NiXLBB(3%a4pokjwINR4y^ShZ}yG<*f)M_+t_t{*;?t@ z{XiCD!3Gio!M__KNd2?13#^{OnE*Z)=|`XAKIsdoADtIqVF~x6F9R7x{5qMz;tedr z@P;D7&U|RBo%{6O_h12#Pr%E;s@BdUnEzi$@d^9{NQI|>RCo-e!YN@3q}u*6)95D5 z9!FW4WZa@xHjDMN2dBrj$D~PX#d;eV;K!}2F4|4920LVRg6GNYC2Si+i}mqHij45_ z0PhB&vmkqcizXQHj;>er*mJP`0+;LX5r1kvUb_D5^`4cUJzMx-yh2Wj&d^z9|FzQ2o;Rsy)jBT$UHZv{(MTpN?B9y$p)+ zJznb7669CWVqvbKFz?49LP|KIWiz*6x@qA^l`<^w2(TFM;=FSc$p0$Xs21sKY!ucw zlh$|8HAIZ@Pw3sdXz(r?Uq$1;qn*E?*KZ?oC(!-rz=G0Hkl_EM|8)Q2fmNJn2m($n z?OX0y)|UG&POc-EZiq$gnwrIgyXgX-3Hf74wVo77bFD87(It7c^kB gn*2l1lRM~wg7E?22I(&8Qms(3z;#xPi_+03IK(-7&QO@ diff --git a/backend/app/api/mobile_builds.py b/backend/app/api/mobile_builds.py index 0303263..3760de7 100644 --- a/backend/app/api/mobile_builds.py +++ b/backend/app/api/mobile_builds.py @@ -305,6 +305,25 @@ async def _upload_files_to_worker( raise RuntimeError(f"Failed to upload file {row.original_name}: {out}") +async def _upload_theme_to_worker( + session: AsyncSession, + request: MobileBuildRequest, + host: str, +) -> None: + """SCP theme.json to the build worker (no-op if file doesn't exist).""" + theme_path = Path(settings.THEME_JSON_PATH) + if not theme_path.exists(): + await _append_log(session, request, "[theme] theme.json not found — skipping upload") + return + + scp_prefix = _build_scp_prefix(host) + remote_target = f"{settings.MOBILE_BUILD_SSH_USER}@{host}:{settings.MOBILE_BUILD_REMOTE_APP_DIR}/theme.json" + code, out = await _run_command(scp_prefix + [str(theme_path), remote_target]) + if code != 0: + raise RuntimeError(f"Failed to upload theme.json: {out}") + await _append_log(session, request, "[theme] theme.json uploaded to worker") + + async def _run_platform_build( session: AsyncSession, request: MobileBuildRequest, @@ -317,6 +336,9 @@ async def _run_platform_build( await _append_log(session, request, f"[{platform}] Uploading {len(platform_files)} file(s) to worker") await _upload_files_to_worker(platform_files, host) + # Upload theme.json to worker + await _upload_theme_to_worker(session, request, host) + ssh_prefix = _build_ssh_prefix(host) cmd = ssh_prefix + [ ( diff --git a/backend/app/api/station_config.py b/backend/app/api/station_config.py index 1f0203a..1e13fb2 100644 --- a/backend/app/api/station_config.py +++ b/backend/app/api/station_config.py @@ -1,15 +1,20 @@ """Station config router: read/update the singleton station branding config.""" +import logging + from fastapi import APIRouter, Depends, HTTPException, status from app.auth import get_current_admin_user from app.database import get_session from app.models import StationConfig from app.schemas import StationConfigResponse, StationConfigUpdate +from app.theme_export import write_theme_json from app.user_models import User from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +logger = logging.getLogger(__name__) + router = APIRouter() @@ -47,4 +52,11 @@ async def update_station_config( await session.commit() await session.refresh(config) + + # Regenerate theme.json on disk (best-effort) + try: + await write_theme_json() + except Exception: + logger.warning("Failed to regenerate theme.json after config update", exc_info=True) + return config diff --git a/backend/app/api/theme.py b/backend/app/api/theme.py new file mode 100644 index 0000000..85a3bf0 --- /dev/null +++ b/backend/app/api/theme.py @@ -0,0 +1,52 @@ +"""Theme export: public JSON endpoint, status check, and admin regeneration trigger.""" + +from typing import Any, Dict + +from fastapi import APIRouter, Depends, HTTPException, status + +from app.auth import get_current_admin_user +from app.theme_export import get_theme_status, write_theme_json +from app.user_models import User + +router = APIRouter() + + +@router.get("/status") +async def theme_status() -> Dict[str, Any]: + """Return theme.json file status. Public endpoint.""" + return get_theme_status() + + +@router.get("") +async def get_theme() -> Dict[str, Any]: + """Return the current theme as a JSON dict. Public endpoint. + + Also regenerates the theme.json file on disk as a side effect, + ensuring the file is always up-to-date. + """ + try: + theme = await write_theme_json() + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to generate theme: {e}", + ) + return theme + + +@router.post("/regenerate", status_code=200) +async def regenerate_theme( + _: User = Depends(get_current_admin_user), +) -> Dict[str, Any]: + """Force regenerate the theme.json file. Admin only. + + Returns the newly generated theme dict. + """ + try: + theme = await write_theme_json() + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to regenerate theme: {e}", + ) + return theme diff --git a/backend/app/config.py b/backend/app/config.py index af675bf..01d6180 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -32,6 +32,10 @@ class Settings(BaseSettings): MOBILE_BUILD_ANDROID_ARTIFACT_GLOB: str = "~/mobile_app/build/app/outputs/bundle/release/*.aab" MOBILE_BUILD_IOS_ARTIFACT_GLOB: str = "~/mobile_app/build/ios/ipa/*.ipa" + # Theme export + WEBSITE_URL: str = "https://kmountainflower.org" + THEME_JSON_PATH: str = "/app/theme.json" + model_config = {"env_prefix": "KMTN_"} diff --git a/backend/app/main.py b/backend/app/main.py index d60179b..160ae8c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -10,7 +10,7 @@ from app.config import settings from app.database import async_session, engine from app.models import Base, Show, StationConfig, HistoryEntry, TeamMember, CommunityHighlight, Underwriter from app.user_models import User -from app.api import events, auth, station_config, history, team, community, shows, storage, underwriters, stats, mobile_builds +from app.api import events, auth, station_config, history, team, community, shows, storage, underwriters, stats, mobile_builds, theme def _cleanup_orphaned_sequences(sync_conn): @@ -153,6 +153,14 @@ async def lifespan(app: FastAPI): async with async_session() as session: await _ensure_station_config(session) + # Write theme.json on startup + try: + from app.theme_export import write_theme_json + await write_theme_json() + print(f" ✓ Theme JSON written to {settings.THEME_JSON_PATH}") + except Exception as e: + print(f" WARNING: Failed to write theme.json on startup: {e}") + # Bootstrap admin user if configured if settings.ADMIN_USERNAME and settings.ADMIN_PASSWORD: async with async_session() as session: @@ -224,6 +232,7 @@ app.include_router(storage.router, prefix="/api/storage", tags=["storage"]) app.include_router(underwriters.router, prefix="/api/underwriters", tags=["underwriters"]) app.include_router(stats.router, prefix="/api/stats", tags=["stats"]) app.include_router(mobile_builds.router, prefix="/api/mobile-builds", tags=["mobile-builds"]) +app.include_router(theme.router, prefix="/api/theme", tags=["theme"]) # Dev-only admin router (disabled in production) if settings.ENV != "production": diff --git a/backend/app/schemas.py b/backend/app/schemas.py index aef385d..ada361f 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -98,6 +98,10 @@ class StationConfigResponse(BaseModel): color_success: str color_danger: str color_info: str + color_white: str + color_light: str + color_medium: str + color_black: str model_config = {"from_attributes": True} @@ -145,6 +149,10 @@ class StationConfigUpdate(BaseModel): color_success: Optional[str] = None color_danger: Optional[str] = None color_info: Optional[str] = None + color_white: Optional[str] = None + color_light: Optional[str] = None + color_medium: Optional[str] = None + color_black: Optional[str] = None # ── HistoryEntry ────────────────────────────────────────── diff --git a/backend/app/theme_export.py b/backend/app/theme_export.py new file mode 100644 index 0000000..4c615fc --- /dev/null +++ b/backend/app/theme_export.py @@ -0,0 +1,134 @@ +"""Theme export: read StationConfig from the DB and dump a theme.json file. + +This module provides functions to: +- Build a clean, consumer-friendly JSON structure from StationConfig. +- Write it atomically to disk (for scp by build machines). +- Check whether the file exists and return its metadata. +""" + +import json +import logging +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict + +from app.config import settings +from app.database import async_session +from app.models import StationConfig +from sqlalchemy import select + +logger = logging.getLogger(__name__) + + +def resolve_image_url(base_url: str, relative_url: str) -> str | None: + """Convert a relative image URL to an absolute one. + + If the URL is already absolute (starts with http:// or https://), return as-is. + Returns None for empty strings. + """ + if not relative_url: + return None + if relative_url.startswith(("http://", "https://")): + return relative_url + path = relative_url.lstrip("/") + base = base_url.rstrip("/") + return f"{base}/{path}" + + +def build_theme_dict(config: StationConfig) -> Dict[str, Any]: + """Build the theme.json dict from a StationConfig ORM object.""" + base_url = settings.WEBSITE_URL + + images: Dict[str, str] = {} + for key, field in [ + ("logo", config.logo_url), + ("hero_background", config.hero_background_url), + ("hero_icon", config.hero_icon_url), + ("hero_divider", config.hero_divider_url), + ]: + resolved = resolve_image_url(base_url, field) + if resolved: + images[key] = resolved + + return { + "version": "1.0", + "generated_at": datetime.now(timezone.utc).isoformat(), + "station": { + "callsign": config.callsign, + "name_primary": config.name_primary, + "name_secondary": config.name_secondary, + "tagline": config.tagline, + "frequency": config.frequency, + }, + "colors": { + "primary": config.color_primary, + "primary_light": config.color_primary_light, + "primary_dark": config.color_primary_dark, + "primary_muted": config.color_primary_muted, + "accent": config.color_accent, + "accent_light": config.color_accent_light, + "accent_dark": config.color_accent_dark, + "accent_muted": config.color_accent_muted, + "background": config.color_background, + "text": config.color_text, + "success": config.color_success, + "danger": config.color_danger, + "info": config.color_info, + "white": config.color_white, + "light": config.color_light, + "medium": config.color_medium, + "black": config.color_black, + }, + "images": images, + } + + +async def write_theme_json() -> Dict[str, Any]: + """Read the current station config and write theme.json to disk. + + Uses atomic write (write to .tmp, then os.replace) to prevent + reading a partially-written file. + + Returns the theme dict that was written. + """ + async with async_session() as session: + result = await session.execute( + select(StationConfig).where(StationConfig.key == "default") + ) + config = result.scalar_one_or_none() + if config is None: + raise RuntimeError("Station config not found in database") + + theme = build_theme_dict(config) + + # Atomic write + path = Path(settings.THEME_JSON_PATH) + tmp_path = path.with_suffix(".tmp") + + path.parent.mkdir(parents=True, exist_ok=True) + + tmp_path.write_text(json.dumps(theme, indent=2) + "\n", encoding="utf-8") + tmp_path.replace(path) + + return theme + + +def get_theme_status() -> Dict[str, Any]: + """Check if theme.json exists on disk and return its metadata.""" + path = Path(settings.THEME_JSON_PATH) + if not path.exists(): + return {"exists": False, "path": str(path)} + + stat = path.stat() + try: + data = json.loads(path.read_text(encoding="utf-8")) + generated_at = data.get("generated_at") + except Exception: + generated_at = None + + return { + "exists": True, + "path": str(path), + "generated_at": generated_at, + "size": stat.st_size, + } diff --git a/src/app/admin/admin.component.html b/src/app/admin/admin.component.html index 863c49d..a28bd46 100644 --- a/src/app/admin/admin.component.html +++ b/src/app/admin/admin.component.html @@ -22,7 +22,7 @@ + (click)="activeTab.set('mobile-builds'); loadThemeJsonStatus()">Mobile Builds @@ -437,6 +437,33 @@ } +
+

Theme Export

+ @if (themeJsonStatus$ | async; as status) { + @if (status.exists) { +
+
theme.json — ready
+
Path: {{ status.path }}
+
Generated: {{ status.generated_at }}
+
Size: {{ status.size }} bytes
+
+ } @else { +
+

theme.json not found at {{ status.path }}

+

Regenerate to create it from the current station config.

+
+ } + + } @else { + @if (themeJsonLoading$ | async) { +

Loading...

+ } + } +
+

Create Build Request

diff --git a/src/app/admin/admin.component.scss b/src/app/admin/admin.component.scss index 02f85e4..7e94c0c 100644 --- a/src/app/admin/admin.component.scss +++ b/src/app/admin/admin.component.scss @@ -539,6 +539,60 @@ font-size: 0.8rem; } +// ── Theme export status ────────────────────────────────── + +.theme-status-ok, +.theme-status-missing { + padding: $spacing-sm $spacing-md; + border-radius: $radius-sm; + margin-bottom: $spacing-sm; + font-size: 0.88rem; + color: $neutral-dark; +} + +.theme-status-ok { + background: rgba($success-green, 0.08); + border: 1px solid rgba($success-green, 0.25); + display: grid; + gap: 2px; +} + +.theme-status-missing p { + margin: 0 0 $spacing-xs; + + &:last-child { + margin-bottom: 0; + } +} + +.theme-status-missing { + background: rgba($accent-orange, 0.08); + border: 1px solid rgba($accent-orange, 0.25); +} + +.btn-outline { + background: transparent; + border: 2px solid $primary-blue; + color: $primary-blue; + border-radius: $radius-md; + padding: $spacing-xs $spacing-md; + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + transition: background $transition-fast, color $transition-fast; + margin-top: $spacing-sm; + + &:hover:not(:disabled) { + background: $primary-blue; + color: $neutral-white; + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } +} + @include responsive(md) { .mobile-build-grid { grid-template-columns: 1fr; diff --git a/src/app/admin/admin.component.ts b/src/app/admin/admin.component.ts index 33f15ba..af01df3 100644 --- a/src/app/admin/admin.component.ts +++ b/src/app/admin/admin.component.ts @@ -13,6 +13,7 @@ import { Underwriter } from '../interfaces/underwriter'; import { MobileBuildDefaults, MobileBuildRequest, + ThemeStatus, } from '../interfaces/mobile-build'; import { ShowService } from '../services/show.service'; import { EventService } from '../services/event.service'; @@ -126,6 +127,10 @@ export class AdminComponent implements OnInit { mobileBuildPreflightIssues: string[] = []; mobileBuildBusy = false; + // ── Theme JSON status (mobile build tab) ──────────────────── + readonly themeJsonStatus$ = new BehaviorSubject(null); + readonly themeJsonLoading$ = new BehaviorSubject(false); + buildAndroid = true; buildIos = true; androidApplicationId = ''; @@ -412,6 +417,36 @@ export class AdminComponent implements OnInit { this.onMobileBuildSelected(this.selectedMobileBuildId); } + // ── Theme JSON status ────────────────────────────────────── + + async loadThemeJsonStatus(): Promise { + try { + this.themeJsonLoading$.next(true); + this.themeJsonStatus$.next(await firstValueFrom( + this.mobileBuildService.getThemeStatus(), + )); + } catch { + this.themeJsonStatus$.next(null); + } finally { + this.themeJsonLoading$.next(false); + } + } + + async regenerateThemeJson(): Promise { + try { + this.themeJsonLoading$.next(true); + await firstValueFrom(this.mobileBuildService.regenerateTheme()); + await this.loadThemeJsonStatus(); + } catch (err) { + this.mobileBuildErrorMessage = this.getErrorMessage( + err, + 'Failed to regenerate theme.json.', + ); + } finally { + this.themeJsonLoading$.next(false); + } + } + getArtifactDownloadHref(path: string): string { return `${getAppConfig().apiBaseUrl}${path}`; } diff --git a/src/app/interfaces/mobile-build.ts b/src/app/interfaces/mobile-build.ts index 52d6f3a..1b0cb08 100644 --- a/src/app/interfaces/mobile-build.ts +++ b/src/app/interfaces/mobile-build.ts @@ -67,3 +67,10 @@ export interface MobileBuildPreflightResult { passed: boolean; issues: string[]; } + +export interface ThemeStatus { + exists: boolean; + path: string; + generated_at: string | null; + size: number | null; +} diff --git a/src/app/services/mobile-build.service.ts b/src/app/services/mobile-build.service.ts index 1b4aede..dd790a1 100644 --- a/src/app/services/mobile-build.service.ts +++ b/src/app/services/mobile-build.service.ts @@ -9,6 +9,7 @@ import { MobileBuildPreflightResult, MobileBuildRequest, MobileBuildUploadedFile, + ThemeStatus, } from '../interfaces/mobile-build'; @Injectable({ @@ -66,4 +67,17 @@ export class MobileBuildService { {}, ); } + + getThemeStatus(): Observable { + return this.http.get( + `${getAppConfig().apiBaseUrl}/api/theme/status`, + ); + } + + regenerateTheme(): Observable { + return this.http.post( + `${getAppConfig().apiBaseUrl}/api/theme/regenerate`, + {}, + ); + } } diff --git a/theme.json b/theme.json new file mode 100644 index 0000000..d35955f --- /dev/null +++ b/theme.json @@ -0,0 +1,35 @@ +{ + "version": "1.0", + "generated_at": "2026-07-06T05:59:35.748956+00:00", + "station": { + "callsign": "STAYLIT", + "name_primary": "Stay Lit", + "name_secondary": "Radio", + "tagline": "The Number 1 Underground Station", + "frequency": "" + }, + "colors": { + "primary": "#000000", + "primary_light": "#757575", + "primary_dark": "#0d0d0d", + "primary_muted": "#a8a8a8", + "accent": "#fe9b2a", + "accent_light": "#fecb8f", + "accent_dark": "#c16701", + "accent_muted": "#e99a3f", + "background": "#faf6f0", + "text": "#3a3632", + "success": "#9c7649", + "danger": "#c98231", + "info": "#bf813b", + "white": "#ffffff", + "light": "#f0ebe3", + "medium": "#8a8580", + "black": "#1a1816" + }, + "images": { + "logo": "https://kmountainflower.org/api/storage/2aaf76bb75dd4b4f9f743e397b0e1f59", + "hero_background": "https://kmountainflower.org/api/storage/89c2e01fb56141f98bc618dbe009b7d9", + "hero_icon": "https://kmountainflower.org/api/storage/bc60ea468348485b84c548401ec3457e" + } +}