feat: admin articles grouped view, live stats, + bug fixes
Admin articles list: - Group EN/DE language variants into a single row (grouped by url_path) - Language chips (● EN/● DE) coloured by status: green=live, amber=scheduled, blue=draft - Inline View ↗ (live only) and Edit buttons per variant — one-click access - Filter by language switches back to flat single-row view - Live HTMX polling of article counts while generation runs (every 3s, self-terminates) - Table overflow fix: card gets overflow:hidden, table wrapped in overflow-x:auto scroll div Bug fixes: - X-Forwarded-Proto: pass $http_x_forwarded_proto through Nginx so Quart sees https - pipeline_routes.py: fix relative import for analytics module (from .analytics → from ..analytics) - Scheduled articles: redirect to parent path instead of 404 when not yet published - city-cost-de: change priority_column from population to padel_venue_count - Quote wizard step 4: make location_status required - Article generation: use COUNT(*) instead of 501-sentinel hack for row counts - Makefile: pin Tailwind v4.1.18, add dev/help targets, uv run python, .PHONY Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -58,7 +58,7 @@ NTFY_TOKEN=
|
|||||||
#ENC[AES256_GCM,data:BCyQYjRnTx8yW9A=,iv:4OPCP+xzRLUJrpoFewVnbZRKnZH4sAbV76SM//2k5wU=,tag:HxwEp7VFVZUN/VjPiL/+Vw==,type:comment]
|
#ENC[AES256_GCM,data:BCyQYjRnTx8yW9A=,iv:4OPCP+xzRLUJrpoFewVnbZRKnZH4sAbV76SM//2k5wU=,tag:HxwEp7VFVZUN/VjPiL/+Vw==,type:comment]
|
||||||
PROXY_URLS=ENC[AES256_GCM,data:CzRaK0piUQfvuYYsdz0i2MEQIphKi0BhNvHw9alo46aTH+kqEKvoS7dKEKzyU9VJ4TyNweInlVMxB962DsvRoBtnHwo/pUmYtVeEr2881clNgEiZVYRDFRdEbpULcLPDJa3ey1leqAAHlmiL0RQ6Qa57gPCOVBzVG6npGLKO+K8XVIb+BZMs9kEUOlw7iuqTJW5xPN/t4X/jHidEqfTSAl9b4vU4bsYVuY3yQrL+/V5QpTbyXlf+cMq3flpA3zE2Fxhalzg+c/wHMTrCksFwrCkrInW0kY9yPkA7usUWr1xwwaV3wIDoNQsLXpMd/3RztipNvKtOMRhRJOmjzP7BKhCJvvvKTV5p+mBCulFijbMQgArg3BqcFanfw3YZ4wPd4hp8q/vOhE/U9Wu0yrMmyWYFHYGQnFVARlBH7pwn/ez8W4KqRFveEAuev9CE7K7s5RqzPLelSkoa9UuiiULJ+t0LFgKlgxuLtQ8GdFdgsmBCxY/4U/xzvNdC82hD549z5nMWWlaUJm4onPWirT/RYm7j3v6z4mmNImI2W6rCNbvEvsXwWsciquVaBIgReA47p6/GTzZ9VZMyGr4PdzB87BJGAgX1W57WNdPAsRIF49XP2BU72RtRFxsUG8Ha2dc=,iv:a10Vpk7Zv8QqORuEcMlpcvtHO/zjBLaFphWPYBXwysc=,tag:8N66/R+CLqEZ45wj+tCt6w==,type:str]
|
PROXY_URLS=ENC[AES256_GCM,data:CzRaK0piUQfvuYYsdz0i2MEQIphKi0BhNvHw9alo46aTH+kqEKvoS7dKEKzyU9VJ4TyNweInlVMxB962DsvRoBtnHwo/pUmYtVeEr2881clNgEiZVYRDFRdEbpULcLPDJa3ey1leqAAHlmiL0RQ6Qa57gPCOVBzVG6npGLKO+K8XVIb+BZMs9kEUOlw7iuqTJW5xPN/t4X/jHidEqfTSAl9b4vU4bsYVuY3yQrL+/V5QpTbyXlf+cMq3flpA3zE2Fxhalzg+c/wHMTrCksFwrCkrInW0kY9yPkA7usUWr1xwwaV3wIDoNQsLXpMd/3RztipNvKtOMRhRJOmjzP7BKhCJvvvKTV5p+mBCulFijbMQgArg3BqcFanfw3YZ4wPd4hp8q/vOhE/U9Wu0yrMmyWYFHYGQnFVARlBH7pwn/ez8W4KqRFveEAuev9CE7K7s5RqzPLelSkoa9UuiiULJ+t0LFgKlgxuLtQ8GdFdgsmBCxY/4U/xzvNdC82hD549z5nMWWlaUJm4onPWirT/RYm7j3v6z4mmNImI2W6rCNbvEvsXwWsciquVaBIgReA47p6/GTzZ9VZMyGr4PdzB87BJGAgX1W57WNdPAsRIF49XP2BU72RtRFxsUG8Ha2dc=,iv:a10Vpk7Zv8QqORuEcMlpcvtHO/zjBLaFphWPYBXwysc=,tag:8N66/R+CLqEZ45wj+tCt6w==,type:str]
|
||||||
RECHECK_WINDOW_MINUTES=ENC[AES256_GCM,data:YWM=,iv:iY5+uMazLAFdwyLT7Gr7MaF1QHBIgHuoi6nF2VbSsOA=,tag:dc6AmuJdTQ55gVe16uzs6A==,type:str]
|
RECHECK_WINDOW_MINUTES=ENC[AES256_GCM,data:YWM=,iv:iY5+uMazLAFdwyLT7Gr7MaF1QHBIgHuoi6nF2VbSsOA=,tag:dc6AmuJdTQ55gVe16uzs6A==,type:str]
|
||||||
PROXY_URLS_FALLBACK=
|
PROXY_URLS_FALLBACK=ENC[AES256_GCM,data:95rwI7kKUj1YxLpjChtrM4f2EFUDzQdAg1e1MOHnLwQ9ZY54UNH7v4JcqTsvDk9D+0N/BIdwFSDi7pnCSd6BWFV+cQ==,iv:rm9HdBsibSne7JR6vWl+ao/GHb1rbuVdZZDUWhVbTnE=,tag:NJ2STxmFZPvFayfTrEEYbg==,type:str]
|
||||||
CIRCUIT_BREAKER_THRESHOLD=
|
CIRCUIT_BREAKER_THRESHOLD=
|
||||||
#ENC[AES256_GCM,data:ZcX/OEbrMfKizIQYq3CYGnvzeTEX7KsmQaz2+Jj1rG5tbTy2aljQBIEkjtiwuo8NsNAD+FhIGRGVfBmKe1CAKME1MuiCbgSG,iv:4BSkeD3jZFawP09qECcqyuiWcDnCNSgbIjBATYhazq4=,tag:Ep1d2Uk700MOlWcLWaQ/ig==,type:comment]
|
#ENC[AES256_GCM,data:ZcX/OEbrMfKizIQYq3CYGnvzeTEX7KsmQaz2+Jj1rG5tbTy2aljQBIEkjtiwuo8NsNAD+FhIGRGVfBmKe1CAKME1MuiCbgSG,iv:4BSkeD3jZFawP09qECcqyuiWcDnCNSgbIjBATYhazq4=,tag:Ep1d2Uk700MOlWcLWaQ/ig==,type:comment]
|
||||||
GSC_SERVICE_ACCOUNT_PATH=
|
GSC_SERVICE_ACCOUNT_PATH=
|
||||||
@@ -67,10 +67,10 @@ BING_WEBMASTER_API_KEY=
|
|||||||
BING_SITE_URL=
|
BING_SITE_URL=
|
||||||
#ENC[AES256_GCM,data:ECsuDMQipS6YmFpSm1vqCsR2fUW2zN1Mg9VcUlw0roM=,iv:j+F6Akx2bklGMkFTux230YcZjMibA+Qp+qvgkGXl4Jw=,tag:7aO0wbmP/qB73wLgtiSJ2w==,type:comment]
|
#ENC[AES256_GCM,data:ECsuDMQipS6YmFpSm1vqCsR2fUW2zN1Mg9VcUlw0roM=,iv:j+F6Akx2bklGMkFTux230YcZjMibA+Qp+qvgkGXl4Jw=,tag:7aO0wbmP/qB73wLgtiSJ2w==,type:comment]
|
||||||
GEONAMES_USERNAME=ENC[AES256_GCM,data:aSkVdLNrhiF6tlg=,iv:eemFGwDIv3EG/P3lVHGZj96MieIsr85e4xYmEIpZyfM=,tag:McpZMNOIO3FDkSebae2gOQ==,type:str]
|
GEONAMES_USERNAME=ENC[AES256_GCM,data:aSkVdLNrhiF6tlg=,iv:eemFGwDIv3EG/P3lVHGZj96MieIsr85e4xYmEIpZyfM=,tag:McpZMNOIO3FDkSebae2gOQ==,type:str]
|
||||||
CENSUS_API_KEY=
|
CENSUS_API_KEY=ENC[AES256_GCM,data:qqG971573aGq9MiHI2xLlanKKFwjfcNNoMXtm8LNbyh0rMbQN2XukQ==,iv:az2i0ldH75nHGah4DeOxaXmDbVYqmC1c77ptZqFA9BI=,tag:zoDdKj9bR7fgIDo1/dEU2g==,type:str]
|
||||||
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBxNWNmUzVNUGdWRnE0ZFpF\nM0JQZWZ3UDdEVzlwTmIxakxOZXBkT2x2ZlNrClRtV2M3S2daSGxUZmFDSWQ2Nmh4\neU51QndFcUxlSE00RFovOVJTcDZmUUUKLS0tIDcvL3hRMDRoMWZZSXljNzA3WG5o\nMWFic21MV0krMzlIaldBTVU0ZDdlTE0K7euGQtA+9lHNws+x7TMCArZamm9att96\nL8cXoUDWe5fNI5+M1bXReqVfNwPTwZsV6j/+ZtYKybklIzWz02Ex4A==\n-----END AGE ENCRYPTED FILE-----\n
|
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBxNWNmUzVNUGdWRnE0ZFpF\nM0JQZWZ3UDdEVzlwTmIxakxOZXBkT2x2ZlNrClRtV2M3S2daSGxUZmFDSWQ2Nmh4\neU51QndFcUxlSE00RFovOVJTcDZmUUUKLS0tIDcvL3hRMDRoMWZZSXljNzA3WG5o\nMWFic21MV0krMzlIaldBTVU0ZDdlTE0K7euGQtA+9lHNws+x7TMCArZamm9att96\nL8cXoUDWe5fNI5+M1bXReqVfNwPTwZsV6j/+ZtYKybklIzWz02Ex4A==\n-----END AGE ENCRYPTED FILE-----\n
|
||||||
sops_age__list_0__map_recipient=age1f5002gj4s78jju45jd28kuejtcfhn5cdujz885fl7z2p9ym68pnsgky87a
|
sops_age__list_0__map_recipient=age1f5002gj4s78jju45jd28kuejtcfhn5cdujz885fl7z2p9ym68pnsgky87a
|
||||||
sops_lastmodified=2026-02-24T21:48:18Z
|
sops_lastmodified=2026-02-26T14:31:14Z
|
||||||
sops_mac=ENC[AES256_GCM,data:RmSB5aS5Avl1jzeSmZPdDS6u+QPKDVD/1A55slXXdht96Knbh7IjaRsqggql9uixQO0/6WWkXsxhcKDWhsbYb0el2ATrLWXHaV6GQqfLq7RUynagcGTNHj8ipizQ93MqaDlXnI92ZOEHNcgvJzRuvRLJYhMErSyzwbUxtbaGMNM=,iv:o5wY+9uurzsTOMgmblGi0xcyYMsYGMfICmt4dSBlt2w=,tag:UKhqs3pedmvP/HjGJb0y4Q==,type:str]
|
sops_mac=ENC[AES256_GCM,data:iqFuTexTS9U/Nv8xoTpHljTNQTGX9ITcJ3AjhDEtxrh0Z9/lngfBvGtjiKmpwFGlobQw/x+/YLM+u3MhciwXF7qNwFfJ/StN2Y66uF71SxWotbL70Dxl4oWSVL3sU+2NYbw5yP0p+xCbE+rEd5SqAe6K5yyq5X25hz8fIapxlYA=,iv:foqoWQVMipuOAQ0Kp799PaIhCIrxV8T5cC811wIzxR8=,tag:yNfxSV3R21XEXksjmdsKBw==,type:str]
|
||||||
sops_unencrypted_suffix=_unencrypted
|
sops_unencrypted_suffix=_unencrypted
|
||||||
sops_version=3.12.1
|
sops_version=3.12.1
|
||||||
|
|||||||
@@ -11,27 +11,27 @@ DATABASE_PATH=ENC[AES256_GCM,data:qxQs7dG0RWMA1rs=,iv:5ZUyk02hCPQESr2vFz3mfnUhUF
|
|||||||
MAGIC_LINK_EXPIRY_MINUTES=ENC[AES256_GCM,data:YSE=,iv:GYm1EWku7+OG+fCIbUHWsfYbnEQVNhlBmWBC1OCV1NA=,tag:L2kdm7tMWOO/cf+VDd+OdQ==,type:str]
|
MAGIC_LINK_EXPIRY_MINUTES=ENC[AES256_GCM,data:YSE=,iv:GYm1EWku7+OG+fCIbUHWsfYbnEQVNhlBmWBC1OCV1NA=,tag:L2kdm7tMWOO/cf+VDd+OdQ==,type:str]
|
||||||
SESSION_LIFETIME_DAYS=ENC[AES256_GCM,data:9Og=,iv:3nStZVZVB24aAtNrtLXZ0oIehTDyu2IzdXoMH59t+3o=,tag:+FQ4n1XeSS12zUGXt/1RKQ==,type:str]
|
SESSION_LIFETIME_DAYS=ENC[AES256_GCM,data:9Og=,iv:3nStZVZVB24aAtNrtLXZ0oIehTDyu2IzdXoMH59t+3o=,tag:+FQ4n1XeSS12zUGXt/1RKQ==,type:str]
|
||||||
#ENC[AES256_GCM,data:mtqp/c5zZxlcB4HrOrfi,iv:eJaN+ZnAIaNHF5iovcz0QynILq9GjqVcwoyN2ZhLmpI=,tag:WyXU7ho5T/CE609id9dOzA==,type:comment]
|
#ENC[AES256_GCM,data:mtqp/c5zZxlcB4HrOrfi,iv:eJaN+ZnAIaNHF5iovcz0QynILq9GjqVcwoyN2ZhLmpI=,tag:WyXU7ho5T/CE609id9dOzA==,type:comment]
|
||||||
RESEND_API_KEY=ENC[AES256_GCM,data:U5aEnItbJ/Af,iv:7BTFimeMbPtK6ANXMr7VwO5TJ7IaRk+HAOZy+TEXMVI=,tag:sDhW5icVloSck1iafu3H0A==,type:str]
|
RESEND_API_KEY=ENC[AES256_GCM,data:K7Uvy98abZ2gohUWjKjz/F/C9HhBsxcHTTPqiQkeuEytxfrD,iv:Bhj5zd6OJc97W9WtQwGr6znoHD9IfQ5gDsVytzb96Kw=,tag:N6LqCiHJBS0r5nOg2mUxsg==,type:str]
|
||||||
EMAIL_FROM=ENC[AES256_GCM,data:BTGeWUjG9qCBvRQr9kK5sfdzQ1CfuNgpkU/AL3Qu6GJ2ng==,iv:0XjqD8hCqleSJR2FrDajlnUul8o4GkK0f1MOP96MRkw=,tag:0PwZwxuBbUFYdiRYTlDffg==,type:str]
|
EMAIL_FROM=ENC[AES256_GCM,data:BTGeWUjG9qCBvRQr9kK5sfdzQ1CfuNgpkU/AL3Qu6GJ2ng==,iv:0XjqD8hCqleSJR2FrDajlnUul8o4GkK0f1MOP96MRkw=,tag:0PwZwxuBbUFYdiRYTlDffg==,type:str]
|
||||||
LEADS_EMAIL=ENC[AES256_GCM,data:jkpWqodUgR2QoB96zvE5aH/tA9Sh0nPcl75P3i43ecFILw==,iv:vNtB/9gdrTDm6vNIjnH6JShYyqmG7h9jd2uzwFwjhO8=,tag:cG5T3CwQfZO/jTYFnwJSgA==,type:str]
|
LEADS_EMAIL=ENC[AES256_GCM,data:jkpWqodUgR2QoB96zvE5aH/tA9Sh0nPcl75P3i43ecFILw==,iv:vNtB/9gdrTDm6vNIjnH6JShYyqmG7h9jd2uzwFwjhO8=,tag:cG5T3CwQfZO/jTYFnwJSgA==,type:str]
|
||||||
RESEND_WEBHOOK_SECRET=ENC[AES256_GCM,data:EQpvkWFyt8H7,iv:6QiZIDo5Ps39vf9MKkiqSJir7BH9zhoLREJ425y3FIs=,tag:kjO4dczb2E5FKfO6OVaQvw==,type:str]
|
RESEND_WEBHOOK_SECRET=ENC[AES256_GCM,data:Fs9UQ4NINYPsEgkGWepWaCr3ini5zXnBNCoe/Tt0DXeo0ZMgtfI=,iv:Z4sIaE7Ixcbeg00asGALVcTtfLloduHTR5vRbC42dwc=,tag:XHlBmYDFnjHgjKEouwC4VQ==,type:str]
|
||||||
#ENC[AES256_GCM,data:HW8JOkd7Hw==,iv:Qfwm2ZHT8TKANrLrRQqHnceQVUTiuzT2hSjLN8hSq5Q=,tag:hvVLmGGUBRlsm2qy9jxIvA==,type:comment]
|
#ENC[AES256_GCM,data:HW8JOkd7Hw==,iv:Qfwm2ZHT8TKANrLrRQqHnceQVUTiuzT2hSjLN8hSq5Q=,tag:hvVLmGGUBRlsm2qy9jxIvA==,type:comment]
|
||||||
PADDLE_API_KEY=ENC[AES256_GCM,data:d3rKjWFrFepp,iv:TGjG9VTC4pZFgnp5daE+jBrRCUJddqgRaV7rQ61llhU=,tag:KKaYPfUgLC58zhC8s3B4cQ==,type:str]
|
PADDLE_API_KEY=ENC[AES256_GCM,data:KyjFIvxOcKTKbnnLBwGTZSfZmvrYDZO7b3pSXrUdyfYu,iv:rlkyBA6fUvH95B0e+i/HGpvAKnb6ZNWRJ218CZeAqPc=,tag:eQC2ikQS0fzzGKC5Ia15mw==,type:str]
|
||||||
PADDLE_CLIENT_TOKEN=ENC[AES256_GCM,data:JPmeLZx16WuV,iv:52EczBQM+fvEQuzoY8Aon0RBZiLzf1vrbT9Kx+b/WUE=,tag:+abUTzCgxulamobp13PbWQ==,type:str]
|
PADDLE_CLIENT_TOKEN=ENC[AES256_GCM,data:nBShxoBK0OzCVlyv0EOmls61YR4XTV9covjz49ZVuwQnj84JxVXoCRvIT+ZgLrTDkSfUHJ9ayM4U5pMZQlEPWyEvq+rS,iv:BJ/l+sGSMjLzhCkoVX3we6g1WafcmAJJruckz0JLhXg=,tag:nyW+WsXVJzEruDDrT0PnFg==,type:str]
|
||||||
PADDLE_WEBHOOK_SECRET=ENC[AES256_GCM,data:fk2PbtpwoGRB,iv:QOhOd4rKmVjMA1EUQUjSj/y/OM7I435K/s4aqShjQNw=,tag:RIfbUCXAQGmCiE9FODHgpA==,type:str]
|
PADDLE_WEBHOOK_SECRET=ENC[AES256_GCM,data:iMu0YHlzWH9Csx7feu2HMf63t6lC9ScCCTXesKOKRjfFMLvgYS/SrIVRy2Vj/h8RxwTsbXD74eyv1N3Enk9AI+rD6rOaYw==,iv:0TCypcz27+f0xu5iKqv3ziUJpSmelBVRV0l34xcrP20=,tag:uJKyBaPkNIGiix0FbwBY6Q==,type:str]
|
||||||
PADDLE_NOTIFICATION_SETTING_ID=ENC[AES256_GCM,data:igRsm8JOO1SP,iv:vQgOZcMHt6YoE+U2d6tT8sILOwsTx3glHVBBatR6Sk8=,tag:1tApDyZmZNiwd3bVm0uZGw==,type:str]
|
PADDLE_NOTIFICATION_SETTING_ID=ENC[AES256_GCM,data:hhmwW691SoiKxcgMIUUjyBtg5ntT1WPff5r4DmXqnzYY,iv:9beXEidjNOljigP1dMEZHKvJpyRT1JMNNAzFPUvWw0o=,tag:uOvr4WAlqIoxEX7hQTr4Sg==,type:str]
|
||||||
PADDLE_ENVIRONMENT=ENC[AES256_GCM,data:A1qXlv+9hjdIug==,iv:nu9kRQZgGLFXXT2I5GaRzp13YgQxU2ucr9azEA4XTUQ=,tag:RBxwE2j9v/RCiEMIa+6ICw==,type:str]
|
PADDLE_ENVIRONMENT=ENC[AES256_GCM,data:A1qXlv+9hjdIug==,iv:nu9kRQZgGLFXXT2I5GaRzp13YgQxU2ucr9azEA4XTUQ=,tag:RBxwE2j9v/RCiEMIa+6ICw==,type:str]
|
||||||
#ENC[AES256_GCM,data:F3dSfSGV,iv:Zjzmp9Vb+LBkqV6xBIMF2cK8ON9crH3fHcOog4+LOpo=,tag:7V8E9ChwYY9ceTaYdg3Lbw==,type:comment]
|
#ENC[AES256_GCM,data:F3dSfSGV,iv:Zjzmp9Vb+LBkqV6xBIMF2cK8ON9crH3fHcOog4+LOpo=,tag:7V8E9ChwYY9ceTaYdg3Lbw==,type:comment]
|
||||||
UMAMI_API_URL=ENC[AES256_GCM,data:4nJZc/opX4rsqAxO6XxD1Es5ySMh7nUtcGt6Kg==,iv:DcmhRe1IJKS0tOFgdJQQv2A1kO5K8VVT8aW0Vq5hVlY=,tag:Sglu4nnAiLIzr+ovJ/hEKQ==,type:str]
|
UMAMI_API_URL=ENC[AES256_GCM,data:4nJZc/opX4rsqAxO6XxD1Es5ySMh7nUtcGt6Kg==,iv:DcmhRe1IJKS0tOFgdJQQv2A1kO5K8VVT8aW0Vq5hVlY=,tag:Sglu4nnAiLIzr+ovJ/hEKQ==,type:str]
|
||||||
UMAMI_API_TOKEN=ENC[AES256_GCM,data:Xv1eTWtiJ6PL,iv:9sYsI2dJaQt6gpC/ev0b2dSk48PzuojTg18xXnBSWvk=,tag:DAMDHk0b9IG7T9MpkpzAkQ==,type:str]
|
UMAMI_API_TOKEN=ENC[AES256_GCM,data:aCO4SNhNXb8cDW9j38c4RW5798wU4W8f3LCqAFZIptptU+tYF8myu4NRH3d7ZItVacBUi8305y4YbublzuflDMvZ24in+MQpS681bG71gWkao8rK6N8AyTTPTUod3kXC3EMgFeO3T0xxzGN1u09u3JmdWigS8usydfoWwlDI4r82IZ19XtrcdXaj21r41uArmgknyWBXH/hvIfmaRHba4YtYzk1uXi3/7qo6T+wGBXIzc+uo9BGLKCInYUrwqCtqM1/6jN5/zV6kZUb0rm4BoQqzsPCF6fuTOLSw+ARVUWx/ol9hW6WFyPhJq8FRbHbBRM+juvz7cL6RCwSkms16OfT77MN+iHcKnQMp9ADvGTiXGwn/3xKfDr33G+Ne8XH7R+IsCLwjk+gHsUKDNKX39AMj5hI3sPMXWV6s3aTbdIAH/a3mgpB1CpzMnZsmsgV/RN9eKXvxduwatznY/YS+f9Uyci/0EH/1Yq+MMiLz32PL0KLZPmjQOS0psbc=,iv:WkYDIVbjvfIKJqgDc2kHtiq0N9/H4A2pShqjyI/xsUI=,tag:SzNYioXrLJ8nPNgedLGnJg==,type:str]
|
||||||
#ENC[AES256_GCM,data:wAePRqqMZL2oCJB812A=,iv:jaLmjd0GW2dnEQ3KgWcvAs7Q7aDwlCexM9W7pH27kss=,tag:h7/yIdc13+3pmqyCc0OPkg==,type:comment]
|
#ENC[AES256_GCM,data:wAePRqqMZL2oCJB812A=,iv:jaLmjd0GW2dnEQ3KgWcvAs7Q7aDwlCexM9W7pH27kss=,tag:h7/yIdc13+3pmqyCc0OPkg==,type:comment]
|
||||||
RATE_LIMIT_REQUESTS=ENC[AES256_GCM,data:W3Nt,iv:ycMAxrPq44S6qezQIa50rc7GDplo1YvAO6VUERGQUxA=,tag:uzendLuSVbmSPcVPEgLiqQ==,type:str]
|
RATE_LIMIT_REQUESTS=ENC[AES256_GCM,data:W3Nt,iv:ycMAxrPq44S6qezQIa50rc7GDplo1YvAO6VUERGQUxA=,tag:uzendLuSVbmSPcVPEgLiqQ==,type:str]
|
||||||
RATE_LIMIT_WINDOW=ENC[AES256_GCM,data:r8o=,iv:m5uKo3N8mb7FWI70SgaaHSyC3CNeD8XxjEx8ENit9uI=,tag:gKXEXsIwtBr3sm7xqLRHIw==,type:str]
|
RATE_LIMIT_WINDOW=ENC[AES256_GCM,data:r8o=,iv:m5uKo3N8mb7FWI70SgaaHSyC3CNeD8XxjEx8ENit9uI=,tag:gKXEXsIwtBr3sm7xqLRHIw==,type:str]
|
||||||
#ENC[AES256_GCM,data:E6JgKjxuqFdPtVEv6Xiz1kqcT4ar,iv:hL7P7/X7nEqFwnlf72QEeHhViQ17HZbsCP/M4gcTJiA=,tag:FjCPSvrBboCWjfIS/fab0A==,type:comment]
|
#ENC[AES256_GCM,data:E6JgKjxuqFdPtVEv6Xiz1kqcT4ar,iv:hL7P7/X7nEqFwnlf72QEeHhViQ17HZbsCP/M4gcTJiA=,tag:FjCPSvrBboCWjfIS/fab0A==,type:comment]
|
||||||
LITESTREAM_R2_BUCKET=ENC[AES256_GCM,data:opg8kQY3PKnZ,iv:lPHUBDwHgBulOyt9WWgZhBQae8t2WKYvLHSFQrG3N/w=,tag:qtyIz4fbh40aLp7ZawBJiA==,type:str]
|
LITESTREAM_R2_BUCKET=ENC[AES256_GCM,data:pAqSkoJzsw==,iv:5J1Js7JPH/j1oTmEBdNXjwd1Mj7GtmC8VWpaclXuQVQ=,tag:H0CN6jBeUGXeuMAodbmx9Q==,type:str]
|
||||||
LITESTREAM_R2_ACCESS_KEY_ID=ENC[AES256_GCM,data:6jaEysPtRal7,iv:s5aLx7LdZ3ZLA9oL5vXXDfDDGI7gd5/CukNrMpPLJNk=,tag:Igp3bqW52raBfEeUaUvZ7A==,type:str]
|
LITESTREAM_R2_ACCESS_KEY_ID=ENC[AES256_GCM,data:e89yGzousImmdO7WVqmRWLJNejDFH5eTaw7G74CyZSw=,iv:bR1jgqSzJlxPA8LMMg2Mc1Lnp01iZgaqa9dgAoV0RpY=,tag:m92xzCP0qaP2onK7ChwA1Q==,type:str]
|
||||||
LITESTREAM_R2_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:QfXhwh9L2rhr,iv:OaYlzTiu4onCNu5HfytYTCJa5p2QLShhO5j5Y038IOs=,tag:i13aQ2ICePyCU/Ob+EA7Nw==,type:str]
|
LITESTREAM_R2_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:yzXeb8c/Y0d+EluY7g6buo4BnFvBDEVblOi7doNgOp3siLvfMmPkjdRLqZzA14ET6CW5vef9i51yijPYwuhnbw==,iv:IYQRZ8SsquUQpsHH3X/iovz2wFskR4iHyvr0arY7Ag4=,tag:9G5lpHloacjQbEhSk9T2pw==,type:str]
|
||||||
LITESTREAM_R2_ENDPOINT=ENC[AES256_GCM,data:hLneNsFmgQ6+,iv:RNefJ3QbviHPURxcK2xYJU7qWpMfWInCxYQ/4xDIwfw=,tag:FhMiHGrNcsXaSmdG4NXgfQ==,type:str]
|
LITESTREAM_R2_ENDPOINT=ENC[AES256_GCM,data:qqDLfsPeiWOfwtgpZeItypnYNmIOD07fV0IPlZfphhUFeY0Z/BRpkVXA7nfqQ2M6PmcYKVIlBiBY,iv:hsEBxxv1+fvUY4v3nhBP8puKlu216eAGZDUNBAjibas=,tag:MvnsJ8W3oSrv4ZrWW/p+dg==,type:str]
|
||||||
#ENC[AES256_GCM,data:YGV2exKdGOUkblNZZos=,iv:NuabFM/gNHIzYmDMRZ2tglFYdMPVFuHFGd+AAWvvu6Q=,tag:gZRoNNEmjL9v3nC8j9YkHw==,type:comment]
|
#ENC[AES256_GCM,data:YGV2exKdGOUkblNZZos=,iv:NuabFM/gNHIzYmDMRZ2tglFYdMPVFuHFGd+AAWvvu6Q=,tag:gZRoNNEmjL9v3nC8j9YkHw==,type:comment]
|
||||||
DUCKDB_PATH=ENC[AES256_GCM,data:GgOEQ5B1KeQrVavhoMU/JGXcVu3H,iv:XY8JiaosxaUDv5PwizrZFWuNKMSOeuE3cfVyp51r++8=,tag:RnoDE5+7WQolFLejfRZ//w==,type:str]
|
DUCKDB_PATH=ENC[AES256_GCM,data:GgOEQ5B1KeQrVavhoMU/JGXcVu3H,iv:XY8JiaosxaUDv5PwizrZFWuNKMSOeuE3cfVyp51r++8=,tag:RnoDE5+7WQolFLejfRZ//w==,type:str]
|
||||||
SERVING_DUCKDB_PATH=ENC[AES256_GCM,data:U2X9KmlgnWXM9uCfhHCJ03HMGCLm,iv:KHHdBTq+ct4AG7Jt4zLog/5jbDC7LvHA6KzWNTDS/Yw=,tag:m5uIG/bS4vaBooSYoYa6SA==,type:str]
|
SERVING_DUCKDB_PATH=ENC[AES256_GCM,data:U2X9KmlgnWXM9uCfhHCJ03HMGCLm,iv:KHHdBTq+ct4AG7Jt4zLog/5jbDC7LvHA6KzWNTDS/Yw=,tag:m5uIG/bS4vaBooSYoYa6SA==,type:str]
|
||||||
@@ -43,7 +43,7 @@ ALERT_WEBHOOK_URL=ENC[AES256_GCM,data:4sXQk8zklruC525J279TUUatdDJQ43qweuoPhtpI82
|
|||||||
NTFY_TOKEN=ENC[AES256_GCM,data:YlOxhsRJ8P1y4kk6ugWm41iyRCsM6oAWjvbU9lGcD0A=,iv:JZXOvi3wTOPV9A46c7fMiqbszNCvXkOgh9i/H1hob24=,tag:8xnPimgy7sesOAnxhaXmpg==,type:str]
|
NTFY_TOKEN=ENC[AES256_GCM,data:YlOxhsRJ8P1y4kk6ugWm41iyRCsM6oAWjvbU9lGcD0A=,iv:JZXOvi3wTOPV9A46c7fMiqbszNCvXkOgh9i/H1hob24=,tag:8xnPimgy7sesOAnxhaXmpg==,type:str]
|
||||||
SUPERVISOR_GIT_PULL=ENC[AES256_GCM,data:mg==,iv:KgqMVYj12FjOzWxtA1T0r0pqCDJ6MtHzMjE+4W/W+s4=,tag:czFaOqhHG8nqrQ8AZ8QiGw==,type:str]
|
SUPERVISOR_GIT_PULL=ENC[AES256_GCM,data:mg==,iv:KgqMVYj12FjOzWxtA1T0r0pqCDJ6MtHzMjE+4W/W+s4=,tag:czFaOqhHG8nqrQ8AZ8QiGw==,type:str]
|
||||||
#ENC[AES256_GCM,data:hzAZvCWc4RTk290=,iv:RsSI4OpAOQGcFVpfXDZ6t705yWmlO0JEWwWF5uQu9As=,tag:UPqFtA2tXiSa0vzJAv8qXg==,type:comment]
|
#ENC[AES256_GCM,data:hzAZvCWc4RTk290=,iv:RsSI4OpAOQGcFVpfXDZ6t705yWmlO0JEWwWF5uQu9As=,tag:UPqFtA2tXiSa0vzJAv8qXg==,type:comment]
|
||||||
PROXY_URLS=ENC[AES256_GCM,data:L2Oobpi6Pq8m,iv:14mXi+8mLv2e20IKVL0VlxZiHW/1BmeQP4a6ns5930g=,tag:pVJasNjv6N/UApVm+KD+XA==,type:str]
|
PROXY_URLS=ENC[AES256_GCM,data:nm4B++SkZZgN3p2xru3WrpVA0X6O8yvb45tH/ovF4006zBy28xqVxbsd44Mz6b5FMinjOXRmGwoI/GDWmdJLzBYdpryQ/FhpbzSUpr1ZOjOz+7P0vn2jfBGAB8ksU3i5kuYglud3EyQGFL+v+uooxwrIUCjfzmmB4vCmf7phssKDsK1CqzmdZ1c54ehSu4bRRdmGp9d0+r+j1SpXb/JbZ8LTqUIhLlZXrHFqkCfN1czhFK9IwMVgR00Q4v2YkjaRBME4lVqwk1NwwatbS9Fq8LlzwuT1uKk+T6ZDkFKC8ZoPW1YRqF13X7hFGFXCNRqABRDZ45lqxYQbBoRrWmH2tfMiAmTrIuRsdPM8bZ/Ol5mXSDhs0HyWX2urX+LD65rIOO0zN/lwjXSwh5mwwBdB61akdzsWRyLZsdafuQUmgGul8y0eGMEbFWaty3bdrtAmqtsvHwxD/Dp/gQWScESXvPd1arn55zaXmefOy+ZLwcmx+FAJPpTMXRaq6Y/Z+D1PZZ+Uhu2D6tsAR4VvqqwlUgpsrAFXk6chJzOry8rmmxoMuIj9mXfjG+BqPFhV2oQsKSuIqFQqd/ZidJLO8ZSxA7L+h1eH4cQjcUd2nfzroG8nnKZ+cA8hQMfLuFiMY1I=,iv:nTaNQlC3px/lnodLphnILWbPVnelaUKKOZAFAaHi8MU=,tag:TYkIX1nrc+PKbvvnWYcvbg==,type:str]
|
||||||
RECHECK_WINDOW_MINUTES=ENC[AES256_GCM,data:L2s=,iv:fV3mCKmK5fxUmIWRePELBDAPTb8JZqasVIhnAl55kYw=,tag:XL+PO6sblz/7WqHC3dtk1w==,type:str]
|
RECHECK_WINDOW_MINUTES=ENC[AES256_GCM,data:L2s=,iv:fV3mCKmK5fxUmIWRePELBDAPTb8JZqasVIhnAl55kYw=,tag:XL+PO6sblz/7WqHC3dtk1w==,type:str]
|
||||||
#ENC[AES256_GCM,data:RC+t2vqLwLjapdAUql8rQls=,iv:Kkiz3ND0g0MRAgcPJysIYMzSQS96Rq+3YP5yO7yWfIY=,tag:Y6TbZd81ihIwn+U515qd1g==,type:comment]
|
#ENC[AES256_GCM,data:RC+t2vqLwLjapdAUql8rQls=,iv:Kkiz3ND0g0MRAgcPJysIYMzSQS96Rq+3YP5yO7yWfIY=,tag:Y6TbZd81ihIwn+U515qd1g==,type:comment]
|
||||||
GSC_SERVICE_ACCOUNT_PATH=ENC[AES256_GCM,data:Vki6yHk+gd4n,iv:rxzKvwrGnAkLcpS41EZ097E87NrIpNZGFfl4iXFvr40=,tag:EZkBJpCq5rSpKYVC4H3JHQ==,type:str]
|
GSC_SERVICE_ACCOUNT_PATH=ENC[AES256_GCM,data:Vki6yHk+gd4n,iv:rxzKvwrGnAkLcpS41EZ097E87NrIpNZGFfl4iXFvr40=,tag:EZkBJpCq5rSpKYVC4H3JHQ==,type:str]
|
||||||
@@ -52,10 +52,10 @@ BING_WEBMASTER_API_KEY=ENC[AES256_GCM,data:kSQxJOpsYCuJ,iv:Kc4jJpOd64PATeBjidNHT
|
|||||||
BING_SITE_URL=ENC[AES256_GCM,data:M33VI97DyxH8gRR3ZUXoXg4QrEv5og==,iv:GxZtwfbBVihUbp6XNQKzAalhO1GfQF1l1j1MeEIBCFQ=,tag:9njlBp4v684PeFl3HebyIg==,type:str]
|
BING_SITE_URL=ENC[AES256_GCM,data:M33VI97DyxH8gRR3ZUXoXg4QrEv5og==,iv:GxZtwfbBVihUbp6XNQKzAalhO1GfQF1l1j1MeEIBCFQ=,tag:9njlBp4v684PeFl3HebyIg==,type:str]
|
||||||
#ENC[AES256_GCM,data:OTUMKNkRW0zrupNppXthwE1oieILhNjM+cjx5hFn69g=,iv:48ID2qtSe9ggD2X+G/iUqp3v2uwEc7fZw8lxHIvVXmk=,tag:okBn0Npk1K9dDOFWA/AB1A==,type:comment]
|
#ENC[AES256_GCM,data:OTUMKNkRW0zrupNppXthwE1oieILhNjM+cjx5hFn69g=,iv:48ID2qtSe9ggD2X+G/iUqp3v2uwEc7fZw8lxHIvVXmk=,tag:okBn0Npk1K9dDOFWA/AB1A==,type:comment]
|
||||||
GEONAMES_USERNAME=ENC[AES256_GCM,data:UXd/S2TzXPiGmLY=,iv:OMURM5E6SFEsaqroUlH76DEnr7C/ujNk9UQnbWT0hK4=,tag:VsjjS12QDbudiEhdAQ/OCQ==,type:str]
|
GEONAMES_USERNAME=ENC[AES256_GCM,data:UXd/S2TzXPiGmLY=,iv:OMURM5E6SFEsaqroUlH76DEnr7C/ujNk9UQnbWT0hK4=,tag:VsjjS12QDbudiEhdAQ/OCQ==,type:str]
|
||||||
CENSUS_API_KEY=
|
CENSUS_API_KEY=ENC[AES256_GCM,data:9RbKlxSD17LqIuuNXaOKSgZ8LnFh9Wbze3XHgpctfV/1TqBMZTIedQ==,iv:WwsmR3HLUEcgUpLliGRaUPhGM9vFNPMGXSAQQ6+9UVc=,tag:R4EMNy5MxxvK0UTaCL0umA==,type:str]
|
||||||
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBqck9GdHVkUmIzNnlvMW5k\nVkNtazZ0ZytzZ25vMU5SckdFLzcrTFNYOVZZCmNjbU9yV0lTRlB5cEpMVC81QTdu\nS2ZDc0ZkNnRBNFhFMEN1bjY3YVhwZEEKLS0tIGE5TEdYenVOV1IwcE0wYnlKNElF\ncXV1K0xuczZzZ3JnL1lrSC9QWHIwNGsKfW4ARke6Cj83BpQc8weayL3v8SVgQ+Fp\n99aVWp103O1fumksR1w4u0X7fSNRrgAmpY/yyZuEvsoIY8ELFVcqgQ==\n-----END AGE ENCRYPTED FILE-----\n
|
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBqck9GdHVkUmIzNnlvMW5k\nVkNtazZ0ZytzZ25vMU5SckdFLzcrTFNYOVZZCmNjbU9yV0lTRlB5cEpMVC81QTdu\nS2ZDc0ZkNnRBNFhFMEN1bjY3YVhwZEEKLS0tIGE5TEdYenVOV1IwcE0wYnlKNElF\ncXV1K0xuczZzZ3JnL1lrSC9QWHIwNGsKfW4ARke6Cj83BpQc8weayL3v8SVgQ+Fp\n99aVWp103O1fumksR1w4u0X7fSNRrgAmpY/yyZuEvsoIY8ELFVcqgQ==\n-----END AGE ENCRYPTED FILE-----\n
|
||||||
sops_age__list_0__map_recipient=age1f5002gj4s78jju45jd28kuejtcfhn5cdujz885fl7z2p9ym68pnsgky87a
|
sops_age__list_0__map_recipient=age1f5002gj4s78jju45jd28kuejtcfhn5cdujz885fl7z2p9ym68pnsgky87a
|
||||||
sops_lastmodified=2026-02-24T21:37:45Z
|
sops_lastmodified=2026-02-26T14:32:28Z
|
||||||
sops_mac=ENC[AES256_GCM,data:FdIU0UvGEc/P7ETNOxYHqfsGMNCdBVqbxHVIrR1v4hAnTWYHelawJqifQOOArTyNGjfsIRGajct7CLADkGE/qVm6vSQO4m6w+veSGEO39Wvlfz6BrVSYMqWMjGuJsTj/TJGSZDBnyC//Jzf3pTTgXrcjM86aoLbqhT/Qbb0JIiE=,iv:fgP4Ro0Cd6u1n9G07UsMkQNDk3fCQPe5hixA3KXhcAk=,tag:2PEKkltbD5TICzZ3WgvXQA==,type:str]
|
sops_mac=ENC[AES256_GCM,data:pyHQHwTtjh7OLiMqbqhUjfrmetEtYS7yB342C/TWfDCwEotWLVwnGWlC4+HIl53pw9+3AgoBVRnW0t86e4kG9O8KyHnk68S9qBcpUsybW3lyGPNXmBydv1W9gQHuK8f/4WGIbkhNxyIToKg9ZAmYWFxNhRKSoYKm5P9Uh7B7CF4=,iv:syrX8VdL3JsDsawvFWbX04Ygcr18hjSSHfEwHkyKETk=,tag:qrhWkh/e+21OKGU2+rCeyg==,type:str]
|
||||||
sops_unencrypted_suffix=_unencrypted
|
sops_unencrypted_suffix=_unencrypted
|
||||||
sops_version=3.12.1
|
sops_version=3.12.1
|
||||||
|
|||||||
46
Makefile
46
Makefile
@@ -1,32 +1,58 @@
|
|||||||
TAILWIND := ./bin/tailwindcss
|
TAILWIND_VERSION := v4.1.18
|
||||||
|
TAILWIND := ./bin/tailwindcss
|
||||||
|
SOPS_DOTENV := sops --input-type dotenv --output-type dotenv
|
||||||
|
|
||||||
|
.PHONY: help dev init-landing-seeds css-build css-watch \
|
||||||
|
secrets-decrypt-dev secrets-decrypt-prod \
|
||||||
|
secrets-edit-dev secrets-edit-prod
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo "Available targets:"
|
||||||
|
@echo " dev Start full dev environment (reset DB, migrate, seed, app + worker + CSS watcher)"
|
||||||
|
@echo " init-landing-seeds Create seed landing files for SQLMesh (run once after clone)"
|
||||||
|
@echo " css-build Build + minify Tailwind CSS"
|
||||||
|
@echo " css-watch Watch + rebuild Tailwind CSS"
|
||||||
|
@echo " secrets-decrypt-dev Decrypt .env.dev.sops → .env"
|
||||||
|
@echo " secrets-decrypt-prod Decrypt .env.prod.sops → .env"
|
||||||
|
@echo " secrets-edit-dev Edit .env.dev.sops in \$$EDITOR"
|
||||||
|
@echo " secrets-edit-prod Edit .env.prod.sops in \$$EDITOR"
|
||||||
|
|
||||||
|
# ── Dev environment ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
dev:
|
||||||
|
@./web/scripts/dev_run.sh
|
||||||
|
|
||||||
|
# ── Landing seeds ─────────────────────────────────────────────────────────────
|
||||||
|
# Create seed files for SQLMesh staging models that require at least one landing file.
|
||||||
|
# Run once after a fresh clone (data/ is gitignored so seeds are not in git).
|
||||||
|
|
||||||
|
init-landing-seeds:
|
||||||
|
@uv run python web/scripts/init_landing_seeds.py
|
||||||
|
|
||||||
|
# ── CSS ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
bin/tailwindcss:
|
bin/tailwindcss:
|
||||||
@mkdir -p bin
|
@mkdir -p bin
|
||||||
curl -sLo bin/tailwindcss https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64
|
curl -sLo bin/tailwindcss https://github.com/tailwindlabs/tailwindcss/releases/download/$(TAILWIND_VERSION)/tailwindcss-linux-x64
|
||||||
chmod +x bin/tailwindcss
|
chmod +x bin/tailwindcss
|
||||||
|
|
||||||
# Create seed files for SQLMesh staging models that require at least one landing file.
|
|
||||||
# Run once after a fresh clone (data/ is gitignored so seeds are not in git).
|
|
||||||
init-landing-seeds:
|
|
||||||
@python3 web/scripts/init_landing_seeds.py
|
|
||||||
|
|
||||||
css-build: bin/tailwindcss
|
css-build: bin/tailwindcss
|
||||||
$(TAILWIND) -i web/src/padelnomics/static/css/input.css -o web/src/padelnomics/static/css/output.css --minify
|
$(TAILWIND) -i web/src/padelnomics/static/css/input.css -o web/src/padelnomics/static/css/output.css --minify
|
||||||
|
|
||||||
css-watch: bin/tailwindcss
|
css-watch: bin/tailwindcss
|
||||||
$(TAILWIND) -i web/src/padelnomics/static/css/input.css -o web/src/padelnomics/static/css/output.css --watch
|
$(TAILWIND) -i web/src/padelnomics/static/css/input.css -o web/src/padelnomics/static/css/output.css --watch
|
||||||
|
|
||||||
# -- Secrets (SOPS + age) --
|
# ── Secrets (SOPS + age) ─────────────────────────────────────────────────────
|
||||||
# .env.*.sops files use dotenv format but sops can't infer from the extension,
|
# .env.*.sops files use dotenv format but sops can't infer from the extension,
|
||||||
# so we pass --input-type / --output-type explicitly.
|
# so we pass --input-type / --output-type explicitly.
|
||||||
|
|
||||||
SOPS_DOTENV := sops --input-type dotenv --output-type dotenv
|
|
||||||
|
|
||||||
secrets-decrypt-dev:
|
secrets-decrypt-dev:
|
||||||
$(SOPS_DOTENV) --decrypt .env.dev.sops > .env
|
$(SOPS_DOTENV) --decrypt .env.dev.sops > .env
|
||||||
|
@echo "Decrypted .env.dev.sops → .env"
|
||||||
|
|
||||||
secrets-decrypt-prod:
|
secrets-decrypt-prod:
|
||||||
$(SOPS_DOTENV) --decrypt .env.prod.sops > .env
|
$(SOPS_DOTENV) --decrypt .env.prod.sops > .env
|
||||||
|
@echo "Decrypted .env.prod.sops → .env"
|
||||||
|
|
||||||
secrets-edit-dev:
|
secrets-edit-dev:
|
||||||
$(SOPS_DOTENV) .env.dev.sops
|
$(SOPS_DOTENV) .env.dev.sops
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ server {
|
|||||||
proxy_set_header Host \$host;
|
proxy_set_header Host \$host;
|
||||||
proxy_set_header X-Real-IP \$remote_addr;
|
proxy_set_header X-Real-IP \$remote_addr;
|
||||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
proxy_set_header X-Forwarded-Proto \$http_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
NGINX
|
NGINX
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
|
|
||||||
**Core insight:** The job is never "I need a calculator." It's "I need to feel confident committing €200K+." Confidence is the product. Calculator is the vehicle.
|
**Core insight:** The job is never "I need a calculator." It's "I need to feel confident committing €200K+." Confidence is the product. Calculator is the vehicle.
|
||||||
|
|
||||||
|
**Investment reality (from `research/padel-hall-economics.md`):** 4-court outdoor commercial = €200K–€350K. Any indoor hall = €700K–€3M+. These are the primary addressable buyers. Single-court installs (€33K–€80K, mostly hotel/corporate add-ons) exist but are not the target for the lead marketplace — suppliers filter them out by project size anyway.
|
||||||
|
|
||||||
### Why This Works (DaaS Thesis)
|
### Why This Works (DaaS Thesis)
|
||||||
|
|
||||||
| Check | Answer |
|
| Check | Answer |
|
||||||
@@ -79,7 +81,7 @@ Small direct revenue Main revenue: subscriptions + credits + upsel
|
|||||||
**Social job:** Look professional and thorough to banks / investors / spouse. ← underserved, pricing lever.
|
**Social job:** Look professional and thorough to banks / investors / spouse. ← underserved, pricing lever.
|
||||||
|
|
||||||
**Acquired via:** SEO, free calculator, content marketing.
|
**Acquired via:** SEO, free calculator, content marketing.
|
||||||
**Direct revenue:** Business plan PDF (€99), market intel subscriptions (future).
|
**Direct revenue:** Business plan PDF (€149), market intel subscriptions (future).
|
||||||
|
|
||||||
#### Beachhead Sub-segment — Tennis/Sports Club Owner (Germany)
|
#### Beachhead Sub-segment — Tennis/Sports Club Owner (Germany)
|
||||||
|
|
||||||
@@ -311,8 +313,8 @@ After calculator results: prompt for email to save/share scenario. Every planner
|
|||||||
| Tier | Price | Includes | State |
|
| Tier | Price | Includes | State |
|
||||||
|------|-------|----------|-------|
|
|------|-------|----------|-------|
|
||||||
| Basic | Free | Listing, no leads | [ ] |
|
| Basic | Free | Listing, no leads | [ ] |
|
||||||
| Growth | €149/mo | Listing + 30 credits/mo + basic analytics | [ ] |
|
| Growth | €199/mo (€1,799/yr) | Listing + 30 credits/mo + basic analytics | [ ] |
|
||||||
| Pro | €399/mo | Listing + 100 credits/mo + full analytics | [ ] |
|
| Pro | €499/mo (€4,499/yr) | Listing + 100 credits/mo + full analytics | [ ] |
|
||||||
|
|
||||||
### Upsell Stack (RemoteOK playbook)
|
### Upsell Stack (RemoteOK playbook)
|
||||||
|
|
||||||
@@ -364,7 +366,7 @@ Suppliers self-select which leads to unlock with credits. Lead cost scales by pr
|
|||||||
| Lead credits (Side B) | Suppliers buy credits to unlock leads | [ ] Not live |
|
| Lead credits (Side B) | Suppliers buy credits to unlock leads | [ ] Not live |
|
||||||
| Directory subscriptions (Side B) | Growth €149/mo, Pro €399/mo | [ ] Not live |
|
| Directory subscriptions (Side B) | Growth €149/mo, Pro €399/mo | [ ] Not live |
|
||||||
| Directory upsells (Side B) | Logo, highlight, verified, sticky | [ ] Not live |
|
| Directory upsells (Side B) | Logo, highlight, verified, sticky | [ ] Not live |
|
||||||
| Business Plan PDF (Side A) | €99 one-time | [ ] CTA exists, no product |
|
| Business Plan PDF (Side A) | €149 one-time | [ ] CTA exists, no product |
|
||||||
|
|
||||||
### Near-term (Phase 2–3)
|
### Near-term (Phase 2–3)
|
||||||
|
|
||||||
|
|||||||
@@ -1,272 +0,0 @@
|
|||||||
# State of Padel Q1 2026 — Research Brief
|
|
||||||
|
|
||||||
> Compiled 2026-02-25 from FIP World Padel Reports 2024 + 2025 (PDF text extraction),
|
|
||||||
> padelfip.com press releases, and Premier Padel circuit announcements.
|
|
||||||
>
|
|
||||||
> **Raw source files:**
|
|
||||||
> - FIP 2024 PDF text: `/tmp/fip_2024_text.txt` (11,049 lines)
|
|
||||||
> - FIP 2025 PDF text: `/tmp/fip_2025_text.txt` (28,135 lines)
|
|
||||||
> - FIP 2024 PDF: `https://www.padelfip.com/wp-content/uploads/2025/05/WORLD_PADEL_REPORT_2024_FIP-1.pdf`
|
|
||||||
> - FIP 2025 PDF: `https://www.padelfip.com/wp-content/uploads/2025/12/FIP-WPR-2025_DIGITAL.pdf`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Global Player Population
|
|
||||||
|
|
||||||
| Metric | End 2023 (FIP 2024 report) | Mid/Oct 2025 (FIP 2025 report) | Growth |
|
|
||||||
|--------|---------------------------|-------------------------------|--------|
|
|
||||||
| Amateur players worldwide (est.) | ~25–30 million | **>35 million** | — |
|
|
||||||
| Federated / licensed players | **600,000** | **850,000** | +42% |
|
|
||||||
| Players playing ≥1×/week | >50% of amateurs | >50% of amateurs | — |
|
|
||||||
| Gender split | ~60% M / 40% F | ~60% M / 40% F | — |
|
|
||||||
| Pro-ranked players | ~4,874 | **11,125** | +128% |
|
|
||||||
| Junior-ranked players | ~1,209 | **4,219** | +249% |
|
|
||||||
|
|
||||||
**Player distribution by continent (FIP 2025 report):**
|
|
||||||
|
|
||||||
| Continent | Share |
|
|
||||||
|-----------|-------|
|
|
||||||
| Europe | 61.3% |
|
|
||||||
| South America | 19.0% |
|
|
||||||
| Central & North America | 7.7% |
|
|
||||||
| Asia | 6.8% |
|
|
||||||
| Africa | 4.9% |
|
|
||||||
| Oceania | ~0.3% |
|
|
||||||
|
|
||||||
*Source: FIP World Padel Report 2025, `/tmp/fip_2025_text.txt`*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Courts Worldwide
|
|
||||||
|
|
||||||
| Metric | Early 2024 (FIP 2024) | June 2025 (FIP 2025) | Growth |
|
|
||||||
|--------|----------------------|---------------------|--------|
|
|
||||||
| **Total courts worldwide** | **60,000** (milestone) | **77,355** | +29% |
|
|
||||||
| Europe | 42,600 (70%) | 51,000+ (66%) | +20% |
|
|
||||||
| South America | ~14,850 | ~14,100 (18%) | — |
|
|
||||||
| Asia | ~3,200 | ~4,633 (6%) | +45% |
|
|
||||||
| Central & North America | ~2,000 | ~3,976 (5%) | +99% |
|
|
||||||
| Africa | ~2,300 | ~3,389 (4%) | +47% |
|
|
||||||
| Oceania | ~50 | ~110 | +120% |
|
|
||||||
| Court-to-club ratio (global avg) | 3.2 | — | — |
|
|
||||||
|
|
||||||
**3-year context (from FIP 2024 report):** +240% court growth globally since 2021.
|
|
||||||
Excluding Spain, European countries grew courts 6× in the same period.
|
|
||||||
|
|
||||||
*Source: FIP 2024 report pp. key facts section; FIP 2025 report country-breakdown section. `/tmp/fip_2024_text.txt` lines ~380–414; `/tmp/fip_2025_text.txt` court section.*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Top Countries by Court Count
|
|
||||||
|
|
||||||
### 2025 (June 2025 data, FIP 2025 report)
|
|
||||||
|
|
||||||
| Rank | Country | Courts |
|
|
||||||
|------|---------|--------|
|
|
||||||
| 1 | Spain | 17,300 |
|
|
||||||
| 2 | Italy | 10,220 |
|
|
||||||
| 3 | Sweden | 4,220 |
|
|
||||||
| 4 | Argentina | 4,220 |
|
|
||||||
| 5 | France | 4,000 |
|
|
||||||
| 6 | Netherlands | 3,500+ |
|
|
||||||
| 7 | Belgium | 2,150 |
|
|
||||||
| 8 | Mexico | 2,000+ |
|
|
||||||
| 9 | Chile | ~2,000 |
|
|
||||||
| 10 | Denmark | ~1,560 |
|
|
||||||
| 11 | Portugal | 1,560 |
|
|
||||||
| 12 | Saudi Arabia | 1,127–1,575 |
|
|
||||||
|
|
||||||
### 2024 (early 2024 data, FIP 2024 report)
|
|
||||||
|
|
||||||
| Country | Courts |
|
|
||||||
|---------|--------|
|
|
||||||
| Spain | 16,000+ |
|
|
||||||
| Argentina | ~9,053 |
|
|
||||||
| Sweden | ~4,200 |
|
|
||||||
| France | ~2,300 |
|
|
||||||
| Netherlands | ~2,420 |
|
|
||||||
|
|
||||||
*Source: FIP 2025 top-15 courts chart, `/tmp/fip_2025_text.txt` ~lines 390–412. FIP 2024 country pages, `/tmp/fip_2024_text.txt`.*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Clubs Worldwide
|
|
||||||
|
|
||||||
| Metric | FIP 2024 report | FIP 2025 report |
|
|
||||||
|--------|----------------|----------------|
|
|
||||||
| Total clubs worldwide | ~19,800 | **24,627+** |
|
|
||||||
| FIP-affiliated clubs | 5,820 (+48% vs 2022's 3,923) | 8,612 (managing 30,000+ courts) |
|
|
||||||
| Countries / nations playing padel | 130 + 12 territories | **150 + 20 territories** |
|
|
||||||
|
|
||||||
*Source: `/tmp/fip_2024_text.txt` global summary; `/tmp/fip_2025_text.txt` headline stats.*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. FIP Member Federations
|
|
||||||
|
|
||||||
| Metric | FIP 2024 | FIP 2025 |
|
|
||||||
|--------|---------|---------|
|
|
||||||
| Total FIP member federations | **71** (30 joined in prior 3 years) | **87** |
|
|
||||||
| European | 39 | 50 |
|
|
||||||
| Asian | 15 | — |
|
|
||||||
| American | 12 | — |
|
|
||||||
| African | 3 | — |
|
|
||||||
| Oceanian | 2 | — |
|
|
||||||
|
|
||||||
*Source: `/tmp/fip_2024_text.txt` line 3573; `/tmp/fip_2025_text.txt` federation overview.*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Country-Level Licensed (Federated) Players
|
|
||||||
|
|
||||||
### From FIP 2025 report (data as of Oct 2025)
|
|
||||||
|
|
||||||
| Country | Licensed Players | Gender split | Amateur est. | Courts | Clubs |
|
|
||||||
|---------|-----------------|-------------|-------------|--------|-------|
|
|
||||||
| Spain | **109,182** | 39,851W + 69,331M | 6,200,000+ | 17,300+ | 4,570+ |
|
|
||||||
| Italy | **86,301** | 23,257W + 61,837M | 2,200,000 | 10,220 | 3,795 |
|
|
||||||
| France | **250,000** | via FFT* | 800,000 | 4,000 | 2,917 |
|
|
||||||
| Sweden | **6,118** | 1,900W + 4,218M | 700,000 | 4,220 | 1,202 |
|
|
||||||
|
|
||||||
*France figure = FFT (Fédération Française de Tennis) licences covering padel. 100,000 are padel-only; 150,000 are multi-racket licences.*
|
|
||||||
|
|
||||||
### From FIP 2024 report (data as of end 2023 / April 2024)
|
|
||||||
|
|
||||||
| Country | Licensed Players | Amateur est. |
|
|
||||||
|---------|-----------------|-------------|
|
|
||||||
| Spain | **101,326** | 5,500,000+ |
|
|
||||||
| France | **113,006** (FFT) | 1,000,000 |
|
|
||||||
| Argentina | **73,741** | 1,500,000 |
|
|
||||||
| Paraguay | 15,000 | 500,000 |
|
|
||||||
|
|
||||||
*Source: `/tmp/fip_2025_text.txt` Spain ~line 25571, Italy ~line 19606, France ~line 17550, Sweden ~line 26084. `/tmp/fip_2024_text.txt` Argentina ~line 4125, France ~line 4488.*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Professional Circuit — Scale & Growth
|
|
||||||
|
|
||||||
### FIP-Organized Tournament Volume
|
|
||||||
|
|
||||||
| Year | Total FIP tournaments | Countries hosting | Unique players |
|
|
||||||
|------|-----------------------|-------------------|----------------|
|
|
||||||
| 2024 (full year) | **182** | — | — |
|
|
||||||
| H1 2024 | 71 | 21 | 2,419 (65 nationalities) |
|
|
||||||
| H1 2025 | **132** (+86%) | **38** (+81%) | **3,266** (+35%, 91 nationalities) |
|
|
||||||
|
|
||||||
**H1 2025 breakdown:** 12 Premier Padel + 75 CUPRA FIP Tour + 45 FIP Promises
|
|
||||||
|
|
||||||
### Digital reach (H1 2025)
|
|
||||||
- 1.85 million unique website users (+19% vs H1 2024)
|
|
||||||
- 30+ million page views (+33%)
|
|
||||||
- 680,000 social followers (+23%)
|
|
||||||
|
|
||||||
*Source: padelfip.com press release July 2025, plain HTML.*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Premier Padel Prize Pools
|
|
||||||
|
|
||||||
### Per-tournament prize money by tier
|
|
||||||
|
|
||||||
| Tier | 2022 | 2023 | 2024 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| Major | €525,000 | €525,000 | Not confirmed |
|
|
||||||
| P1 | €250,000 | **€300,000** | Not confirmed |
|
|
||||||
| P2 | — | €150,000 | **€250,000** (+67%) |
|
|
||||||
| Year-end Finals | — | — | **€600,000 total** |
|
|
||||||
|
|
||||||
**2024 Finals breakdown (Barcelona, Dec 2024):**
|
|
||||||
Winner per player €52,500 · Finalist €30,000 · 3rd €21,000 · 4th €13,500 · QF €7,688 · Alternate €2,250
|
|
||||||
|
|
||||||
**2025 update:** Women's P2 raised a further €10,000 (announced March 2025, padelfip.com).
|
|
||||||
|
|
||||||
> **Note on 2024 Major/P1 amounts:** Not confirmed in any accessible source.
|
|
||||||
> The 2024 P2 increase (€150k → €250k) was confirmed from Genova P2 announcement.
|
|
||||||
> Major/P1 for 2024 require one more manual lookup on padelfip.com.
|
|
||||||
|
|
||||||
*Source: padelfip.com press releases 2022, 2023, 2024 — plain HTML, fully accessible.
|
|
||||||
2024 Finals: `padelfip.com/2024/10/countdown-to-the-qatar-airways-premier-padel-finals...`*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Broadcast & Viewership
|
|
||||||
|
|
||||||
### 2022 (most granular year — from FIP press release)
|
|
||||||
|
|
||||||
| Metric | Value |
|
|
||||||
|--------|-------|
|
|
||||||
| YouTube views (season) | 22 million |
|
|
||||||
| Live stream viewers | 17 million |
|
|
||||||
| Watch time | 6 million hours |
|
|
||||||
| Household broadcast reach | 150 million+ |
|
|
||||||
| Territories | 180+ |
|
|
||||||
| Multi-year broadcast deals | 11 (ESPN, beIN Sports, Canal+, RTVE, etc.) |
|
|
||||||
| Live attendance | 200,000+ across 8 events |
|
|
||||||
|
|
||||||
### H1 2024
|
|
||||||
|
|
||||||
| Metric | Value |
|
|
||||||
|--------|-------|
|
|
||||||
| Broadcast territories | 242 (6 continents) |
|
|
||||||
| Live attendance | **350,000+** (14 tournaments, 12 countries) |
|
|
||||||
| Social media followers | ~2M total; 4M on Instagram |
|
|
||||||
| Broadcast countries (full year) | 187 |
|
|
||||||
|
|
||||||
> 2024 viewership ratings / unique viewer counts **not published** in any accessible source.
|
|
||||||
> The 2022 figures remain the most granular available.
|
|
||||||
|
|
||||||
*Source: padelfip.com 2022 year-end release; padelfip.com August 2024 US expansion announcement.*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. World Padel Tour (WPT)
|
|
||||||
|
|
||||||
WPT operated prior to the WPT/Premier Padel merger (completed May 2024).
|
|
||||||
`worldpadeltour.com` is effectively defunct — `ERR_TLS_CERT_ALTNAME_INVALID` on all fetches, domain being wound down. No aggregate 2023 WPT prize pool figure was recoverable from any accessible source.
|
|
||||||
|
|
||||||
The FIP 2024 report notes: from 2019 (birth of Cupra FIP Tour) through April 2024, **355 tournaments** were played across **36 different nations** on the FIP circuit.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. What Padelnomics DuckDB Adds
|
|
||||||
|
|
||||||
The FIP data is country-level. The Padelnomics pipeline has city-level granularity:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Venue counts per country (OSM + Playtomic deduplicated)
|
|
||||||
SELECT country_code, COUNT(*) as venues
|
|
||||||
FROM serving.dim_venues
|
|
||||||
GROUP BY country_code ORDER BY venues DESC;
|
|
||||||
|
|
||||||
-- City-level market scores + venue density
|
|
||||||
SELECT city_name, country_code, market_score, venue_count, population
|
|
||||||
FROM serving.city_market_profile
|
|
||||||
ORDER BY market_score DESC LIMIT 50;
|
|
||||||
```
|
|
||||||
|
|
||||||
The delta between FIP's 17,300 courts in Spain and Padelnomics-tracked venues is meaningful for the article — it illustrates the data methodology gap (FIP = self-reported by federations; Padelnomics = independently scraped OSM + Playtomic).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Data Source Assessment
|
|
||||||
|
|
||||||
| Source | Available | Format | Notes |
|
|
||||||
|--------|-----------|--------|-------|
|
|
||||||
| FIP 2024 PDF | **Free** | PDF → parseable text | `/tmp/fip_2024_text.txt` — layout scrambles extraction slightly |
|
|
||||||
| FIP 2025 PDF | **Free** | PDF → parseable text | `/tmp/fip_2025_text.txt` — same |
|
|
||||||
| padelfip.com press releases | **Free** | Plain HTML | Clean URL pattern `/YYYY/MM/slug/`. Best source for prize pools, circuit stats |
|
|
||||||
| premierpadel.com | Partial | Next.js SPA | Requires headless browser; static fetch returns empty shell |
|
|
||||||
| Playtomic / PwC Global Padel Report 2025 | **Gated** | HubSpot form → PDF | 45-page report; only teaser stat (70k courts by 2026) visible on landing page |
|
|
||||||
| Statista padel topic | **Paywalled** | — | |
|
|
||||||
| Market research firms (Mordor, GVR, etc.) | **Paywalled** | — | Typical quoted figures: ~$1.5–2B market, 15–20% CAGR through 2030 |
|
|
||||||
| worldpadeltour.com | **Dead** | — | Invalid TLS cert post-merger |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. Data Still Needed Before Writing Session
|
|
||||||
|
|
||||||
| Gap | How to fill |
|
|
||||||
|-----|------------|
|
|
||||||
| Premier Padel Major/P1 prize pools for 2024 | 1 manual lookup: search padelfip.com for "2024 Premier Padel season announcement" |
|
|
||||||
| DuckDB pipeline venue counts | Run the 2 SQL queries above |
|
|
||||||
| FIP 2025 per-country data (all 87 federations) | `/tmp/fip_2025_text.txt` has the full text — grep by country name |
|
|
||||||
@@ -10,6 +10,6 @@ server {
|
|||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -372,7 +372,7 @@ def _is_stale(run: dict) -> bool:
|
|||||||
@role_required("admin")
|
@role_required("admin")
|
||||||
async def pipeline_dashboard():
|
async def pipeline_dashboard():
|
||||||
"""Main page: health stat cards + tab container."""
|
"""Main page: health stat cards + tab container."""
|
||||||
from .analytics import fetch_analytics # noqa: PLC0415
|
from ..analytics import fetch_analytics # noqa: PLC0415
|
||||||
|
|
||||||
summary, serving_meta = await asyncio.gather(
|
summary, serving_meta = await asyncio.gather(
|
||||||
asyncio.to_thread(_fetch_extraction_summary_sync),
|
asyncio.to_thread(_fetch_extraction_summary_sync),
|
||||||
@@ -442,7 +442,7 @@ async def pipeline_overview():
|
|||||||
]
|
]
|
||||||
last_export = serving_meta.get("exported_at_utc", "")[:19].replace("T", " ") or None
|
last_export = serving_meta.get("exported_at_utc", "")[:19].replace("T", " ") or None
|
||||||
else:
|
else:
|
||||||
from .analytics import fetch_analytics # noqa: PLC0415
|
from ..analytics import fetch_analytics # noqa: PLC0415
|
||||||
schema_rows = await fetch_analytics(
|
schema_rows = await fetch_analytics(
|
||||||
"SELECT table_name FROM information_schema.tables "
|
"SELECT table_name FROM information_schema.tables "
|
||||||
"WHERE table_schema = 'serving' ORDER BY table_name"
|
"WHERE table_schema = 'serving' ORDER BY table_name"
|
||||||
|
|||||||
@@ -2244,6 +2244,79 @@ async def _get_article_list(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_article_list_grouped(
|
||||||
|
status: str = None,
|
||||||
|
template_slug: str = None,
|
||||||
|
search: str = None,
|
||||||
|
page: int = 1,
|
||||||
|
per_page: int = 50,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Get articles grouped by slug; each item has a 'variants' list (one per language)."""
|
||||||
|
wheres = ["1=1"]
|
||||||
|
params: list = []
|
||||||
|
|
||||||
|
if status == "live":
|
||||||
|
wheres.append("status = 'published' AND published_at <= datetime('now')")
|
||||||
|
elif status == "scheduled":
|
||||||
|
wheres.append("status = 'published' AND published_at > datetime('now')")
|
||||||
|
elif status == "draft":
|
||||||
|
wheres.append("status = 'draft'")
|
||||||
|
if template_slug:
|
||||||
|
wheres.append("template_slug = ?")
|
||||||
|
params.append(template_slug)
|
||||||
|
if search:
|
||||||
|
wheres.append("title LIKE ?")
|
||||||
|
params.append(f"%{search}%")
|
||||||
|
|
||||||
|
where = " AND ".join(wheres)
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
|
||||||
|
# Group by url_path — language variants share the same url_path (no lang prefix stored)
|
||||||
|
path_rows = await fetch_all(
|
||||||
|
f"""SELECT url_path, MAX(created_at) AS latest_created
|
||||||
|
FROM articles WHERE {where}
|
||||||
|
GROUP BY url_path
|
||||||
|
ORDER BY latest_created DESC
|
||||||
|
LIMIT ? OFFSET ?""",
|
||||||
|
tuple(params + [per_page, offset]),
|
||||||
|
)
|
||||||
|
if not path_rows:
|
||||||
|
return []
|
||||||
|
|
||||||
|
url_paths = [r["url_path"] for r in path_rows]
|
||||||
|
placeholders = ",".join("?" * len(url_paths))
|
||||||
|
variants = await fetch_all(
|
||||||
|
f"""SELECT *,
|
||||||
|
CASE WHEN status = 'published' AND published_at > datetime('now')
|
||||||
|
THEN 'scheduled'
|
||||||
|
WHEN status = 'published' THEN 'live'
|
||||||
|
ELSE status END AS display_status
|
||||||
|
FROM articles WHERE url_path IN ({placeholders})
|
||||||
|
ORDER BY url_path, language""",
|
||||||
|
tuple(url_paths),
|
||||||
|
)
|
||||||
|
|
||||||
|
by_path: dict[str, list] = {}
|
||||||
|
for v in variants:
|
||||||
|
by_path.setdefault(v["url_path"], []).append(dict(v))
|
||||||
|
|
||||||
|
groups = []
|
||||||
|
for url_path in url_paths:
|
||||||
|
variant_list = by_path.get(url_path, [])
|
||||||
|
if not variant_list:
|
||||||
|
continue
|
||||||
|
primary = next((v for v in variant_list if v["language"] == "en"), variant_list[0])
|
||||||
|
groups.append({
|
||||||
|
"url_path": url_path,
|
||||||
|
"title": primary["title"],
|
||||||
|
"published_at": primary["published_at"],
|
||||||
|
"template_slug": primary["template_slug"],
|
||||||
|
"variants": variant_list,
|
||||||
|
})
|
||||||
|
|
||||||
|
return groups
|
||||||
|
|
||||||
|
|
||||||
async def _get_article_stats() -> dict:
|
async def _get_article_stats() -> dict:
|
||||||
"""Get aggregate article stats for the admin list header."""
|
"""Get aggregate article stats for the admin list header."""
|
||||||
row = await fetch_one(
|
row = await fetch_one(
|
||||||
@@ -2275,10 +2348,17 @@ async def articles():
|
|||||||
language_filter = request.args.get("language", "")
|
language_filter = request.args.get("language", "")
|
||||||
page = max(1, int(request.args.get("page", "1") or "1"))
|
page = max(1, int(request.args.get("page", "1") or "1"))
|
||||||
|
|
||||||
article_list = await _get_article_list(
|
grouped = not language_filter
|
||||||
status=status_filter or None, template_slug=template_filter or None,
|
if grouped:
|
||||||
language=language_filter or None, search=search or None, page=page,
|
article_list = await _get_article_list_grouped(
|
||||||
)
|
status=status_filter or None, template_slug=template_filter or None,
|
||||||
|
search=search or None, page=page,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
article_list = await _get_article_list(
|
||||||
|
status=status_filter or None, template_slug=template_filter or None,
|
||||||
|
language=language_filter or None, search=search or None, page=page,
|
||||||
|
)
|
||||||
stats = await _get_article_stats()
|
stats = await _get_article_stats()
|
||||||
templates = await fetch_all(
|
templates = await fetch_all(
|
||||||
"SELECT DISTINCT template_slug FROM articles WHERE template_slug IS NOT NULL ORDER BY template_slug"
|
"SELECT DISTINCT template_slug FROM articles WHERE template_slug IS NOT NULL ORDER BY template_slug"
|
||||||
@@ -2287,6 +2367,7 @@ async def articles():
|
|||||||
return await render_template(
|
return await render_template(
|
||||||
"admin/articles.html",
|
"admin/articles.html",
|
||||||
articles=article_list,
|
articles=article_list,
|
||||||
|
grouped=grouped,
|
||||||
stats=stats,
|
stats=stats,
|
||||||
template_slugs=[t["template_slug"] for t in templates],
|
template_slugs=[t["template_slug"] for t in templates],
|
||||||
current_search=search,
|
current_search=search,
|
||||||
@@ -2308,13 +2389,21 @@ async def article_results():
|
|||||||
language_filter = request.args.get("language", "")
|
language_filter = request.args.get("language", "")
|
||||||
page = max(1, int(request.args.get("page", "1") or "1"))
|
page = max(1, int(request.args.get("page", "1") or "1"))
|
||||||
|
|
||||||
article_list = await _get_article_list(
|
grouped = not language_filter
|
||||||
status=status_filter or None, template_slug=template_filter or None,
|
if grouped:
|
||||||
language=language_filter or None, search=search or None, page=page,
|
article_list = await _get_article_list_grouped(
|
||||||
)
|
status=status_filter or None, template_slug=template_filter or None,
|
||||||
|
search=search or None, page=page,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
article_list = await _get_article_list(
|
||||||
|
status=status_filter or None, template_slug=template_filter or None,
|
||||||
|
language=language_filter or None, search=search or None, page=page,
|
||||||
|
)
|
||||||
return await render_template(
|
return await render_template(
|
||||||
"admin/partials/article_results.html",
|
"admin/partials/article_results.html",
|
||||||
articles=article_list,
|
articles=article_list,
|
||||||
|
grouped=grouped,
|
||||||
page=page,
|
page=page,
|
||||||
is_generating=await _is_generating(),
|
is_generating=await _is_generating(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,12 +7,7 @@
|
|||||||
<header class="flex justify-between items-center mb-6">
|
<header class="flex justify-between items-center mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl">Articles</h1>
|
<h1 class="text-2xl">Articles</h1>
|
||||||
<p class="text-sm text-slate mt-1">
|
{% include "admin/partials/article_stats.html" %}
|
||||||
{{ stats.total }} total
|
|
||||||
· {{ stats.live }} live
|
|
||||||
· {{ stats.scheduled }} scheduled
|
|
||||||
· {{ stats.draft }} draft
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<a href="{{ url_for('admin.article_new') }}" class="btn btn-sm">New Article</a>
|
<a href="{{ url_for('admin.article_new') }}" class="btn btn-sm">New Article</a>
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<tr id="article-group-{{ g.url_path | replace('/', '-') | trim('-') }}">
|
||||||
|
<td style="max-width:260px">
|
||||||
|
<div style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500" title="{{ g.url_path }}">{{ g.title }}</div>
|
||||||
|
<div class="article-subtitle">{{ g.url_path }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% for v in g.variants %}
|
||||||
|
<div class="variant-row">
|
||||||
|
<a href="{{ url_for('admin.article_edit', article_id=v.id) }}"
|
||||||
|
class="lang-chip lang-chip-{{ v.display_status }}"
|
||||||
|
title="Edit {{ v.language|upper }} variant">
|
||||||
|
<span class="dot"></span>{{ v.language | upper }}
|
||||||
|
{% if v.noindex %}<span class="noindex-tag">noindex</span>{% endif %}
|
||||||
|
</a>
|
||||||
|
{% if v.display_status == 'live' %}
|
||||||
|
<a href="/{{ v.language or 'en' }}{{ v.url_path }}" target="_blank"
|
||||||
|
class="btn-outline btn-sm view-lang-btn" title="View live article">View ↗</a>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{{ url_for('admin.article_edit', article_id=v.id) }}"
|
||||||
|
class="btn-outline btn-sm view-lang-btn">Edit</a>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
|
<td class="mono">{{ g.published_at[:10] if g.published_at else '-' }}</td>
|
||||||
|
<td class="text-slate">{{ g.template_slug or 'Manual' }}</td>
|
||||||
|
</tr>
|
||||||
@@ -1,3 +1,40 @@
|
|||||||
|
{% if grouped %}
|
||||||
|
<style>
|
||||||
|
.lang-chip {
|
||||||
|
display: inline-flex; align-items: center; gap: 0.3rem;
|
||||||
|
font-size: 0.6rem; font-weight: 700;
|
||||||
|
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
|
||||||
|
letter-spacing: 0.06em; text-transform: uppercase;
|
||||||
|
padding: 0.2rem 0.5rem 0.2rem 0.35rem; border-radius: 0.3rem;
|
||||||
|
text-decoration: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.lang-chip-live { background: #DCFCE7; color: #14532D; border-color: #A7F3D0; }
|
||||||
|
.lang-chip-scheduled { background: #FEF9C3; color: #713F12; border-color: #FDE68A; }
|
||||||
|
.lang-chip-draft { background: #EFF6FF; color: #1E40AF; border-color: #BFDBFE; }
|
||||||
|
.lang-chip .dot { width: 5px; height: 5px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
.lang-chip-live .dot { background: #16A34A; }
|
||||||
|
.lang-chip-scheduled .dot { background: #D97706; }
|
||||||
|
.lang-chip-draft .dot { background: #3B82F6; }
|
||||||
|
.lang-chip .noindex-tag {
|
||||||
|
font-size: 0.5rem; font-weight: 600; opacity: 0.55;
|
||||||
|
border-left: 1px solid currentColor; padding-left: 0.3rem; margin-left: 0.1rem;
|
||||||
|
}
|
||||||
|
.article-subtitle {
|
||||||
|
font-size: 0.625rem; font-family: ui-monospace, 'SF Mono', Menlo, monospace;
|
||||||
|
color: #CBD5E1; margin-top: 0.1rem;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.variant-row {
|
||||||
|
display: flex; align-items: center; gap: 0.4rem; white-space: nowrap;
|
||||||
|
padding: 0.15rem 0;
|
||||||
|
}
|
||||||
|
.variant-row + .variant-row {
|
||||||
|
border-top: 1px solid #F1F5F9; margin-top: 0.15rem; padding-top: 0.3rem;
|
||||||
|
}
|
||||||
|
.view-lang-btn { font-size: 0.65rem !important; padding: 0.15rem 0.5rem !important; }
|
||||||
|
</style>
|
||||||
|
{% endif %}
|
||||||
{% if is_generating %}
|
{% if is_generating %}
|
||||||
<div class="generating-banner"
|
<div class="generating-banner"
|
||||||
hx-get="{{ url_for('admin.article_results') }}"
|
hx-get="{{ url_for('admin.article_results') }}"
|
||||||
@@ -12,26 +49,34 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if articles %}
|
{% if articles %}
|
||||||
<div class="card">
|
<div class="card" style="padding:0; overflow:hidden">
|
||||||
|
<div style="overflow-x:auto">
|
||||||
<table class="table text-sm">
|
<table class="table text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Title</th>
|
<th>Title</th>
|
||||||
<th>Status</th>
|
<th>{% if grouped %}Variants{% else %}Status{% endif %}</th>
|
||||||
<th>Published</th>
|
<th>Published</th>
|
||||||
<th>Lang</th>
|
{% if not grouped %}<th>Lang</th>{% endif %}
|
||||||
<th>Template</th>
|
<th>Template</th>
|
||||||
<th></th>
|
{% if not grouped %}<th></th>{% endif %}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for a in articles %}
|
{% if grouped %}
|
||||||
{% include "admin/partials/article_row.html" %}
|
{% for g in articles %}
|
||||||
{% endfor %}
|
{% include "admin/partials/article_group_row.html" %}
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
{% for a in articles %}
|
||||||
|
{% include "admin/partials/article_row.html" %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
{% if articles | length >= 50 %}
|
{% if articles | length >= 50 %}
|
||||||
<div class="flex justify-between items-center" style="padding:0.75rem 1rem; border-top:1px solid #E2E8F0">
|
<div class="flex justify-between items-center" style="padding:0.75rem 1.5rem; border-top:1px solid #E2E8F0">
|
||||||
{% if page > 1 %}
|
{% if page > 1 %}
|
||||||
<button class="btn-outline btn-sm"
|
<button class="btn-outline btn-sm"
|
||||||
hx-get="{{ url_for('admin.article_results') }}?page={{ page - 1 }}"
|
hx-get="{{ url_for('admin.article_results') }}?page={{ page - 1 }}"
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<p class="text-sm text-slate mt-1"
|
||||||
|
id="article-stats"
|
||||||
|
{% if is_generating %}
|
||||||
|
hx-get="{{ url_for('admin.article_stats') }}"
|
||||||
|
hx-trigger="every 3s"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
{% endif %}>
|
||||||
|
{{ stats.total }} total
|
||||||
|
· {{ stats.live }} live
|
||||||
|
· {{ stats.scheduled }} scheduled
|
||||||
|
· {{ stats.draft }} draft
|
||||||
|
{% if is_generating %}
|
||||||
|
· <span class="text-blue-500">generating…</span>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from jinja2 import Environment, FileSystemLoader
|
||||||
from markupsafe import Markup
|
from markupsafe import Markup
|
||||||
from quart import Blueprint, abort, g, render_template, request
|
from quart import Blueprint, abort, g, redirect, render_template, request
|
||||||
|
|
||||||
from ..core import capture_waitlist_email, csrf_protect, feature_gate, fetch_all, fetch_one
|
from ..core import capture_waitlist_email, csrf_protect, feature_gate, fetch_all, fetch_one
|
||||||
from ..i18n import get_translations
|
from ..i18n import get_translations
|
||||||
@@ -230,6 +230,15 @@ async def article_page(url_path: str):
|
|||||||
(clean_path, lang),
|
(clean_path, lang),
|
||||||
)
|
)
|
||||||
if not article:
|
if not article:
|
||||||
|
# If a scheduled (not yet live) article exists at this URL, redirect to
|
||||||
|
# the nearest parent path rather than showing a bare 404.
|
||||||
|
scheduled = await fetch_one(
|
||||||
|
"SELECT 1 FROM articles WHERE url_path = ? AND language = ?",
|
||||||
|
(clean_path, lang),
|
||||||
|
)
|
||||||
|
if scheduled:
|
||||||
|
parent = clean_path.rsplit("/", 1)[0] or f"/{lang}/markets"
|
||||||
|
return redirect(parent, 302)
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
# SSG articles: language-prefixed build path
|
# SSG articles: language-prefixed build path
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ url_pattern: "/markets/{{ country_slug }}/{{ city_slug }}"
|
|||||||
title_pattern: "{% if language == 'de' %}Padel in {{ city_name }} — Investitionskosten & Marktanalyse {{ 'now' | datetimeformat('%Y') }}{% else %}Padel in {{ city_name }} — Investment Costs & Market Analysis {{ 'now' | datetimeformat('%Y') }}{% endif %}"
|
title_pattern: "{% if language == 'de' %}Padel in {{ city_name }} — Investitionskosten & Marktanalyse {{ 'now' | datetimeformat('%Y') }}{% else %}Padel in {{ city_name }} — Investment Costs & Market Analysis {{ 'now' | datetimeformat('%Y') }}{% endif %}"
|
||||||
meta_description_pattern: "{% if language == 'de' %}Lohnt sich eine Padelhalle in {{ city_name }}? {{ padel_venue_count }} Anlagen, <span style=\"font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em\">padelnomics</span> Market Score {{ market_score | round(1) }}/100 und ein vollständiges Finanzmodell. Stand {{ 'now' | datetimeformat('%B %Y') }}.{% else %}Is {{ city_name }} worth building a padel center in? {{ padel_venue_count }} venues, <span style=\"font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em\">padelnomics</span> Market Score {{ market_score | round(1) }}/100, and a full financial model. Updated {{ 'now' | datetimeformat('%B %Y') }}.{% endif %}"
|
meta_description_pattern: "{% if language == 'de' %}Lohnt sich eine Padelhalle in {{ city_name }}? {{ padel_venue_count }} Anlagen, <span style=\"font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em\">padelnomics</span> Market Score {{ market_score | round(1) }}/100 und ein vollständiges Finanzmodell. Stand {{ 'now' | datetimeformat('%B %Y') }}.{% else %}Is {{ city_name }} worth building a padel center in? {{ padel_venue_count }} venues, <span style=\"font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em\">padelnomics</span> Market Score {{ market_score | round(1) }}/100, and a full financial model. Updated {{ 'now' | datetimeformat('%B %Y') }}.{% endif %}"
|
||||||
schema_type: [Article, FAQPage]
|
schema_type: [Article, FAQPage]
|
||||||
priority_column: population
|
priority_column: padel_venue_count
|
||||||
---
|
---
|
||||||
{% if language == "de" %}
|
{% if language == "de" %}
|
||||||
# Lohnt sich eine Padelhalle in {{ city_name }}?
|
# Lohnt sich eine Padelhalle in {{ city_name }}?
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ def _get_quote_steps(lang: str) -> list:
|
|||||||
{"n": 1, "title": t["q1_heading"], "required": ["facility_type"]},
|
{"n": 1, "title": t["q1_heading"], "required": ["facility_type"]},
|
||||||
{"n": 2, "title": t["q2_heading"], "required": ["country"]},
|
{"n": 2, "title": t["q2_heading"], "required": ["country"]},
|
||||||
{"n": 3, "title": t["q3_heading"], "required": []},
|
{"n": 3, "title": t["q3_heading"], "required": []},
|
||||||
{"n": 4, "title": t["q4_heading"], "required": []},
|
{"n": 4, "title": t["q4_heading"], "required": ["location_status"]},
|
||||||
{"n": 5, "title": t["q5_heading"], "required": ["timeline"]},
|
{"n": 5, "title": t["q5_heading"], "required": ["timeline"]},
|
||||||
{"n": 6, "title": t["q6_heading"], "required": ["financing_status", "decision_process"]},
|
{"n": 6, "title": t["q6_heading"], "required": ["financing_status", "decision_process"]},
|
||||||
{"n": 7, "title": t["q7_heading"], "required": ["stakeholder_type"]},
|
{"n": 7, "title": t["q7_heading"], "required": ["stakeholder_type"]},
|
||||||
|
|||||||
@@ -275,7 +275,7 @@
|
|||||||
|
|
||||||
/* Cards (replace Pico <article>) */
|
/* Cards (replace Pico <article>) */
|
||||||
.card {
|
.card {
|
||||||
@apply bg-white border border-light-gray rounded-2xl p-6 mb-6 shadow-sm;
|
@apply bg-white border border-light-gray rounded-2xl p-6 mb-6 shadow-sm overflow-hidden;
|
||||||
}
|
}
|
||||||
.card-header {
|
.card-header {
|
||||||
@apply border-b border-light-gray pb-3 mb-4 text-sm text-slate font-medium;
|
@apply border-b border-light-gray pb-3 mb-4 text-sm text-slate font-medium;
|
||||||
|
|||||||
Reference in New Issue
Block a user