Compare commits
625 Commits
cssx
...
719da7914e
| Author | SHA1 | Date | |
|---|---|---|---|
| 719da7914e | |||
| c6a662c980 | |||
| e475222099 | |||
| b4df216fae | |||
| 9b4f735a0e | |||
| 293af75821 | |||
| ebb3445667 | |||
| 8f146cc810 | |||
| c67adaceaf | |||
| a2ab12a1d5 | |||
| 5a03943b39 | |||
| c20369b766 | |||
| 237ac234df | |||
| 4b21efc43c | |||
| 1ea80a2b71 | |||
| c3aee94c8f | |||
| 1800b80316 | |||
| 1a5dbc2800 | |||
| 7cde140c7e | |||
| 72eaefac13 | |||
| 7036621be8 | |||
| 05f7b10864 | |||
| 8ed8134d66 | |||
| f8a8e1eeb0 | |||
| 1a3d7b3d77 | |||
| ab015fa2fd | |||
| b3a7df45e6 | |||
| e2940e1c5f | |||
| f7debec7c6 | |||
| 488fc53fda | |||
| cb4f4b85e5 | |||
| a759f4da3b | |||
| b03c84b962 | |||
| 4dd9968264 | |||
| 7cc1bffc23 | |||
| 169097097c | |||
| a7638e48d5 | |||
| 93e140280b | |||
| 07bf5a1142 | |||
| 623f947b52 | |||
| 41f4772ba7 | |||
| ae1ba46b44 | |||
| 0047757af8 | |||
| b3cba5e281 | |||
| 48d493e9cc | |||
| 7556cc303d | |||
| 919998be1c | |||
| 2211655060 | |||
| d0a5ce1070 | |||
| 6581211a10 | |||
| 455e48df07 | |||
| 30d9d4aa4c | |||
| b06cc2daca | |||
| 4b746e4c8b | |||
| f96506024e | |||
| 203f9a49a1 | |||
| 893c767238 | |||
| 5c4a8c8cc2 | |||
| 90febbd91e | |||
| f3a9f3ccc0 | |||
| dcc73a68d5 | |||
| 1765216335 | |||
| 11fdd1a840 | |||
| 6ca46bb295 | |||
| e1a5e3eb89 | |||
| aef990735f | |||
| 04d3b2ecaf | |||
| c4a999d0d0 | |||
| 2de4ba8c57 | |||
| ee969a343c | |||
| 400d6d4086 | |||
| dbf16929fa | |||
| 859aad4333 | |||
| c95e320825 | |||
| 427dee13f0 | |||
| a7de0e9410 | |||
| 214963ea6a | |||
| 2fc391696c | |||
| 28a6560963 | |||
| cee0ca7667 | |||
| 98036b2292 | |||
| 6d0c0b2230 | |||
| 9d0bd3b0e7 | |||
| 2329533d1a | |||
| 085f959323 | |||
| fe911625e3 | |||
| 9806aec60c | |||
| 36b070f796 | |||
| ae6c6d06a7 | |||
| 846719908f | |||
| 301bb8e585 | |||
| d42972518a | |||
| 071869331f | |||
| 2fd64351d0 | |||
| 9096476402 | |||
| 0847824935 | |||
| b31eb393c4 | |||
| 2c97542ee8 | |||
| 04539675d8 | |||
| 1d1e7f30bb | |||
| 56dfff8299 | |||
| f52b9e880b | |||
| a0d78e44d5 | |||
| 9284a946ba | |||
| 11ea641f7b | |||
| c3430ade90 | |||
| 1f22f3fcd5 | |||
| 8100dc5fc9 | |||
| 5f6600f572 | |||
| ea2b71cfa3 | |||
| 41097eeef9 | |||
| c2efa192c5 | |||
| 100450772f | |||
| 7c969f9192 | |||
| bc1ea0128f | |||
| 0358b6ec9e | |||
| a2d8fb0f0f | |||
| cedff42d15 | |||
| 1324e984ef | |||
| 5f06e2e2cc | |||
| b9d85bd797 | |||
| 1dd2d73766 | |||
| 355f57a60b | |||
| c6a4a6f65c | |||
| 6186cd1c53 | |||
| 1647921895 | |||
| b0920a1121 | |||
| de80d921e9 | |||
| acd2fa6541 | |||
| b23e81730c | |||
| 7a1d1e9ea2 | |||
| 9f2f4377b9 | |||
| f759cd6688 | |||
| 2076e1805f | |||
| feecbb66ba | |||
| da1ca6009a | |||
| 0cc2f178a9 | |||
| 2d3c79d999 | |||
| 78b4d0f1ac | |||
| c440c26292 | |||
| 33586024a7 | |||
| 1fce4970fb | |||
| 17c58a2b5b | |||
| c23d0888ea | |||
| 95e42f9a87 | |||
| 1b6612fd08 | |||
| 00cf6bbd75 | |||
| 6a68894f7d | |||
| ac72a4de8d | |||
| 2dc13ab34f | |||
| 7515634901 | |||
| c5a4340293 | |||
| 365440d42f | |||
| fe36877c71 | |||
| 4aa2133b39 | |||
| c2d9a3d2b1 | |||
| 575d100f67 | |||
| 56f49f29fb | |||
| e046542aa0 | |||
| 89e8645d8f | |||
| fba84540e2 | |||
| 4e96997e09 | |||
| 2f42e8826c | |||
| 524c99e4ff | |||
| 0f9b449315 | |||
| a69604acaf | |||
| ce7ad125b6 | |||
| 8f88e52b27 | |||
| b8018ba385 | |||
| 95ffc0ecb7 | |||
| 477ce766ff | |||
| 98c1023b81 | |||
| b99e69d1bb | |||
| a425ea8ed4 | |||
| c82941d93c | |||
| 9b38ef2ce9 | |||
| 4d54be6b6b | |||
| 5d5512e74a | |||
| 8a530569a2 | |||
| b82fd7822d | |||
| e5dbe9f3da | |||
| 0174fbfea3 | |||
| cd7653d8c3 | |||
| ff6c1fab71 | |||
| e843602ac9 | |||
| c95e19dcf2 | |||
| 29c90a625b | |||
| 4c4806c8dd | |||
| d8cddbd971 | |||
| 3906ab3558 | |||
| 46cd179703 | |||
| 5d3676d751 | |||
| 86363d9f34 | |||
| 8586f54dcb | |||
| f54ebf26f8 | |||
| 0a7a9aa5ae | |||
| f1e0e0d0a3 | |||
| 1341c144da | |||
| e149dfe968 | |||
| b8c5426093 | |||
| 9b9fc6b6a5 | |||
| d5e416e478 | |||
| 8a5c115557 | |||
| 31a6e708fc | |||
| ec1093d372 | |||
| cad65bcdf1 | |||
| e6ca1a5f44 | |||
| fd4f13e571 | |||
| e5acfdcd3c | |||
| b4944aa2b6 | |||
| e4e8b45cb4 | |||
| db1691d8f5 | |||
| 192d48d0e3 | |||
| c0ced8a40f | |||
| ff41fa2238 | |||
| 00e7ba4650 | |||
| 7b8ae473a5 | |||
| 3ca89ef765 | |||
| 8b1333de96 | |||
| f9939a660c | |||
| 8be8926155 | |||
| 03ba8e58e5 | |||
| 56589a81b2 | |||
| 06adbdcd59 | |||
| 7efd1b401b | |||
| a496ee6ae6 | |||
| 6bda2bafa2 | |||
| 3103d7ff9d | |||
| 8683cf24c3 | |||
| efc7e340da | |||
| 09164e32ad | |||
| 189a0258d9 | |||
| 9a0173419a | |||
| 50a184faf2 | |||
| 4709c6bf49 | |||
| e15b5c9dbc | |||
| c55f0956bc | |||
| 5b70cd5cfc | |||
| 0da5dc41e1 | |||
| 57ff7705c7 | |||
| c344b0d7b0 | |||
| baa9d66a59 | |||
| cf2e386cda | |||
| fe289287ec | |||
| 26320abd64 | |||
| a97f4c0e39 | |||
| 391a0c675b | |||
| 145028ccc0 | |||
| c7c824c488 | |||
| 7f665d874c | |||
| 599964c39c | |||
| b2aaa3786d | |||
| 2d38a76f0b | |||
| 5f20a16aa0 | |||
| dba5bf05fa | |||
| 4c1853bc7b | |||
| 3cbdfd8f7f | |||
| 7f1dad6bfd | |||
| 0ce3f95d6c | |||
| 9a707dbe56 | |||
| 069d7e7090 | |||
| 09947262a5 | |||
| ec52e2116e | |||
| 657b631700 | |||
| 32ca059ed7 | |||
| 2da80c69ed | |||
| a8bfff9e0b | |||
| a70ff2b153 | |||
| 81d8e55fb0 | |||
| 179631130c | |||
| 5a4a0c0e1c | |||
| 621c0bbf42 | |||
| 5a68046bd8 | |||
| df1aa4e1d1 | |||
| 41c3b9f3b8 | |||
| f5e47678d5 | |||
| 6596fac758 | |||
| 299de98ea8 | |||
| e7a511d40a | |||
| aeac3c0b13 | |||
| 25edc7d64a | |||
| 5cca22ae6d | |||
| 260475a4da | |||
| 2c9d7c95a2 | |||
| fd03eeb0fe | |||
| 47448a6d37 | |||
| cdd775c999 | |||
| 7294f07f5b | |||
| dd774efc18 | |||
| 668a46bec0 | |||
| 9d70599416 | |||
| 309579aec7 | |||
| ca0ea69ca1 | |||
| 44095c0a04 | |||
| 5991a5b397 | |||
| b9b315c86f | |||
| ccf9a155ad | |||
| fa70c5f297 | |||
| 3574f7e163 | |||
| 6312eb66a2 | |||
| 917a487195 | |||
| 605aafa2eb | |||
| 7f466f0fd6 | |||
| 6421a23223 | |||
| 342da2bd44 | |||
| a05d642461 | |||
| 1fe258e3f7 | |||
| bec0397c3c | |||
| 85083a0fff | |||
| fab9bffc49 | |||
| d618530f29 | |||
| 624d1872e3 | |||
| 3b3c904953 | |||
| 3119b8e310 | |||
| aab1f3e966 | |||
| 79025b9913 | |||
| 99a78a70b3 | |||
| 72148fa4c0 | |||
| 84f66557df | |||
| b6ba7ad6be | |||
| 6f403c0c2d | |||
| 3ab26635ce | |||
| 9b3b2ea224 | |||
| 3a12368c9d | |||
| bec881acb3 | |||
| e89c496dc8 | |||
| 7eb158c79f | |||
| e9d86d628b | |||
| 754e7557f5 | |||
| f674a5edcc | |||
| e09bc3b601 | |||
| 43f2547de8 | |||
| 8366088ee1 | |||
| fd20811afa | |||
| 84ea5d4c16 | |||
| 51990d9445 | |||
| 0d6b959045 | |||
| 847d5d1f31 | |||
| ff2ef29d8a | |||
| ab27491157 | |||
| aa67b036c7 | |||
| 9ac90a787d | |||
| cb0990feb3 | |||
| 8c89311182 | |||
| a745de7e35 | |||
| a5f5373a63 | |||
| c2a85ed026 | |||
| 69ced865db | |||
| 2b0a45b337 | |||
| feb368f7fb | |||
| 6215d3573b | |||
| 79fa1411dc | |||
| 04ff03f5d4 | |||
| b85a46bb62 | |||
| 09d06a4c87 | |||
| 6655f638b9 | |||
| 2c56d3e14b | |||
| fa295acfe3 | |||
| 28ee441d9a | |||
| 1387d97c82 | |||
| b90cc59029 | |||
| 59c935e394 | |||
| c15dbc3242 | |||
| ece2aa225d | |||
| ac1dc34dad | |||
| 9278be9fe2 | |||
| f36583b620 | |||
| 6772f1141f | |||
| 60b58fdff7 | |||
| d3617ab7f3 | |||
| 732923a7ef | |||
| b1f9e41027 | |||
| a657d0831c | |||
| 9d0cffb84d | |||
| eee2954559 | |||
| b9003eacb2 | |||
| 7229335d22 | |||
| e38534a898 | |||
| daf76c3e5b | |||
| 093050059d | |||
| 6a5cb31123 | |||
| bcb58d340f | |||
| b98a8f8c41 | |||
| 14c5316d17 | |||
| 3b00a7095a | |||
| 719dfbf732 | |||
| 5ea0f5c546 | |||
| 74428cc433 | |||
| d1a47e1e52 | |||
| 3d191099e0 | |||
| 70cf501c49 | |||
| 2a978e6e9f | |||
| 3a8ee0dbd6 | |||
| c346f525d2 | |||
| 79ee3bc46e | |||
| c80b5d674f | |||
| f08bd403de | |||
| 227444a026 | |||
| 2660d37f9e | |||
| d850f7c9c1 | |||
| bc9d9e51c9 | |||
| eb70e7237e | |||
| a7d09291b8 | |||
| 2d5096be6c | |||
| f70861c175 | |||
| 78c3ff30dd | |||
| 756162b63f | |||
| 0385be0a0d | |||
| 1e52bb33a6 | |||
| a8e61dd0ea | |||
| 20ac0fe948 | |||
| 2aa0f1d010 | |||
| a2d0a8a0fa | |||
| b8d3e46a9b | |||
| 3749fe9625 | |||
| dd1c1c9a3c | |||
| cf5e767510 | |||
| 631394989c | |||
| a0e39f0014 | |||
| 55adbf6463 | |||
| fbfd203746 | |||
| 65ed8a8941 | |||
| 54814b4258 | |||
| 3482cbdaa6 | |||
| 0ba7ebe349 | |||
| 652e7f81c8 | |||
| 8ff9827d7b | |||
| 07a73821e7 | |||
| 44d5414bc6 | |||
| a90c8bf3fc | |||
| a06400370a | |||
| 0191948b6e | |||
| 9ac1d273e2 | |||
| e36a036873 | |||
| d6ca185975 | |||
| 0ebf3c27fd | |||
| 4c97b03dda | |||
| 6739343a06 | |||
| 2866bcbfc3 | |||
| 1fe53c2032 | |||
| 59a8d2063d | |||
| 624b08997d | |||
| e112bffe5c | |||
| e6cada972e | |||
| 6aa2f3f6bd | |||
| 6c27ebd3b4 | |||
| f77d7350dd | |||
| ca8de3be1a | |||
| 31ace8768e | |||
| f34e55aa9b | |||
| 102a27e845 | |||
| 12fe93bb55 | |||
| 0693586e6f | |||
| cfde5bc491 | |||
| abeb4551da | |||
| 04366990ec | |||
| 54adc9c216 | |||
| 38f1f82988 | |||
| bb5c7e8444 | |||
| a40dd06811 | |||
| ef04beba00 | |||
| 4ed879bc84 | |||
| d076fc1465 | |||
| 17767ed8c4 | |||
| 5aa13a99d1 | |||
| 6328e3d680 | |||
| 7982a07f94 | |||
| 4534fb9fee | |||
| c43f774992 | |||
| 9cde15c3ce | |||
| 6a98c39937 | |||
| 60ed828e0e | |||
| 0f4520d987 | |||
| 5fff83ae79 | |||
| 1797bd4b16 | |||
| 436848060d | |||
| c1ad6fd8d4 | |||
| cea009084f | |||
| af77fc32c7 | |||
| d696735f95 | |||
| bea071a039 | |||
| 1c7346ab37 | |||
| d07a408c89 | |||
| eac0fce8f7 | |||
| 639f96fe6b | |||
| d4b23aae4c | |||
| 3197022299 | |||
| 7c99002345 | |||
| 157a32b426 | |||
| ab50fb5f56 | |||
| daeecab310 | |||
| 7ecbf19c11 | |||
| 6fa843016b | |||
| 4a515f1a0d | |||
| 824396c7b0 | |||
| dea4f52454 | |||
| a9526c4fa1 | |||
| 4a3a510a23 | |||
| e1ae81f736 | |||
| 8c69e329e0 | |||
| 235428628a | |||
| 64aa417d63 | |||
| 2a04aaad5e | |||
| 51ebf347ba | |||
| 1d59023571 | |||
| 877e776977 | |||
| 1560207097 | |||
| aed4c03537 | |||
| dfccd113fc | |||
| b15025befd | |||
| 0144220427 | |||
| c71ca6754d | |||
| e81d77437e | |||
| 36a0bd8577 | |||
| 4298d5be16 | |||
| 1077fae815 | |||
| 57a31a3b83 | |||
| 1db52472e3 | |||
| 278ae3e8f6 | |||
| ad75798ab7 | |||
| 0456b3d25c | |||
| 959e63d440 | |||
| 57e0d0c341 | |||
| 7fda7a8027 | |||
| 8be00df6d9 | |||
| ad6a8ecb17 | |||
| 8772d59d84 | |||
| ece30fb1d2 | |||
| 5344b382a5 | |||
| 0e0a42ac04 | |||
| 9cbfb09b41 | |||
| 5690bb0388 | |||
| 8eaf4026ab | |||
| 76bc293faa | |||
| 992a9e1731 | |||
| 03d7b29745 | |||
| 9a9999d2e1 | |||
| 015469e401 | |||
| 2258a0790b | |||
| 527c4186ee | |||
| 0b4443f394 | |||
| 4939884f25 | |||
| e23d73d1b1 | |||
| 715df11f82 | |||
| 69d328b20f | |||
| 121aa30f32 | |||
| be3e86d8d6 | |||
| 1dbf600af2 | |||
| 9be8a38fe9 | |||
| a30e7228d8 | |||
| 2f26437004 | |||
| e4bfd46c48 | |||
| 2e23feb09e | |||
| 45c5e4a0db | |||
| a84916e82f | |||
| f5c266e785 | |||
| d551806976 | |||
| 2663dfb095 | |||
| ccd9b969ea | |||
| 7325bb9ecf | |||
| 6f3562707a | |||
| 2609e782fc | |||
| 28cbe60dc6 | |||
| 0f82294dc1 | |||
| 19d59f5f4b | |||
| 28388540d5 | |||
| 5fac47c132 | |||
| 213421516e | |||
| 3bffc212cc | |||
| b51b050dda | |||
| 5bb02b7dd5 | |||
| 16f0908ec9 | |||
| 7419ecf3c0 | |||
| 31a8b755d9 | |||
| 049796c391 | |||
| 8578eb525e | |||
| 96a4f56424 | |||
| e72f7485f4 | |||
| da8d2e342f | |||
| fd67f202c2 | |||
| 5069072715 | |||
| a3318b4fd7 | |||
| 8a945db37b | |||
| 03f9968979 | |||
| 96132d9cfe | |||
| baf9f1468d | |||
| c2fe142039 | |||
| f0fbcef3f6 | |||
| d7f9afff8e | |||
| f2910ad767 | |||
| e75c8d16d1 | |||
| 984e2ebed0 | |||
| d80894dbf5 | |||
| 8e16cc459a | |||
| 336a4ad9a1 | |||
| d6f3250a77 | |||
| 486ab834de | |||
| 41e803335a | |||
| 1f36987f77 | |||
| e53e8cc1f7 | |||
| 418ac9424f | |||
| fb8f115acb | |||
| 63b895afd8 | |||
| 50b33ab08e | |||
| bd314a0be7 | |||
| 41cdd6eab8 | |||
| 1a6503782d | |||
| 72997068c6 | |||
| dacb61b0ae | |||
| 400667b15a | |||
| 44503a7d9b | |||
| e085fe43b4 | |||
| 0554f8a113 | |||
| 4e5f9ff16c | |||
| 193578ef88 | |||
| 03f0929fdf | |||
| f551fc7453 | |||
| e30cb0a992 | |||
| 293f7713d6 | |||
| 4ba63bda17 | |||
| 0a81a2af01 | |||
| 0c9dbd6657 | |||
| a4377668be | |||
| a98354c0f0 | |||
| df8b19ccb8 |
278
.claude/plans/sx-ci-pipeline.md
Normal file
278
.claude/plans/sx-ci-pipeline.md
Normal file
@@ -0,0 +1,278 @@
|
||||
# SX CI Pipeline — Build/Test/Deploy in S-Expressions
|
||||
|
||||
## Context
|
||||
|
||||
Rose Ash currently uses shell scripts for CI:
|
||||
- `deploy.sh` — auto-detect changed apps via git diff, build Docker images, push to registry, restart services
|
||||
- `dev.sh` — start/stop dev environment, run tests
|
||||
- `test/Dockerfile.unit` — headless test runner in Docker
|
||||
- Tailwind CSS build via CLI command with v3 config
|
||||
- SX bootstrapping via `python bootstrap_js.py`, `python bootstrap_py.py`, `python bootstrap_test.py`
|
||||
|
||||
These work, but they are opaque shell scripts with imperative logic, no reuse, and no relationship to the language the application is written in. The CI pipeline is the one remaining piece of Rose Ash infrastructure that is not expressed in s-expressions.
|
||||
|
||||
## Goal
|
||||
|
||||
Replace the shell-based CI with pipeline definitions written in SX. The pipeline runner is a minimal Python CLI that evaluates `.sx` files using the SX spec. Pipeline steps are s-expressions. Conditionals, composition, and reuse are the same language constructs used everywhere else in the codebase.
|
||||
|
||||
This is not a generic CI framework — it is a project-specific pipeline that happens to be written in SX, proving the "one representation for everything" claim from the essays.
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **Same language** — Pipeline definitions use the same SX syntax, parser, and evaluator as the application
|
||||
2. **Boundary-enforced** — CI primitives (shell, docker, git) are IO primitives declared in boundary.sx, sandboxed like everything else
|
||||
3. **Composable** — Pipeline steps are components; complex pipelines compose them by nesting
|
||||
4. **Self-testing** — The pipeline can run the SX spec tests as a pipeline step, using the same spec that defines the pipeline language
|
||||
5. **Incremental** — Each phase is independently useful; shell scripts remain as fallback
|
||||
|
||||
## Implementation
|
||||
|
||||
### Phase 1: CI Spec + Runner
|
||||
|
||||
#### `shared/sx/ref/ci.sx` — CI primitives spec
|
||||
|
||||
Declare CI-specific IO primitives in the boundary:
|
||||
|
||||
```lisp
|
||||
;; Shell execution
|
||||
(define shell-run ...) ;; (shell-run "pytest shared/ -v") → {:exit 0 :stdout "..." :stderr "..."}
|
||||
(define shell-run! ...) ;; Like shell-run but throws on non-zero exit
|
||||
|
||||
;; Docker
|
||||
(define docker-build ...) ;; (docker-build :file "sx/Dockerfile" :tag "registry/sx:latest" :context ".")
|
||||
(define docker-push ...) ;; (docker-push "registry/sx:latest")
|
||||
(define docker-restart ...) ;; (docker-restart "coop_sx_docs")
|
||||
|
||||
;; Git
|
||||
(define git-diff-files ...) ;; (git-diff-files "HEAD~1" "HEAD") → ("shared/sx/parser.py" "sx/sx/essays.sx")
|
||||
(define git-branch ...) ;; (git-branch) → "macros"
|
||||
|
||||
;; Filesystem
|
||||
(define file-exists? ...) ;; (file-exists? "sx/Dockerfile") → true
|
||||
(define read-file ...) ;; (read-file "version.txt") → "1.2.3"
|
||||
|
||||
;; Pipeline control
|
||||
(define log-step ...) ;; (log-step "Building sx_docs") — formatted output
|
||||
(define fail! ...) ;; (fail! "Unit tests failed") — abort pipeline
|
||||
```
|
||||
|
||||
#### `sx-ci` — CLI runner
|
||||
|
||||
Minimal Python script (~100 lines):
|
||||
1. Loads SX evaluator (sx_ref.py)
|
||||
2. Registers CI IO primitives (subprocess, docker SDK, git)
|
||||
3. Evaluates the pipeline `.sx` file
|
||||
4. Exit code = pipeline result
|
||||
|
||||
```bash
|
||||
python -m shared.sx.ci pipeline/deploy.sx
|
||||
python -m shared.sx.ci pipeline/test.sx
|
||||
```
|
||||
|
||||
### Phase 2: Pipeline Definitions
|
||||
|
||||
#### `pipeline/services.sx` — Service registry (data)
|
||||
|
||||
```lisp
|
||||
(define services
|
||||
(list
|
||||
{:name "blog" :dir "blog" :compose "blog" :port 8001}
|
||||
{:name "market" :dir "market" :compose "market" :port 8002}
|
||||
{:name "cart" :dir "cart" :compose "cart" :port 8003}
|
||||
{:name "events" :dir "events" :compose "events" :port 8004}
|
||||
{:name "federation" :dir "federation" :compose "federation" :port 8005}
|
||||
{:name "account" :dir "account" :compose "account" :port 8006}
|
||||
{:name "relations" :dir "relations" :compose "relations" :port 8008}
|
||||
{:name "likes" :dir "likes" :compose "likes" :port 8009}
|
||||
{:name "orders" :dir "orders" :compose "orders" :port 8010}
|
||||
{:name "sx_docs" :dir "sx" :compose "sx_docs" :port 8011}))
|
||||
|
||||
(define registry "registry.rose-ash.com:5000")
|
||||
```
|
||||
|
||||
#### `pipeline/steps.sx` — Reusable step components
|
||||
|
||||
```lisp
|
||||
(defcomp ~detect-changed (&key base)
|
||||
;; Returns list of services whose source dirs have changes
|
||||
(let ((files (git-diff-files (or base "HEAD~1") "HEAD")))
|
||||
(if (some (fn (f) (starts-with? f "shared/")) files)
|
||||
services ;; shared changed → rebuild all
|
||||
(filter (fn (svc)
|
||||
(some (fn (f) (starts-with? f (str (get svc "dir") "/"))) files))
|
||||
services))))
|
||||
|
||||
(defcomp ~unit-tests ()
|
||||
(log-step "Running unit tests")
|
||||
(shell-run! "docker build -f test/Dockerfile.unit -t rose-ash-test-unit:latest . -q")
|
||||
(shell-run! "docker run --rm rose-ash-test-unit:latest"))
|
||||
|
||||
(defcomp ~sx-spec-tests ()
|
||||
(log-step "Running SX spec tests")
|
||||
(shell-run! "cd shared/sx/ref && python bootstrap_test.py")
|
||||
(shell-run! "node shared/sx/ref/test_sx_ref.js"))
|
||||
|
||||
(defcomp ~bootstrap-check ()
|
||||
(log-step "Checking bootstrapped files are up to date")
|
||||
;; Rebootstrap and check for diff
|
||||
(shell-run! "python shared/sx/ref/bootstrap_js.py")
|
||||
(shell-run! "python shared/sx/ref/bootstrap_py.py")
|
||||
(let ((diff (shell-run "git diff --name-only shared/static/scripts/sx-ref.js shared/sx/ref/sx_ref.py")))
|
||||
(when (not (= (get diff "stdout") ""))
|
||||
(fail! "Bootstrapped files are stale — rebootstrap and commit"))))
|
||||
|
||||
(defcomp ~tailwind-check ()
|
||||
(log-step "Checking tw.css is up to date")
|
||||
(shell-run! "cat <<'CSS' | npx tailwindcss -i /dev/stdin -o /tmp/tw-check.css --minify -c shared/static/styles/tailwind.config.js\n@tailwind base;\n@tailwind components;\n@tailwind utilities;\nCSS")
|
||||
(let ((diff (shell-run "diff shared/static/styles/tw.css /tmp/tw-check.css")))
|
||||
(when (not (= (get diff "exit") 0))
|
||||
(log-step "WARNING: tw.css may be stale"))))
|
||||
|
||||
(defcomp ~build-service (&key service)
|
||||
(let ((name (get service "name"))
|
||||
(dir (get service "dir"))
|
||||
(tag (str registry "/" name ":latest")))
|
||||
(log-step (str "Building " name))
|
||||
(docker-build :file (str dir "/Dockerfile") :tag tag :context ".")
|
||||
(docker-push tag)))
|
||||
|
||||
(defcomp ~restart-service (&key service)
|
||||
(let ((name (get service "compose")))
|
||||
(log-step (str "Restarting coop_" name))
|
||||
(docker-restart (str "coop_" name))))
|
||||
```
|
||||
|
||||
#### `pipeline/test.sx` — Test pipeline
|
||||
|
||||
```lisp
|
||||
(load "pipeline/steps.sx")
|
||||
|
||||
(do
|
||||
(~unit-tests)
|
||||
(~sx-spec-tests)
|
||||
(~bootstrap-check)
|
||||
(~tailwind-check)
|
||||
(log-step "All checks passed"))
|
||||
```
|
||||
|
||||
#### `pipeline/deploy.sx` — Deploy pipeline
|
||||
|
||||
```lisp
|
||||
(load "pipeline/services.sx")
|
||||
(load "pipeline/steps.sx")
|
||||
|
||||
(let ((targets (if (= (length ARGS) 0)
|
||||
(~detect-changed :base "HEAD~1")
|
||||
(filter (fn (svc) (some (fn (a) (= a (get svc "name"))) ARGS)) services))))
|
||||
(when (= (length targets) 0)
|
||||
(log-step "No changes detected")
|
||||
(exit 0))
|
||||
|
||||
(log-step (str "Deploying: " (join " " (map (fn (s) (get s "name")) targets))))
|
||||
|
||||
;; Tests first
|
||||
(~unit-tests)
|
||||
(~sx-spec-tests)
|
||||
|
||||
;; Build and push
|
||||
(for-each (fn (svc) (~build-service :service svc)) targets)
|
||||
|
||||
;; Restart
|
||||
(for-each (fn (svc) (~restart-service :service svc)) targets)
|
||||
|
||||
(log-step "Deploy complete"))
|
||||
```
|
||||
|
||||
### Phase 3: Boundary Integration
|
||||
|
||||
Add CI primitives to `boundary.sx`:
|
||||
|
||||
```lisp
|
||||
;; CI primitives (pipeline runner only — not available in web context)
|
||||
(io-primitive shell-run (command) -> dict)
|
||||
(io-primitive shell-run! (command) -> dict)
|
||||
(io-primitive docker-build (&key file tag context) -> nil)
|
||||
(io-primitive docker-push (tag) -> nil)
|
||||
(io-primitive docker-restart (service) -> nil)
|
||||
(io-primitive git-diff-files (base head) -> list)
|
||||
(io-primitive git-branch () -> string)
|
||||
(io-primitive file-exists? (path) -> boolean)
|
||||
(io-primitive read-file (path) -> string)
|
||||
(io-primitive log-step (message) -> nil)
|
||||
(io-primitive fail! (message) -> nil)
|
||||
```
|
||||
|
||||
These are only registered by the CI runner, never by the web app. The boundary enforces that web components cannot call `shell-run!`.
|
||||
|
||||
### Phase 4: Bootstrap + Runner Implementation
|
||||
|
||||
#### `shared/sx/ci.py` — Runner module
|
||||
|
||||
```python
|
||||
"""SX CI pipeline runner.
|
||||
|
||||
Usage: python -m shared.sx.ci pipeline/deploy.sx [args...]
|
||||
"""
|
||||
import sys, subprocess, os
|
||||
from .ref.sx_ref import evaluate, parse, create_env
|
||||
from .ref.boundary_parser import parse_boundary
|
||||
|
||||
def register_ci_primitives(env):
|
||||
"""Register CI IO primitives into the evaluation environment."""
|
||||
# shell-run, docker-build, git-diff-files, etc.
|
||||
# Each is a thin Python wrapper around subprocess/docker SDK
|
||||
...
|
||||
|
||||
def main():
|
||||
pipeline_file = sys.argv[1]
|
||||
args = sys.argv[2:]
|
||||
|
||||
env = create_env()
|
||||
register_ci_primitives(env)
|
||||
env_set(env, "ARGS", args)
|
||||
|
||||
with open(pipeline_file) as f:
|
||||
source = f.read()
|
||||
|
||||
result = evaluate(parse(source), env)
|
||||
sys.exit(0 if result else 1)
|
||||
```
|
||||
|
||||
### Phase 5: Documentation + Essay Section
|
||||
|
||||
- Add a section to the "No Alternative" essay about CI pipelines as proof of universality
|
||||
- Add a plan page at `/plans/sx-ci` documenting the pipeline architecture
|
||||
- The pipeline definitions themselves serve as examples of SX beyond web rendering
|
||||
|
||||
## Files
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `shared/sx/ref/ci.sx` | **NEW** — CI primitive declarations |
|
||||
| `shared/sx/ci.py` | **NEW** — Pipeline runner (~150 lines) |
|
||||
| `shared/sx/ci_primitives.py` | **NEW** — CI IO primitive implementations |
|
||||
| `pipeline/services.sx` | **NEW** — Service registry data |
|
||||
| `pipeline/steps.sx` | **NEW** — Reusable pipeline step components |
|
||||
| `pipeline/test.sx` | **NEW** — Test pipeline |
|
||||
| `pipeline/deploy.sx` | **NEW** — Deploy pipeline |
|
||||
| `shared/sx/ref/boundary.sx` | Add CI primitive declarations |
|
||||
| `sx/sx/plans.sx` | Add plan page |
|
||||
| `sx/sx/essays.sx` | Add CI section to "No Alternative" |
|
||||
|
||||
## Verification
|
||||
|
||||
1. `python -m shared.sx.ci pipeline/test.sx` — runs all checks, same results as manual
|
||||
2. `python -m shared.sx.ci pipeline/deploy.sx blog` — builds and deploys blog only
|
||||
3. `python -m shared.sx.ci pipeline/deploy.sx` — auto-detects changes, deploys affected services
|
||||
4. Pipeline output is readable: step names, pass/fail, timing
|
||||
5. Shell scripts remain as fallback — nothing is deleted
|
||||
|
||||
## Order of Implementation
|
||||
|
||||
1. Phase 1 first — get the runner evaluating simple pipeline files
|
||||
2. Phase 2 — define the actual pipeline steps
|
||||
3. Phase 3 — formal boundary declarations
|
||||
4. Phase 4 — full runner with all CI primitives
|
||||
5. Phase 5 — documentation and essay content
|
||||
|
||||
Each phase is independently committable and testable.
|
||||
@@ -7,6 +7,7 @@ on:
|
||||
env:
|
||||
REGISTRY: registry.rose-ash.com:5000
|
||||
APP_DIR: /root/rose-ash
|
||||
BUILD_DIR: /root/rose-ash-ci
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
@@ -33,23 +34,26 @@ jobs:
|
||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
run: |
|
||||
ssh "root@$DEPLOY_HOST" "
|
||||
cd ${{ env.APP_DIR }}
|
||||
|
||||
# Save current HEAD before updating
|
||||
OLD_HEAD=\$(git rev-parse HEAD 2>/dev/null || echo none)
|
||||
|
||||
git fetch origin ${{ github.ref_name }}
|
||||
# --- Build in isolated CI directory (never touch dev working tree) ---
|
||||
BUILD=${{ env.BUILD_DIR }}
|
||||
ORIGIN=\$(git -C ${{ env.APP_DIR }} remote get-url origin)
|
||||
if [ ! -d \"\$BUILD/.git\" ]; then
|
||||
git clone \"\$ORIGIN\" \"\$BUILD\"
|
||||
fi
|
||||
cd \"\$BUILD\"
|
||||
git fetch origin
|
||||
git reset --hard origin/${{ github.ref_name }}
|
||||
|
||||
NEW_HEAD=\$(git rev-parse HEAD)
|
||||
# Detect changes using push event SHAs (not local checkout state)
|
||||
BEFORE='${{ github.event.before }}'
|
||||
AFTER='${{ github.sha }}'
|
||||
|
||||
# Detect what changed
|
||||
REBUILD_ALL=false
|
||||
if [ \"\$OLD_HEAD\" = \"none\" ] || [ \"\$OLD_HEAD\" = \"\$NEW_HEAD\" ]; then
|
||||
# First deploy or CI re-run on same commit — rebuild all
|
||||
if [ -z \"\$BEFORE\" ] || [ \"\$BEFORE\" = '0000000000000000000000000000000000000000' ] || ! git cat-file -e \"\$BEFORE\" 2>/dev/null; then
|
||||
# New branch, force push, or unreachable parent — rebuild all
|
||||
REBUILD_ALL=true
|
||||
else
|
||||
CHANGED=\$(git diff --name-only \$OLD_HEAD \$NEW_HEAD)
|
||||
CHANGED=\$(git diff --name-only \$BEFORE \$AFTER)
|
||||
if echo \"\$CHANGED\" | grep -q '^shared/'; then
|
||||
REBUILD_ALL=true
|
||||
fi
|
||||
@@ -84,18 +88,32 @@ jobs:
|
||||
fi
|
||||
done
|
||||
|
||||
# Deploy swarm stack only on main branch
|
||||
# Deploy swarm stacks only on main branch
|
||||
if [ '${{ github.ref_name }}' = 'main' ]; then
|
||||
source .env
|
||||
docker stack deploy -c docker-compose.yml rose-ash
|
||||
source ${{ env.APP_DIR }}/.env
|
||||
docker stack deploy --resolve-image always -c docker-compose.yml rose-ash
|
||||
echo 'Waiting for swarm services to update...'
|
||||
sleep 10
|
||||
docker stack services rose-ash
|
||||
|
||||
# Deploy sx-web standalone stack (sx-web.org)
|
||||
SX_REBUILT=false
|
||||
if [ \"\$REBUILD_ALL\" = true ] || echo \"\$CHANGED\" | grep -q '^sx/'; then
|
||||
SX_REBUILT=true
|
||||
fi
|
||||
if [ \"\$SX_REBUILT\" = true ]; then
|
||||
echo 'Deploying sx-web stack (sx-web.org)...'
|
||||
docker stack deploy --resolve-image always -c /root/sx-web/docker-compose.yml sx-web
|
||||
sleep 5
|
||||
docker stack services sx-web
|
||||
docker service update --force caddy_caddy 2>/dev/null || true
|
||||
fi
|
||||
else
|
||||
echo 'Skipping swarm deploy (branch: ${{ github.ref_name }})'
|
||||
fi
|
||||
|
||||
# Dev stack always deployed (bind-mounted source + auto-reload)
|
||||
# Dev stack uses working tree (bind-mounted source + auto-reload)
|
||||
cd ${{ env.APP_DIR }}
|
||||
echo 'Deploying dev stack...'
|
||||
docker compose -p rose-ash-dev -f docker-compose.yml -f docker-compose.dev.yml up -d
|
||||
echo 'Dev stack deployed'
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
shared/sx/.cache/
|
||||
.env
|
||||
node_modules/
|
||||
*.egg-info/
|
||||
@@ -10,3 +11,6 @@ build/
|
||||
venv/
|
||||
_snapshot/
|
||||
_debug/
|
||||
sx-haskell/
|
||||
sx-rust/
|
||||
shared/static/scripts/sx-full-test.js
|
||||
|
||||
84
CLAUDE.md
84
CLAUDE.md
@@ -5,6 +5,7 @@ Cooperative web platform: federated content, commerce, events, and media process
|
||||
## Deployment
|
||||
|
||||
- **Do NOT push** until explicitly told to. Pushes reload code to dev automatically.
|
||||
- **NEVER push to `main`** — pushing to main triggers a **PRODUCTION deploy**. Only push to main when the user explicitly requests a production deploy. Work on the `macros` branch by default; merge to main only with explicit permission.
|
||||
|
||||
## Project Structure
|
||||
|
||||
@@ -52,6 +53,65 @@ artdag/
|
||||
test/ # Integration & e2e tests
|
||||
```
|
||||
|
||||
## SX Language — Canonical Reference
|
||||
|
||||
The SX language is defined by a self-hosting specification in `shared/sx/ref/`. **Read these files for authoritative SX semantics** — they supersede any implementation detail in `sx.js` or Python evaluators.
|
||||
|
||||
### Specification files
|
||||
|
||||
- **`shared/sx/ref/eval.sx`** — Core evaluator: types, trampoline (TCO), `eval-expr` dispatch, special forms (`if`, `when`, `cond`, `case`, `let`, `and`, `or`, `lambda`, `define`, `defcomp`, `defmacro`, `quasiquote`), higher-order forms (`map`, `filter`, `reduce`, `some`, `every?`, `for-each`), macro expansion, function/lambda/component calling.
|
||||
- **`shared/sx/ref/parser.sx`** — Tokenizer and parser: grammar, string escapes, dict literals `{:key val}`, quote sugar (`` ` ``, `,`, `,@`), serializer.
|
||||
- **`shared/sx/ref/primitives.sx`** — All ~80 built-in pure functions: arithmetic, comparison, predicates, string ops, collection ops, dict ops, format helpers, CSSX style primitives.
|
||||
- **`shared/sx/ref/render.sx`** — Three rendering modes: `render-to-html` (server HTML), `render-to-sx`/`aser` (SX wire format for client), `render-to-dom` (browser). HTML tag registry, void elements, boolean attrs.
|
||||
- **`shared/sx/ref/bootstrap_js.py`** — Transpiler: reads the `.sx` spec files and emits `sx-ref.js`.
|
||||
|
||||
### Type system
|
||||
|
||||
```
|
||||
number, string, boolean, nil, symbol, keyword, list, dict,
|
||||
lambda, component, macro, thunk (TCO deferred eval)
|
||||
```
|
||||
|
||||
### Evaluation rules (from eval.sx)
|
||||
|
||||
1. **Literals** (number, string, boolean, nil) — pass through
|
||||
2. **Symbols** — look up in env, then primitives, then `true`/`false`/`nil`, else error
|
||||
3. **Keywords** — evaluate to their string name
|
||||
4. **Dicts** — evaluate all values recursively
|
||||
5. **Lists** — dispatch on head:
|
||||
- Special forms (`if`, `when`, `cond`, `case`, `let`, `lambda`, `define`, `defcomp`, `defmacro`, `quote`, `quasiquote`, `begin`/`do`, `set!`, `->`)
|
||||
- Higher-order forms (`map`, `filter`, `reduce`, `some`, `every?`, `for-each`, `map-indexed`)
|
||||
- Macros — expand then re-evaluate
|
||||
- Function calls — evaluate head and args, then: native callable → `apply`, lambda → bind params + TCO thunk, component → parse keyword args + bind params + TCO thunk
|
||||
|
||||
### Component calling convention
|
||||
|
||||
```lisp
|
||||
(defcomp ~card (&key title subtitle &rest children)
|
||||
(div :class "card"
|
||||
(h2 title)
|
||||
(when subtitle (p subtitle))
|
||||
children))
|
||||
```
|
||||
|
||||
- `&key` params are keyword arguments: `(~card :title "Hi" :subtitle "Sub")`
|
||||
- `&rest children` captures positional args as `children`
|
||||
- Component body evaluated in merged env: `closure + caller-env + bound-params`
|
||||
|
||||
### Rendering modes (from render.sx)
|
||||
|
||||
| Mode | Function | Expands components? | Output |
|
||||
|------|----------|-------------------|--------|
|
||||
| HTML | `render-to-html` | Yes (recursive) | HTML string |
|
||||
| SX wire | `aser` | No — serializes `(~name ...)` | SX source text |
|
||||
| DOM | `render-to-dom` | Yes (recursive) | DOM nodes |
|
||||
|
||||
The `aser` (async-serialize) mode evaluates control flow and function calls but serializes HTML tags and component calls as SX source — the client renders them. This is the wire format for HTMX-like responses.
|
||||
|
||||
### Platform interface
|
||||
|
||||
Each target (JS, Python) must provide: type inspection (`type-of`), constructors (`make-lambda`, `make-component`, `make-macro`, `make-thunk`), accessors, environment operations (`env-has?`, `env-get`, `env-set!`, `env-extend`, `env-merge`), and DOM/HTML rendering primitives.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
**Web platform:** Python 3.11+, Quart (async Flask), SQLAlchemy (asyncpg), Jinja2, HTMX, PostgreSQL, Redis, Docker Swarm, Hypercorn.
|
||||
@@ -108,6 +168,26 @@ cd artdag/l1 && mypy app/types.py app/routers/recipes.py tests/
|
||||
- Silent SSO: `prompt=none` OAuth flow for automatic cross-app login
|
||||
- ActivityPub: RSA signatures, per-app virtual actor projections sharing same keypair
|
||||
|
||||
### SX Rendering Pipeline
|
||||
|
||||
The SX system renders component trees defined in s-expressions. Canonical semantics are in `shared/sx/ref/` (see "SX Language" section above). The same AST can be evaluated in different modes depending on where the server/client rendering boundary is drawn:
|
||||
|
||||
- `render_to_html(name, **kw)` — server-side, produces HTML. Maps to `render-to-html` in the spec.
|
||||
- `render_to_sx(name, **kw)` — server-side, produces SX wire format. Maps to `aser` in the spec. Component calls stay **unexpanded**.
|
||||
- `render_to_sx_with_env(name, env, **kw)` — server-side, **expands known components** then serializes as SX wire format. Used by layout components that need Python context.
|
||||
- `sx_page(ctx, page_sx)` — produces the full HTML shell (`<!doctype html>...`) with component definitions, CSS, and page SX inlined for client-side boot.
|
||||
|
||||
See the docstring in `shared/sx/async_eval.py` for the full evaluation modes table.
|
||||
|
||||
### Service SX Directory Convention
|
||||
|
||||
Each service has two SX-related directories:
|
||||
|
||||
- **`{service}/sx/`** — service-specific component definitions (`.sx` files with `defcomp`). Loaded at startup by `load_service_components()`. These define layout components, reusable UI fragments, etc.
|
||||
- **`{service}/sxc/`** — page definitions and Python rendering logic. Contains `defpage` definitions (client-routed pages) and the Python functions that compose headers, layouts, and page content.
|
||||
|
||||
Shared components live in `shared/sx/templates/` and are loaded by `load_shared_components()` in the app factory.
|
||||
|
||||
### Art DAG
|
||||
|
||||
- **3-Phase Execution:** Analyze → Plan → Execute (tasks in `artdag/l1/tasks/`)
|
||||
@@ -130,6 +210,10 @@ cd artdag/l1 && mypy app/types.py app/routers/recipes.py tests/
|
||||
| likes | (internal only) | 8009 |
|
||||
| orders | orders.rose-ash.com | 8010 |
|
||||
|
||||
## Dev Container Mounts
|
||||
|
||||
Dev bind mounts in `docker-compose.dev.yml` must mirror the Docker image's COPY paths. When adding a new directory to a service (e.g. `{service}/sx/`), add a corresponding volume mount (`./service/sx:/app/sx`) or the directory won't be visible inside the dev container. Hypercorn `--reload` watches for Python file changes; `.sx` file hot-reload is handled by `reload_if_changed()` in `shared/sx/jinja_bridge.py`.
|
||||
|
||||
## Key Config Files
|
||||
|
||||
- `docker-compose.yml` / `docker-compose.dev.yml` — service definitions, env vars, volumes
|
||||
|
||||
91
RESTRUCTURE_PLAN.md
Normal file
91
RESTRUCTURE_PLAN.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# Restructure Plan
|
||||
|
||||
Reorganise from flat `shared/sx/ref/` to layered `spec/` + `hosts/` + `web/` + `sx/`.
|
||||
|
||||
Recovery point: commit `1a3d7b3` on branch `macros`.
|
||||
|
||||
## Phase 1: Directory structure
|
||||
Create all directories. No file moves.
|
||||
```
|
||||
spec/tests/
|
||||
hosts/python/
|
||||
hosts/javascript/
|
||||
web/adapters/
|
||||
web/tests/
|
||||
web/platforms/python/
|
||||
web/platforms/javascript/
|
||||
sx/platforms/python/
|
||||
sx/platforms/javascript/
|
||||
```
|
||||
|
||||
## Phase 2: Spec files (git mv)
|
||||
Move from `shared/sx/ref/` to `spec/`:
|
||||
- eval.sx, parser.sx, primitives.sx, render.sx
|
||||
- cek.sx, frames.sx, special-forms.sx
|
||||
- continuations.sx, callcc.sx, types.sx
|
||||
Move tests to `spec/tests/`:
|
||||
- test-framework.sx, test.sx, test-eval.sx, test-parser.sx
|
||||
- test-render.sx, test-cek.sx, test-continuations.sx, test-types.sx
|
||||
Remove boundary-core.sx from spec/ (it's a contract doc, not spec)
|
||||
|
||||
## Phase 3: Host files (git mv)
|
||||
Python host - move from `shared/sx/ref/` to `hosts/python/`:
|
||||
- bootstrap_py.py → hosts/python/bootstrap.py
|
||||
- platform_py.py → hosts/python/platform.py
|
||||
- py.sx → hosts/python/transpiler.sx
|
||||
- boundary_parser.py → hosts/python/boundary_parser.py
|
||||
- run_signal_tests.py, run_cek_tests.py, run_cek_reactive_tests.py,
|
||||
run_continuation_tests.py, run_type_tests.py → hosts/python/tests/
|
||||
|
||||
JS host - move from `shared/sx/ref/` to `hosts/javascript/`:
|
||||
- run_js_sx.py → hosts/javascript/bootstrap.py
|
||||
- bootstrap_js.py → hosts/javascript/cli.py
|
||||
- platform_js.py → hosts/javascript/platform.py
|
||||
- js.sx → hosts/javascript/transpiler.sx
|
||||
|
||||
Generated output stays in place:
|
||||
- shared/sx/ref/sx_ref.py (Python runtime)
|
||||
- shared/static/scripts/sx-browser.js (JS runtime)
|
||||
|
||||
## Phase 4: Web framework files (git mv)
|
||||
Move from `shared/sx/ref/` to `web/`:
|
||||
- signals.sx → web/signals.sx
|
||||
- engine.sx, orchestration.sx, boot.sx → web/
|
||||
- router.sx, deps.sx, forms.sx, page-helpers.sx → web/
|
||||
Move adapters to `web/adapters/`:
|
||||
- adapter-dom.sx → web/adapters/dom.sx
|
||||
- adapter-html.sx → web/adapters/html.sx
|
||||
- adapter-sx.sx → web/adapters/sx.sx
|
||||
- adapter-async.sx → web/adapters/async.sx
|
||||
Move web tests to `web/tests/`:
|
||||
- test-signals.sx, test-aser.sx, test-engine.sx, etc.
|
||||
Move boundary-web.sx to `web/boundary.sx`
|
||||
Move boundary-app.sx to `web/boundary-app.sx`
|
||||
|
||||
## Phase 5: Platform bindings
|
||||
Web platforms:
|
||||
- Extract DOM/browser primitives from platform_js.py → web/platforms/javascript/
|
||||
- Extract IO/server primitives from platform_py.py → web/platforms/python/
|
||||
App platforms:
|
||||
- sx/sxc/pages/helpers.py → sx/platforms/python/helpers.py
|
||||
- sx/sxc/init-client.sx.txt → sx/platforms/javascript/init.sx
|
||||
|
||||
## Phase 6: Update imports
|
||||
- All Python imports referencing shared.sx.ref.*
|
||||
- Bootstrapper paths (ref_dir, _source_dirs, _find_sx)
|
||||
- Docker volume mounts in docker-compose*.yml
|
||||
- Test runner paths
|
||||
- CLAUDE.md paths
|
||||
|
||||
## Phase 7: Verify
|
||||
- Both bootstrappers build
|
||||
- All tests pass
|
||||
- Dev container starts
|
||||
- Website works
|
||||
- Remove duplicate files from shared/sx/ref/
|
||||
|
||||
## Notes
|
||||
- Generated files (sx_ref.py, sx-browser.js) stay where they are
|
||||
- The runtime imports from shared.sx.ref.sx_ref — that doesn't change
|
||||
- Only the SOURCE .sx files and bootstrapper tools move
|
||||
- Each phase is a separate commit for safe rollback
|
||||
86
_config/dev-sh-config.yaml
Normal file
86
_config/dev-sh-config.yaml
Normal file
@@ -0,0 +1,86 @@
|
||||
root: "/rose-ash-wholefood-coop" # no trailing slash needed (we normalize it)
|
||||
host: "https://rose-ash.com"
|
||||
base_host: "wholesale.suma.coop"
|
||||
base_login: https://wholesale.suma.coop/customer/account/login/
|
||||
base_url: https://wholesale.suma.coop/
|
||||
title: sx-web
|
||||
market_root: /market
|
||||
market_title: Market
|
||||
blog_root: /
|
||||
blog_title: all the news
|
||||
cart_root: /cart
|
||||
app_urls:
|
||||
blog: "https://blog.rose-ash.com"
|
||||
market: "https://market.rose-ash.com"
|
||||
cart: "https://cart.rose-ash.com"
|
||||
events: "https://events.rose-ash.com"
|
||||
federation: "https://federation.rose-ash.com"
|
||||
account: "https://account.rose-ash.com"
|
||||
sx: "https://sx.rose-ash.com"
|
||||
test: "https://test.rose-ash.com"
|
||||
orders: "https://orders.rose-ash.com"
|
||||
cache:
|
||||
fs_root: /app/_snapshot # <- absolute path to your snapshot dir
|
||||
categories:
|
||||
allow:
|
||||
Basics: basics
|
||||
Branded Goods: branded-goods
|
||||
Chilled: chilled
|
||||
Frozen: frozen
|
||||
Non-foods: non-foods
|
||||
Supplements: supplements
|
||||
Christmas: christmas
|
||||
slugs:
|
||||
skip:
|
||||
- ""
|
||||
- customer
|
||||
- account
|
||||
- checkout
|
||||
- wishlist
|
||||
- sales
|
||||
- contact
|
||||
- privacy-policy
|
||||
- terms-and-conditions
|
||||
- delivery
|
||||
- catalogsearch
|
||||
- quickorder
|
||||
- apply
|
||||
- search
|
||||
- static
|
||||
- media
|
||||
section-titles:
|
||||
- ingredients
|
||||
- allergy information
|
||||
- allergens
|
||||
- nutritional information
|
||||
- nutrition
|
||||
- storage
|
||||
- directions
|
||||
- preparation
|
||||
- serving suggestions
|
||||
- origin
|
||||
- country of origin
|
||||
- recycling
|
||||
- general information
|
||||
- additional information
|
||||
- a note about prices
|
||||
|
||||
blacklist:
|
||||
category:
|
||||
- branded-goods/alcoholic-drinks
|
||||
- branded-goods/beers
|
||||
- branded-goods/ciders
|
||||
- branded-goods/wines
|
||||
product:
|
||||
- list-price-suma-current-suma-price-list-each-bk012-2-html
|
||||
product-details:
|
||||
- General Information
|
||||
- A Note About Prices
|
||||
sumup:
|
||||
merchant_code: "ME4J6100"
|
||||
currency: "GBP"
|
||||
# Name of the environment variable that holds your SumUp API key
|
||||
api_key_env: "SUMUP_API_KEY"
|
||||
webhook_secret: "jfwlekjfwef798ewf769ew8f679ew8f7weflwef"
|
||||
|
||||
|
||||
4
account/actions.sx
Normal file
4
account/actions.sx
Normal file
@@ -0,0 +1,4 @@
|
||||
;; Account service — inter-service action endpoints
|
||||
;;
|
||||
;; ghost-sync-member and ghost-push-member use local service imports —
|
||||
;; remain as Python fallbacks.
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
import path_setup # noqa: F401 # adds shared/ to sys.path
|
||||
import sx.sx_components as sx_components # noqa: F401 # ensure Hypercorn --reload watches this file
|
||||
from pathlib import Path
|
||||
|
||||
from quart import g, request
|
||||
@@ -8,7 +7,7 @@ from jinja2 import FileSystemLoader, ChoiceLoader
|
||||
|
||||
from shared.infrastructure.factory import create_base_app
|
||||
|
||||
from bp import register_account_bp, register_auth_bp, register_fragments
|
||||
from bp import register_account_bp, register_auth_bp
|
||||
|
||||
|
||||
async def account_context() -> dict:
|
||||
@@ -72,8 +71,9 @@ def create_app() -> "Quart":
|
||||
app.jinja_loader,
|
||||
])
|
||||
|
||||
# Setup defpage routes
|
||||
import sx.sx_components # noqa: F811 — ensure components loaded
|
||||
# Load .sx component files and setup defpage routes
|
||||
from shared.sx.jinja_bridge import load_service_components
|
||||
load_service_components(str(Path(__file__).resolve().parent), service_name="account")
|
||||
from sxc.pages import setup_account_pages
|
||||
setup_account_pages()
|
||||
|
||||
@@ -81,11 +81,13 @@ def create_app() -> "Quart":
|
||||
app.register_blueprint(register_auth_bp())
|
||||
|
||||
account_bp = register_account_bp()
|
||||
from shared.sx.pages import mount_pages
|
||||
mount_pages(account_bp, "account")
|
||||
app.register_blueprint(account_bp)
|
||||
|
||||
app.register_blueprint(register_fragments())
|
||||
from shared.sx.pages import auto_mount_pages
|
||||
auto_mount_pages(app, "account")
|
||||
|
||||
from shared.sx.handlers import auto_mount_fragment_handlers
|
||||
auto_mount_fragment_handlers(app, "account")
|
||||
|
||||
from bp.actions.routes import register as register_actions
|
||||
app.register_blueprint(register_actions())
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
from .account.routes import register as register_account_bp
|
||||
from .auth.routes import register as register_auth_bp
|
||||
from .fragments import register_fragments
|
||||
|
||||
@@ -7,17 +7,13 @@ from __future__ import annotations
|
||||
|
||||
from quart import (
|
||||
Blueprint,
|
||||
request,
|
||||
redirect,
|
||||
g,
|
||||
)
|
||||
from sqlalchemy import select
|
||||
|
||||
from shared.models import UserNewsletter
|
||||
from shared.models.ghost_membership_entities import GhostNewsletter
|
||||
from shared.infrastructure.urls import login_url
|
||||
from shared.infrastructure.fragments import fetch_fragment, fetch_fragments
|
||||
from shared.sx.helpers import sx_response
|
||||
from shared.infrastructure.fragments import fetch_fragments
|
||||
from shared.sx.helpers import sx_response, sx_call
|
||||
|
||||
|
||||
def register(url_prefix="/"):
|
||||
@@ -25,8 +21,7 @@ def register(url_prefix="/"):
|
||||
|
||||
@account_bp.before_request
|
||||
async def _prepare_page_data():
|
||||
"""Fetch account_nav fragments and load data for defpage routes."""
|
||||
# Fetch account nav items for layout (was in context_processor)
|
||||
"""Fetch account_nav fragments for layout."""
|
||||
events_nav, cart_nav, artdag_nav = await fetch_fragments([
|
||||
("events", "account-nav-item", {}),
|
||||
("cart", "account-nav-item", {}),
|
||||
@@ -34,48 +29,6 @@ def register(url_prefix="/"):
|
||||
], required=False)
|
||||
g.account_nav = events_nav + cart_nav + artdag_nav
|
||||
|
||||
if request.method != "GET":
|
||||
return
|
||||
|
||||
endpoint = request.endpoint or ""
|
||||
|
||||
# Newsletters page — load newsletter data
|
||||
if endpoint.endswith("defpage_newsletters"):
|
||||
result = await g.s.execute(
|
||||
select(GhostNewsletter).order_by(GhostNewsletter.name)
|
||||
)
|
||||
all_newsletters = result.scalars().all()
|
||||
|
||||
sub_result = await g.s.execute(
|
||||
select(UserNewsletter).where(
|
||||
UserNewsletter.user_id == g.user.id,
|
||||
)
|
||||
)
|
||||
user_subs = {un.newsletter_id: un for un in sub_result.scalars().all()}
|
||||
|
||||
newsletter_list = []
|
||||
for nl in all_newsletters:
|
||||
un = user_subs.get(nl.id)
|
||||
newsletter_list.append({
|
||||
"newsletter": nl,
|
||||
"un": un,
|
||||
"subscribed": un.subscribed if un else False,
|
||||
})
|
||||
g.newsletters_data = newsletter_list
|
||||
|
||||
# Fragment page — load fragment from events service
|
||||
elif endpoint.endswith("defpage_fragment_page"):
|
||||
slug = request.view_args.get("slug")
|
||||
if slug and g.get("user"):
|
||||
fragment_html = await fetch_fragment(
|
||||
"events", "account-page",
|
||||
params={"slug": slug, "user_id": str(g.user.id)},
|
||||
)
|
||||
if not fragment_html:
|
||||
from quart import abort
|
||||
abort(404)
|
||||
g.fragment_page_data = fragment_html
|
||||
|
||||
@account_bp.post("/newsletter/<int:newsletter_id>/toggle/")
|
||||
async def toggle_newsletter(newsletter_id: int):
|
||||
if not g.get("user"):
|
||||
@@ -101,7 +54,26 @@ def register(url_prefix="/"):
|
||||
|
||||
await g.s.flush()
|
||||
|
||||
from sx.sx_components import render_newsletter_toggle
|
||||
return sx_response(render_newsletter_toggle(un))
|
||||
# Render toggle directly — no sx_components intermediary
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from shared.infrastructure.urls import account_url
|
||||
|
||||
nid = un.newsletter_id
|
||||
url_fn = getattr(g, "_account_url", None) or account_url
|
||||
toggle_url = url_fn(f"/newsletter/{nid}/toggle/")
|
||||
csrf = generate_csrf_token()
|
||||
bg = "bg-emerald-500" if un.subscribed else "bg-stone-300"
|
||||
translate = "translate-x-6" if un.subscribed else "translate-x-1"
|
||||
checked = "true" if un.subscribed else "false"
|
||||
|
||||
return sx_response(sx_call(
|
||||
"account-newsletter-toggle",
|
||||
id=f"nl-{nid}", url=toggle_url,
|
||||
hdrs={"X-CSRFToken": csrf},
|
||||
target=f"#nl-{nid}",
|
||||
cls=f"relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 {bg}",
|
||||
checked=checked,
|
||||
knob_cls=f"inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform {translate}",
|
||||
))
|
||||
|
||||
return account_bp
|
||||
|
||||
@@ -1,63 +1,33 @@
|
||||
"""Account app action endpoints.
|
||||
|
||||
Exposes write operations at ``/internal/actions/<action_name>`` for
|
||||
cross-app callers (blog webhooks) via the internal action client.
|
||||
All actions remain as Python fallbacks (local service imports).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, jsonify, request
|
||||
from quart import Blueprint, g, request
|
||||
|
||||
from shared.infrastructure.actions import ACTION_HEADER
|
||||
from shared.infrastructure.query_blueprint import create_action_blueprint
|
||||
|
||||
|
||||
def register() -> Blueprint:
|
||||
bp = Blueprint("actions", __name__, url_prefix="/internal/actions")
|
||||
bp, _handlers = create_action_blueprint("account")
|
||||
|
||||
@bp.before_request
|
||||
async def _require_action_header():
|
||||
if not request.headers.get(ACTION_HEADER):
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
from shared.infrastructure.internal_auth import validate_internal_request
|
||||
if not validate_internal_request():
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
|
||||
_handlers: dict[str, object] = {}
|
||||
|
||||
@bp.post("/<action_name>")
|
||||
async def handle_action(action_name: str):
|
||||
handler = _handlers.get(action_name)
|
||||
if handler is None:
|
||||
return jsonify({"error": "unknown action"}), 404
|
||||
try:
|
||||
result = await handler()
|
||||
return jsonify(result)
|
||||
except Exception as exc:
|
||||
import logging
|
||||
logging.getLogger(__name__).exception("Action %s failed", action_name)
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
|
||||
# --- ghost-sync-member ---
|
||||
async def _ghost_sync_member():
|
||||
"""Sync a single Ghost member into db_account."""
|
||||
data = await request.get_json()
|
||||
ghost_id = data.get("ghost_id")
|
||||
if not ghost_id:
|
||||
return {"error": "ghost_id required"}, 400
|
||||
|
||||
from services.ghost_membership import sync_single_member
|
||||
await sync_single_member(g.s, ghost_id)
|
||||
return {"ok": True}
|
||||
|
||||
_handlers["ghost-sync-member"] = _ghost_sync_member
|
||||
|
||||
# --- ghost-push-member ---
|
||||
async def _ghost_push_member():
|
||||
"""Push a local user's membership data to Ghost."""
|
||||
data = await request.get_json()
|
||||
user_id = data.get("user_id")
|
||||
if not user_id:
|
||||
return {"error": "user_id required"}, 400
|
||||
|
||||
from services.ghost_membership import sync_member_to_ghost
|
||||
result_id = await sync_member_to_ghost(g.s, int(user_id))
|
||||
return {"ok": True, "ghost_id": result_id}
|
||||
|
||||
@@ -44,6 +44,17 @@ from .services import (
|
||||
SESSION_USER_KEY = "uid"
|
||||
ACCOUNT_SESSION_KEY = "account_sid"
|
||||
|
||||
|
||||
async def _render_auth_page(component: str, title: str, **kwargs) -> str:
|
||||
"""Render an auth page with root layout — replaces sx_components helpers."""
|
||||
from shared.sx.helpers import sx_call, full_page_sx, root_header_sx
|
||||
from shared.sx.page import get_template_context
|
||||
ctx = await get_template_context()
|
||||
hdr = await root_header_sx(ctx)
|
||||
content = sx_call(component, **{k: v for k, v in kwargs.items() if v})
|
||||
return await full_page_sx(ctx, header_rows=hdr, content=content,
|
||||
meta_html=f"<title>{title}</title>")
|
||||
|
||||
ALLOWED_CLIENTS = {"blog", "market", "cart", "events", "federation", "orders", "test", "sx", "artdag", "artdag_l2"}
|
||||
|
||||
|
||||
@@ -275,10 +286,7 @@ def register(url_prefix="/auth"):
|
||||
redirect_url = pop_login_redirect_target()
|
||||
return redirect(redirect_url)
|
||||
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_login_page
|
||||
ctx = await get_template_context()
|
||||
return await render_login_page(ctx)
|
||||
return await _render_auth_page("account-login-content", "Login \u2014 Rose Ash")
|
||||
|
||||
@rate_limit(
|
||||
key_func=lambda: request.headers.get("X-Forwarded-For", request.remote_addr),
|
||||
@@ -291,20 +299,20 @@ def register(url_prefix="/auth"):
|
||||
|
||||
is_valid, email = validate_email(email_input)
|
||||
if not is_valid:
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_login_page
|
||||
ctx = await get_template_context(error="Please enter a valid email address.", email=email_input)
|
||||
return await render_login_page(ctx), 400
|
||||
return await _render_auth_page(
|
||||
"account-login-content", "Login \u2014 Rose Ash",
|
||||
error="Please enter a valid email address.", email=email_input,
|
||||
), 400
|
||||
|
||||
# Per-email rate limit: 5 magic links per 15 minutes
|
||||
from shared.infrastructure.rate_limit import _check_rate_limit
|
||||
try:
|
||||
allowed, _ = await _check_rate_limit(f"magic_email:{email}", 5, 900)
|
||||
if not allowed:
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_check_email_page
|
||||
ctx = await get_template_context(email=email, email_error=None)
|
||||
return await render_check_email_page(ctx), 200
|
||||
return await _render_auth_page(
|
||||
"account-check-email-content", "Check your email \u2014 Rose Ash",
|
||||
email=email,
|
||||
), 200
|
||||
except Exception:
|
||||
pass # Redis down — allow the request
|
||||
|
||||
@@ -324,10 +332,10 @@ def register(url_prefix="/auth"):
|
||||
"Please try again in a moment."
|
||||
)
|
||||
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_check_email_page
|
||||
ctx = await get_template_context(email=email, email_error=email_error)
|
||||
return await render_check_email_page(ctx)
|
||||
return await _render_auth_page(
|
||||
"account-check-email-content", "Check your email \u2014 Rose Ash",
|
||||
email=email, email_error=email_error,
|
||||
)
|
||||
|
||||
@auth_bp.get("/magic/<token>/")
|
||||
async def magic(token: str):
|
||||
@@ -340,17 +348,17 @@ def register(url_prefix="/auth"):
|
||||
user, error = await validate_magic_link(s, token)
|
||||
|
||||
if error:
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_login_page
|
||||
ctx = await get_template_context(error=error)
|
||||
return await render_login_page(ctx), 400
|
||||
return await _render_auth_page(
|
||||
"account-login-content", "Login \u2014 Rose Ash",
|
||||
error=error,
|
||||
), 400
|
||||
user_id = user.id
|
||||
|
||||
except Exception:
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_login_page
|
||||
ctx = await get_template_context(error="Could not sign you in right now. Please try again.")
|
||||
return await render_login_page(ctx), 502
|
||||
return await _render_auth_page(
|
||||
"account-login-content", "Login \u2014 Rose Ash",
|
||||
error="Could not sign you in right now. Please try again.",
|
||||
), 502
|
||||
|
||||
assert user_id is not None
|
||||
|
||||
@@ -679,11 +687,11 @@ def register(url_prefix="/auth"):
|
||||
@auth_bp.get("/device/")
|
||||
async def device_form():
|
||||
"""Browser form where user enters the code displayed in terminal."""
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_device_page
|
||||
code = request.args.get("code", "")
|
||||
ctx = await get_template_context(code=code)
|
||||
return await render_device_page(ctx)
|
||||
return await _render_auth_page(
|
||||
"account-device-content", "Authorize Device \u2014 Rose Ash",
|
||||
code=code,
|
||||
)
|
||||
|
||||
@auth_bp.post("/device")
|
||||
@auth_bp.post("/device/")
|
||||
@@ -693,20 +701,20 @@ def register(url_prefix="/auth"):
|
||||
user_code = (form.get("code") or "").strip().replace("-", "").upper()
|
||||
|
||||
if not user_code or len(user_code) != 8:
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_device_page
|
||||
ctx = await get_template_context(error="Please enter a valid 8-character code.", code=form.get("code", ""))
|
||||
return await render_device_page(ctx), 400
|
||||
return await _render_auth_page(
|
||||
"account-device-content", "Authorize Device \u2014 Rose Ash",
|
||||
error="Please enter a valid 8-character code.", code=form.get("code", ""),
|
||||
), 400
|
||||
|
||||
from shared.infrastructure.auth_redis import get_auth_redis
|
||||
|
||||
r = await get_auth_redis()
|
||||
device_code = await r.get(f"devflow_uc:{user_code}")
|
||||
if not device_code:
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_device_page
|
||||
ctx = await get_template_context(error="Code not found or expired. Please try again.", code=form.get("code", ""))
|
||||
return await render_device_page(ctx), 400
|
||||
return await _render_auth_page(
|
||||
"account-device-content", "Authorize Device \u2014 Rose Ash",
|
||||
error="Code not found or expired. Please try again.", code=form.get("code", ""),
|
||||
), 400
|
||||
|
||||
if isinstance(device_code, bytes):
|
||||
device_code = device_code.decode()
|
||||
@@ -720,23 +728,19 @@ def register(url_prefix="/auth"):
|
||||
# Logged in — approve immediately
|
||||
ok = await _approve_device(device_code, g.user)
|
||||
if not ok:
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_device_page
|
||||
ctx = await get_template_context(error="Code expired or already used.")
|
||||
return await render_device_page(ctx), 400
|
||||
return await _render_auth_page(
|
||||
"account-device-content", "Authorize Device \u2014 Rose Ash",
|
||||
error="Code expired or already used.",
|
||||
), 400
|
||||
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_device_approved_page
|
||||
ctx = await get_template_context()
|
||||
return await render_device_approved_page(ctx)
|
||||
return await _render_auth_page(
|
||||
"account-device-approved", "Device Authorized \u2014 Rose Ash",
|
||||
)
|
||||
|
||||
@auth_bp.get("/device/complete")
|
||||
@auth_bp.get("/device/complete/")
|
||||
async def device_complete():
|
||||
"""Post-login redirect — completes approval after magic link auth."""
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_device_page, render_device_approved_page
|
||||
|
||||
device_code = request.args.get("code", "")
|
||||
|
||||
if not device_code:
|
||||
@@ -748,12 +752,13 @@ def register(url_prefix="/auth"):
|
||||
|
||||
ok = await _approve_device(device_code, g.user)
|
||||
if not ok:
|
||||
ctx = await get_template_context(
|
||||
return await _render_auth_page(
|
||||
"account-device-content", "Authorize Device \u2014 Rose Ash",
|
||||
error="Code expired or already used. Please start the login process again in your terminal.",
|
||||
)
|
||||
return await render_device_page(ctx), 400
|
||||
), 400
|
||||
|
||||
ctx = await get_template_context()
|
||||
return await render_device_approved_page(ctx)
|
||||
return await _render_auth_page(
|
||||
"account-device-approved", "Device Authorized \u2014 Rose Ash",
|
||||
)
|
||||
|
||||
return auth_bp
|
||||
|
||||
@@ -1,67 +1,14 @@
|
||||
"""Account app data endpoints.
|
||||
|
||||
Exposes read-only JSON queries at ``/internal/data/<query_name>`` for
|
||||
cross-app callers via the internal data client.
|
||||
All queries are defined in ``account/queries.sx``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, jsonify, request
|
||||
from quart import Blueprint
|
||||
|
||||
from shared.infrastructure.data_client import DATA_HEADER
|
||||
from sqlalchemy import select
|
||||
from shared.models import User
|
||||
from shared.infrastructure.query_blueprint import create_data_blueprint
|
||||
|
||||
|
||||
def register() -> Blueprint:
|
||||
bp = Blueprint("data", __name__, url_prefix="/internal/data")
|
||||
|
||||
@bp.before_request
|
||||
async def _require_data_header():
|
||||
if not request.headers.get(DATA_HEADER):
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
from shared.infrastructure.internal_auth import validate_internal_request
|
||||
if not validate_internal_request():
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
|
||||
_handlers: dict[str, object] = {}
|
||||
|
||||
@bp.get("/<query_name>")
|
||||
async def handle_query(query_name: str):
|
||||
handler = _handlers.get(query_name)
|
||||
if handler is None:
|
||||
return jsonify({"error": "unknown query"}), 404
|
||||
result = await handler()
|
||||
return jsonify(result)
|
||||
|
||||
# --- user-by-email ---
|
||||
async def _user_by_email():
|
||||
"""Return user_id for a given email address."""
|
||||
email = request.args.get("email", "").strip().lower()
|
||||
if not email:
|
||||
return None
|
||||
result = await g.s.execute(
|
||||
select(User.id).where(User.email.ilike(email))
|
||||
)
|
||||
row = result.first()
|
||||
if not row:
|
||||
return None
|
||||
return {"user_id": row[0]}
|
||||
|
||||
_handlers["user-by-email"] = _user_by_email
|
||||
|
||||
# --- newsletters ---
|
||||
async def _newsletters():
|
||||
"""Return all Ghost newsletters (for blog post editor)."""
|
||||
from shared.models.ghost_membership_entities import GhostNewsletter
|
||||
result = await g.s.execute(
|
||||
select(GhostNewsletter.id, GhostNewsletter.ghost_id, GhostNewsletter.name, GhostNewsletter.slug)
|
||||
.order_by(GhostNewsletter.name)
|
||||
)
|
||||
return [
|
||||
{"id": row[0], "ghost_id": row[1], "name": row[2], "slug": row[3]}
|
||||
for row in result.all()
|
||||
]
|
||||
|
||||
_handlers["newsletters"] = _newsletters
|
||||
|
||||
bp, _handlers = create_data_blueprint("account")
|
||||
return bp
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
from .routes import register as register_fragments
|
||||
@@ -1,36 +0,0 @@
|
||||
"""Account app fragment endpoints.
|
||||
|
||||
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
|
||||
by other coop apps via the fragment client.
|
||||
|
||||
All handlers are defined declaratively in .sx files under
|
||||
``account/sx/handlers/`` and dispatched via the sx handler registry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, Response, request
|
||||
|
||||
from shared.infrastructure.fragments import FRAGMENT_HEADER
|
||||
from shared.sx.handlers import get_handler, execute_handler
|
||||
|
||||
|
||||
def register():
|
||||
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
|
||||
|
||||
@bp.before_request
|
||||
async def _require_fragment_header():
|
||||
if not request.headers.get(FRAGMENT_HEADER):
|
||||
return Response("", status=403)
|
||||
|
||||
@bp.get("/<fragment_type>")
|
||||
async def get_fragment(fragment_type: str):
|
||||
handler_def = get_handler("account", fragment_type)
|
||||
if handler_def is not None:
|
||||
result = await execute_handler(
|
||||
handler_def, "account", args=dict(request.args),
|
||||
)
|
||||
return Response(result, status=200, content_type="text/sx")
|
||||
return Response("", status=200, content_type="text/sx")
|
||||
|
||||
return bp
|
||||
9
account/queries.sx
Normal file
9
account/queries.sx
Normal file
@@ -0,0 +1,9 @@
|
||||
;; Account service — inter-service data queries
|
||||
|
||||
(defquery user-by-email (&key email)
|
||||
"Return user_id for a given email address."
|
||||
(service "account" "user-by-email" :email email))
|
||||
|
||||
(defquery newsletters ()
|
||||
"Return all Ghost newsletters."
|
||||
(service "account" "newsletters"))
|
||||
@@ -3,9 +3,10 @@ from __future__ import annotations
|
||||
|
||||
|
||||
def register_domain_services() -> None:
|
||||
"""Register services for the account app.
|
||||
"""Register services for the account app."""
|
||||
from shared.services.registry import services
|
||||
from .account_page import AccountPageService
|
||||
services.register("account_page", AccountPageService())
|
||||
|
||||
Account is a consumer-only dashboard app. It has no own domain.
|
||||
All cross-app data comes via fragments and HTTP data endpoints.
|
||||
"""
|
||||
pass
|
||||
from shared.services.account_impl import SqlAccountDataService
|
||||
services.register("account", SqlAccountDataService())
|
||||
|
||||
40
account/services/account_page.py
Normal file
40
account/services/account_page.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Account page data service — provides serialized dicts for .sx defpages."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class AccountPageService:
|
||||
"""Service for account page data, callable via (service "account-page" ...)."""
|
||||
|
||||
async def newsletters_data(self, session, **kw):
|
||||
"""Return newsletter list with user subscription status."""
|
||||
from quart import g
|
||||
from sqlalchemy import select
|
||||
from shared.models import UserNewsletter
|
||||
from shared.models.ghost_membership_entities import GhostNewsletter
|
||||
|
||||
result = await session.execute(
|
||||
select(GhostNewsletter).order_by(GhostNewsletter.name)
|
||||
)
|
||||
all_newsletters = result.scalars().all()
|
||||
|
||||
sub_result = await session.execute(
|
||||
select(UserNewsletter).where(
|
||||
UserNewsletter.user_id == g.user.id,
|
||||
)
|
||||
)
|
||||
user_subs = {un.newsletter_id: un for un in sub_result.scalars().all()}
|
||||
|
||||
newsletter_list = []
|
||||
for nl in all_newsletters:
|
||||
un = user_subs.get(nl.id)
|
||||
newsletter_list.append({
|
||||
"newsletter": {"id": nl.id, "name": nl.name, "description": nl.description},
|
||||
"un": {"newsletter_id": un.newsletter_id, "subscribed": un.subscribed} if un else None,
|
||||
"subscribed": un.subscribed if un else False,
|
||||
})
|
||||
|
||||
from shared.infrastructure.urls import account_url
|
||||
return {
|
||||
"newsletter_list": newsletter_list,
|
||||
"account_url": account_url(""),
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
;; Auth page components (device auth — account-specific)
|
||||
;; Login and check-email components are shared: see shared/sx/templates/auth.sx
|
||||
|
||||
(defcomp ~account-device-error (&key error)
|
||||
(defcomp ~auth/device-error (&key (error :as string))
|
||||
(when error
|
||||
(div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4"
|
||||
error)))
|
||||
|
||||
(defcomp ~account-device-form (&key error action csrf-token code)
|
||||
(defcomp ~auth/device-form (&key error (action :as string) (csrf-token :as string) (code :as string))
|
||||
(div :class "py-8 max-w-md mx-auto"
|
||||
(h1 :class "text-2xl font-bold mb-6" "Authorize device")
|
||||
(p :class "text-stone-600 mb-4" "Enter the code shown in your terminal to sign in.")
|
||||
@@ -22,8 +22,30 @@
|
||||
:class "w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition"
|
||||
"Authorize"))))
|
||||
|
||||
(defcomp ~account-device-approved ()
|
||||
(defcomp ~auth/device-approved ()
|
||||
(div :class "py-8 max-w-md mx-auto text-center"
|
||||
(h1 :class "text-2xl font-bold mb-4" "Device authorized")
|
||||
(p :class "text-stone-600" "You can close this window and return to your terminal.")))
|
||||
|
||||
;; Assembled auth page content — replaces Python _login_page_content etc.
|
||||
|
||||
(defcomp ~auth/login-content (&key (error :as string?) (email :as string?))
|
||||
(~shared:auth/login-form
|
||||
:error (when error (~shared:auth/error-banner :error error))
|
||||
:action (url-for "auth.start_login")
|
||||
:csrf-token (csrf-token)
|
||||
:email (or email "")))
|
||||
|
||||
(defcomp ~auth/device-content (&key (error :as string?) (code :as string?))
|
||||
(~auth/device-form
|
||||
:error (when error (~auth/device-error :error error))
|
||||
:action (url-for "auth.device_submit")
|
||||
:csrf-token (csrf-token)
|
||||
:code (or code "")))
|
||||
|
||||
(defcomp ~auth/check-email-content (&key (email :as string?) (email-error :as string?))
|
||||
(~shared:auth/check-email
|
||||
:email (escape (or email ""))
|
||||
:error (when email-error
|
||||
(~shared:auth/check-email-error :error (escape email-error)))))
|
||||
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
;; Account dashboard components
|
||||
|
||||
(defcomp ~account-error-banner (&key error)
|
||||
(defcomp ~dashboard/error-banner (&key (error :as string))
|
||||
(when error
|
||||
(div :class "rounded-lg border border-red-200 bg-red-50 text-red-800 px-4 py-3 text-sm"
|
||||
error)))
|
||||
|
||||
(defcomp ~account-user-email (&key email)
|
||||
(defcomp ~dashboard/user-email (&key (email :as string))
|
||||
(when email
|
||||
(p :class "text-sm text-stone-500 mt-1" email)))
|
||||
|
||||
(defcomp ~account-user-name (&key name)
|
||||
(defcomp ~dashboard/user-name (&key (name :as string))
|
||||
(when name
|
||||
(p :class "text-sm text-stone-600" name)))
|
||||
|
||||
(defcomp ~account-logout-form (&key csrf-token)
|
||||
(defcomp ~dashboard/logout-form (&key (csrf-token :as string))
|
||||
(form :action "/auth/logout/" :method "post"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf-token)
|
||||
(button :type "submit"
|
||||
:class "inline-flex items-center gap-2 rounded-full border border-stone-300 px-4 py-2 text-sm font-medium text-stone-700 hover:bg-stone-50 transition"
|
||||
(i :class "fa-solid fa-right-from-bracket text-xs") " Sign out")))
|
||||
|
||||
(defcomp ~account-label-item (&key name)
|
||||
(defcomp ~dashboard/label-item (&key (name :as string))
|
||||
(span :class "inline-flex items-center rounded-full border border-stone-200 px-3 py-1 text-xs font-medium bg-white/60"
|
||||
name))
|
||||
|
||||
(defcomp ~account-labels-section (&key items)
|
||||
(defcomp ~dashboard/labels-section (&key items)
|
||||
(when items
|
||||
(div
|
||||
(h2 :class "text-base font-semibold tracking-tight mb-3" "Labels")
|
||||
(div :class "flex flex-wrap gap-2" items))))
|
||||
|
||||
(defcomp ~account-main-panel (&key error email name logout labels)
|
||||
(defcomp ~dashboard/main-panel (&key error email name logout labels)
|
||||
(div :class "w-full max-w-3xl mx-auto px-4 py-6"
|
||||
(div :class "bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-8"
|
||||
error
|
||||
@@ -41,3 +41,20 @@
|
||||
name)
|
||||
logout)
|
||||
labels)))
|
||||
|
||||
;; Assembled dashboard content — replaces Python _account_main_panel_sx
|
||||
(defcomp ~dashboard/content (&key (error :as string?))
|
||||
(let* ((user (current-user))
|
||||
(csrf (csrf-token)))
|
||||
(~dashboard/main-panel
|
||||
:error (when error (~dashboard/error-banner :error error))
|
||||
:email (when (get user "email")
|
||||
(~dashboard/user-email :email (get user "email")))
|
||||
:name (when (get user "name")
|
||||
(~dashboard/user-name :name (get user "name")))
|
||||
:logout (~dashboard/logout-form :csrf-token csrf)
|
||||
:labels (when (not (empty? (or (get user "labels") (list))))
|
||||
(~dashboard/labels-section
|
||||
:items (map (lambda (label)
|
||||
(~dashboard/label-item :name (get label "name")))
|
||||
(get user "labels")))))))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
;; Account auth-menu fragment handler
|
||||
;; returns: sx
|
||||
;;
|
||||
;; Renders the desktop + mobile auth menu (sign-in or user link).
|
||||
|
||||
|
||||
20
account/sx/layouts.sx
Normal file
20
account/sx/layouts.sx
Normal file
@@ -0,0 +1,20 @@
|
||||
;; Account layout defcomps — fully self-contained via IO primitives.
|
||||
;; Registered via register_sx_layout("account", ...) in __init__.py.
|
||||
|
||||
;; Full page: root header + auth header row in header-child
|
||||
(defcomp ~layouts/full ()
|
||||
(<> (~root-header-auto)
|
||||
(~shared:layout/header-child-sx
|
||||
:inner (~auth-header-row-auto))))
|
||||
|
||||
;; OOB (HTMX): auth row + root header, both with oob=true
|
||||
(defcomp ~layouts/oob ()
|
||||
(<> (~auth-header-row-auto true)
|
||||
(~root-header-auto true)))
|
||||
|
||||
;; Mobile menu: auth section + root nav
|
||||
(defcomp ~layouts/mobile ()
|
||||
(<> (~shared:layout/mobile-menu-section
|
||||
:label "account" :href "/" :level 1 :colour "sky"
|
||||
:items (~auth-nav-items-auto))
|
||||
(~root-mobile-auto)))
|
||||
@@ -1,31 +1,62 @@
|
||||
;; Newsletter management components
|
||||
|
||||
(defcomp ~account-newsletter-desc (&key description)
|
||||
(defcomp ~newsletters/desc (&key (description :as string))
|
||||
(when description
|
||||
(p :class "text-xs text-stone-500 mt-0.5 truncate" description)))
|
||||
|
||||
(defcomp ~account-newsletter-toggle (&key id url hdrs target cls checked knob-cls)
|
||||
(defcomp ~newsletters/toggle (&key (id :as string) (url :as string) (hdrs :as dict) (target :as string) (cls :as string) (checked :as string) (knob-cls :as string))
|
||||
(div :id id :class "flex items-center"
|
||||
(button :sx-post url :sx-headers hdrs :sx-target target :sx-swap "outerHTML"
|
||||
:class cls :role "switch" :aria-checked checked
|
||||
(span :class knob-cls))))
|
||||
|
||||
|
||||
(defcomp ~account-newsletter-item (&key name desc toggle)
|
||||
(defcomp ~newsletters/item (&key (name :as string) desc toggle)
|
||||
(div :class "flex items-center justify-between py-4 first:pt-0 last:pb-0"
|
||||
(div :class "min-w-0 flex-1"
|
||||
(p :class "text-sm font-medium text-stone-800" name)
|
||||
desc)
|
||||
(div :class "ml-4 flex-shrink-0" toggle)))
|
||||
|
||||
(defcomp ~account-newsletter-list (&key items)
|
||||
(defcomp ~newsletters/list (&key items)
|
||||
(div :class "divide-y divide-stone-100" items))
|
||||
|
||||
(defcomp ~account-newsletter-empty ()
|
||||
(defcomp ~newsletters/empty ()
|
||||
(p :class "text-sm text-stone-500" "No newsletters available."))
|
||||
|
||||
(defcomp ~account-newsletters-panel (&key list)
|
||||
(defcomp ~newsletters/panel (&key list)
|
||||
(div :class "w-full max-w-3xl mx-auto px-4 py-6"
|
||||
(div :class "bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6"
|
||||
(h1 :class "text-xl font-semibold tracking-tight" "Newsletters")
|
||||
list)))
|
||||
|
||||
;; Assembled newsletters content — replaces Python _newsletters_panel_sx
|
||||
;; Takes pre-fetched newsletter-list from page helper
|
||||
(defcomp ~newsletters/content (&key (newsletter-list :as list) (account-url :as string?))
|
||||
(let* ((csrf (csrf-token)))
|
||||
(if (empty? newsletter-list)
|
||||
(~newsletters/empty)
|
||||
(~newsletters/panel
|
||||
:list (~newsletters/list
|
||||
:items (map (lambda (item)
|
||||
(let* ((nl (get item "newsletter"))
|
||||
(un (get item "un"))
|
||||
(nid (get nl "id"))
|
||||
(subscribed (get item "subscribed"))
|
||||
(toggle-url (str (or account-url "") "/newsletter/" nid "/toggle/"))
|
||||
(bg (if subscribed "bg-emerald-500" "bg-stone-300"))
|
||||
(translate (if subscribed "translate-x-6" "translate-x-1"))
|
||||
(checked (if subscribed "true" "false")))
|
||||
(~newsletters/item
|
||||
:name (get nl "name")
|
||||
:desc (when (get nl "description")
|
||||
(~newsletters/desc :description (get nl "description")))
|
||||
:toggle (~newsletters/toggle
|
||||
:id (str "nl-" nid)
|
||||
:url toggle-url
|
||||
:hdrs {:X-CSRFToken csrf}
|
||||
:target (str "#nl-" nid)
|
||||
:cls (str "relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 " bg)
|
||||
:checked checked
|
||||
:knob-cls (str "inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform " translate)))))
|
||||
newsletter-list))))))
|
||||
|
||||
@@ -1,339 +0,0 @@
|
||||
"""
|
||||
Account service s-expression page components.
|
||||
|
||||
Renders account dashboard, newsletters, fragment pages, login, and device
|
||||
auth pages. Called from route handlers in place of ``render_template()``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from shared.sx.jinja_bridge import load_service_components
|
||||
from shared.sx.helpers import (
|
||||
call_url, sx_call, SxExpr,
|
||||
root_header_sx, full_page_sx,
|
||||
)
|
||||
|
||||
# Load account-specific .sx components + handlers at import time
|
||||
load_service_components(os.path.dirname(os.path.dirname(__file__)),
|
||||
service_name="account")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Header helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _auth_nav_sx(ctx: dict) -> str:
|
||||
"""Auth section desktop nav items."""
|
||||
parts = [
|
||||
sx_call("nav-link",
|
||||
href=call_url(ctx, "account_url", "/newsletters/"),
|
||||
label="newsletters",
|
||||
select_colours=ctx.get("select_colours", ""),
|
||||
)
|
||||
]
|
||||
account_nav = ctx.get("account_nav")
|
||||
if account_nav:
|
||||
parts.append(account_nav)
|
||||
return "(<> " + " ".join(parts) + ")"
|
||||
|
||||
|
||||
def _auth_header_sx(ctx: dict, *, oob: bool = False) -> str:
|
||||
"""Build the account section header row."""
|
||||
return sx_call(
|
||||
"menu-row-sx",
|
||||
id="auth-row", level=1, colour="sky",
|
||||
link_href=call_url(ctx, "account_url", "/"),
|
||||
link_label="account", icon="fa-solid fa-user",
|
||||
nav=SxExpr(_auth_nav_sx(ctx)),
|
||||
child_id="auth-header-child", oob=oob,
|
||||
)
|
||||
|
||||
|
||||
def _auth_nav_mobile_sx(ctx: dict) -> str:
|
||||
"""Mobile nav menu for auth section."""
|
||||
parts = [
|
||||
sx_call("nav-link",
|
||||
href=call_url(ctx, "account_url", "/newsletters/"),
|
||||
label="newsletters",
|
||||
select_colours=ctx.get("select_colours", ""),
|
||||
)
|
||||
]
|
||||
account_nav = ctx.get("account_nav")
|
||||
if account_nav:
|
||||
parts.append(account_nav)
|
||||
return "(<> " + " ".join(parts) + ")"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Account dashboard (GET /)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _account_main_panel_sx(ctx: dict) -> str:
|
||||
"""Account info panel with user details and logout."""
|
||||
from quart import g
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
|
||||
user = getattr(g, "user", None)
|
||||
error = ctx.get("error", "")
|
||||
|
||||
error_sx = sx_call("account-error-banner", error=error) if error else ""
|
||||
|
||||
user_email_sx = ""
|
||||
user_name_sx = ""
|
||||
if user:
|
||||
user_email_sx = sx_call("account-user-email", email=user.email)
|
||||
if user.name:
|
||||
user_name_sx = sx_call("account-user-name", name=user.name)
|
||||
|
||||
logout_sx = sx_call("account-logout-form", csrf_token=generate_csrf_token())
|
||||
|
||||
labels_sx = ""
|
||||
if user and hasattr(user, "labels") and user.labels:
|
||||
label_items = " ".join(
|
||||
sx_call("account-label-item", name=label.name)
|
||||
for label in user.labels
|
||||
)
|
||||
labels_sx = sx_call("account-labels-section",
|
||||
items=SxExpr("(<> " + label_items + ")"))
|
||||
|
||||
return sx_call(
|
||||
"account-main-panel",
|
||||
error=SxExpr(error_sx) if error_sx else None,
|
||||
email=SxExpr(user_email_sx) if user_email_sx else None,
|
||||
name=SxExpr(user_name_sx) if user_name_sx else None,
|
||||
logout=SxExpr(logout_sx),
|
||||
labels=SxExpr(labels_sx) if labels_sx else None,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Newsletters (GET /newsletters/)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _newsletter_toggle_sx(un: Any, account_url_fn: Any, csrf_token: str) -> str:
|
||||
"""Render a single newsletter toggle switch."""
|
||||
nid = un.newsletter_id
|
||||
toggle_url = account_url_fn(f"/newsletter/{nid}/toggle/")
|
||||
if un.subscribed:
|
||||
bg = "bg-emerald-500"
|
||||
translate = "translate-x-6"
|
||||
checked = "true"
|
||||
else:
|
||||
bg = "bg-stone-300"
|
||||
translate = "translate-x-1"
|
||||
checked = "false"
|
||||
return sx_call(
|
||||
"account-newsletter-toggle",
|
||||
id=f"nl-{nid}", url=toggle_url,
|
||||
hdrs=f'{{"X-CSRFToken": "{csrf_token}"}}',
|
||||
target=f"#nl-{nid}",
|
||||
cls=f"relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 {bg}",
|
||||
checked=checked,
|
||||
knob_cls=f"inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform {translate}",
|
||||
)
|
||||
|
||||
|
||||
def _newsletter_toggle_off_sx(nid: int, toggle_url: str, csrf_token: str) -> str:
|
||||
"""Render an unsubscribed newsletter toggle (no subscription record yet)."""
|
||||
return sx_call(
|
||||
"account-newsletter-toggle",
|
||||
id=f"nl-{nid}", url=toggle_url,
|
||||
hdrs=f'{{"X-CSRFToken": "{csrf_token}"}}',
|
||||
target=f"#nl-{nid}",
|
||||
cls="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 bg-stone-300",
|
||||
checked="false",
|
||||
knob_cls="inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform translate-x-1",
|
||||
)
|
||||
|
||||
|
||||
def _newsletters_panel_sx(ctx: dict, newsletter_list: list) -> str:
|
||||
"""Newsletters management panel."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
|
||||
account_url_fn = ctx.get("account_url") or (lambda p: p)
|
||||
csrf = generate_csrf_token()
|
||||
|
||||
if newsletter_list:
|
||||
items = []
|
||||
for item in newsletter_list:
|
||||
nl = item["newsletter"]
|
||||
un = item.get("un")
|
||||
|
||||
desc_sx = sx_call(
|
||||
"account-newsletter-desc", description=nl.description
|
||||
) if nl.description else ""
|
||||
|
||||
if un:
|
||||
toggle = _newsletter_toggle_sx(un, account_url_fn, csrf)
|
||||
else:
|
||||
toggle_url = account_url_fn(f"/newsletter/{nl.id}/toggle/")
|
||||
toggle = _newsletter_toggle_off_sx(nl.id, toggle_url, csrf)
|
||||
|
||||
items.append(sx_call(
|
||||
"account-newsletter-item",
|
||||
name=nl.name,
|
||||
desc=SxExpr(desc_sx) if desc_sx else None,
|
||||
toggle=SxExpr(toggle),
|
||||
))
|
||||
list_sx = sx_call(
|
||||
"account-newsletter-list",
|
||||
items=SxExpr("(<> " + " ".join(items) + ")"),
|
||||
)
|
||||
else:
|
||||
list_sx = sx_call("account-newsletter-empty")
|
||||
|
||||
return sx_call("account-newsletters-panel", list=SxExpr(list_sx))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth pages (login, device, check_email)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _login_page_content(ctx: dict) -> str:
|
||||
"""Login form content."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from quart import url_for
|
||||
|
||||
error = ctx.get("error", "")
|
||||
email = ctx.get("email", "")
|
||||
action = url_for("auth.start_login")
|
||||
|
||||
error_sx = sx_call("auth-error-banner", error=error) if error else ""
|
||||
|
||||
return sx_call(
|
||||
"auth-login-form",
|
||||
error=SxExpr(error_sx) if error_sx else None,
|
||||
action=action,
|
||||
csrf_token=generate_csrf_token(), email=email,
|
||||
)
|
||||
|
||||
|
||||
def _device_page_content(ctx: dict) -> str:
|
||||
"""Device authorization form content."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from quart import url_for
|
||||
|
||||
error = ctx.get("error", "")
|
||||
code = ctx.get("code", "")
|
||||
action = url_for("auth.device_submit")
|
||||
|
||||
error_sx = sx_call("account-device-error", error=error) if error else ""
|
||||
|
||||
return sx_call(
|
||||
"account-device-form",
|
||||
error=SxExpr(error_sx) if error_sx else None,
|
||||
action=action,
|
||||
csrf_token=generate_csrf_token(), code=code,
|
||||
)
|
||||
|
||||
|
||||
def _device_approved_content() -> str:
|
||||
"""Device approved success content."""
|
||||
return sx_call("account-device-approved")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Account dashboard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _fragment_content(frag: object) -> str:
|
||||
"""Convert a fragment response to sx content string.
|
||||
|
||||
SxExpr (from text/sx responses) is embedded as-is; plain strings
|
||||
(from text/html) are wrapped in ``~rich-text``.
|
||||
"""
|
||||
from shared.sx.parser import SxExpr
|
||||
if isinstance(frag, SxExpr):
|
||||
return frag.source
|
||||
s = str(frag) if frag else ""
|
||||
if not s:
|
||||
return ""
|
||||
return f'(~rich-text :html "{_sx_escape(s)}")'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Auth pages (login, device)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_login_page(ctx: dict) -> str:
|
||||
"""Full page: login form."""
|
||||
hdr = root_header_sx(ctx)
|
||||
return full_page_sx(ctx, header_rows=hdr,
|
||||
content=_login_page_content(ctx),
|
||||
meta_html='<title>Login \u2014 Rose Ash</title>')
|
||||
|
||||
|
||||
async def render_device_page(ctx: dict) -> str:
|
||||
"""Full page: device authorization form."""
|
||||
hdr = root_header_sx(ctx)
|
||||
return full_page_sx(ctx, header_rows=hdr,
|
||||
content=_device_page_content(ctx),
|
||||
meta_html='<title>Authorize Device \u2014 Rose Ash</title>')
|
||||
|
||||
|
||||
async def render_device_approved_page(ctx: dict) -> str:
|
||||
"""Full page: device approved."""
|
||||
hdr = root_header_sx(ctx)
|
||||
return full_page_sx(ctx, header_rows=hdr,
|
||||
content=_device_approved_content(),
|
||||
meta_html='<title>Device Authorized \u2014 Rose Ash</title>')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Check email page (POST /start/ success)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _check_email_content(email: str, email_error: str | None = None) -> str:
|
||||
"""Check email confirmation content."""
|
||||
from markupsafe import escape
|
||||
|
||||
error_sx = sx_call(
|
||||
"auth-check-email-error", error=str(escape(email_error))
|
||||
) if email_error else ""
|
||||
|
||||
return sx_call(
|
||||
"auth-check-email",
|
||||
email=str(escape(email)),
|
||||
error=SxExpr(error_sx) if error_sx else None,
|
||||
)
|
||||
|
||||
|
||||
async def render_check_email_page(ctx: dict) -> str:
|
||||
"""Full page: check email after magic link sent."""
|
||||
email = ctx.get("email", "")
|
||||
email_error = ctx.get("email_error")
|
||||
hdr = root_header_sx(ctx)
|
||||
return full_page_sx(ctx, header_rows=hdr,
|
||||
content=_check_email_content(email, email_error),
|
||||
meta_html='<title>Check your email \u2014 Rose Ash</title>')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Fragment renderers for POST handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def render_newsletter_toggle(un) -> str:
|
||||
"""Render a newsletter toggle switch for POST response (uses account_url)."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from quart import g
|
||||
account_url_fn = getattr(g, "_account_url", None)
|
||||
if account_url_fn is None:
|
||||
from shared.infrastructure.urls import account_url
|
||||
account_url_fn = account_url
|
||||
return _newsletter_toggle_sx(un, account_url_fn, generate_csrf_token())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _sx_escape(s: str) -> str:
|
||||
"""Escape a string for embedding in sx string literals."""
|
||||
return s.replace("\\", "\\\\").replace('"', '\\"')
|
||||
@@ -1,13 +1,10 @@
|
||||
"""Account defpage setup — registers layouts, page helpers, and loads .sx pages."""
|
||||
"""Account defpage setup — registers layouts and loads .sx pages."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def setup_account_pages() -> None:
|
||||
"""Register account-specific layouts, page helpers, and load page definitions."""
|
||||
"""Register account-specific layouts and load page definitions."""
|
||||
_register_account_layouts()
|
||||
_register_account_helpers()
|
||||
_load_account_page_files()
|
||||
|
||||
|
||||
@@ -17,89 +14,6 @@ def _load_account_page_files() -> None:
|
||||
load_page_dir(os.path.dirname(__file__), "account")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Layouts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _register_account_layouts() -> None:
|
||||
from shared.sx.layouts import register_custom_layout
|
||||
register_custom_layout("account", _account_full, _account_oob, _account_mobile)
|
||||
|
||||
|
||||
def _account_full(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import root_header_sx, header_child_sx
|
||||
from sx.sx_components import _auth_header_sx
|
||||
|
||||
root_hdr = root_header_sx(ctx)
|
||||
hdr_child = header_child_sx(_auth_header_sx(ctx))
|
||||
return "(<> " + root_hdr + " " + hdr_child + ")"
|
||||
|
||||
|
||||
def _account_oob(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import root_header_sx
|
||||
from sx.sx_components import _auth_header_sx
|
||||
|
||||
return "(<> " + _auth_header_sx(ctx, oob=True) + " " + root_header_sx(ctx, oob=True) + ")"
|
||||
|
||||
|
||||
def _account_mobile(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import mobile_menu_sx, mobile_root_nav_sx, sx_call, SxExpr
|
||||
from sx.sx_components import _auth_nav_mobile_sx
|
||||
ctx = _inject_account_nav(ctx)
|
||||
auth_section = sx_call("mobile-menu-section",
|
||||
label="account", href="/", level=1, colour="sky",
|
||||
items=SxExpr(_auth_nav_mobile_sx(ctx)))
|
||||
return mobile_menu_sx(auth_section, mobile_root_nav_sx(ctx))
|
||||
|
||||
|
||||
def _inject_account_nav(ctx: dict) -> dict:
|
||||
"""Ensure account_nav is in ctx from g.account_nav."""
|
||||
if "account_nav" not in ctx:
|
||||
from quart import g
|
||||
ctx = dict(ctx)
|
||||
ctx["account_nav"] = getattr(g, "account_nav", "")
|
||||
return ctx
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Page helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _register_account_helpers() -> None:
|
||||
from shared.sx.pages import register_page_helpers
|
||||
|
||||
register_page_helpers("account", {
|
||||
"account-content": _h_account_content,
|
||||
"newsletters-content": _h_newsletters_content,
|
||||
"fragment-content": _h_fragment_content,
|
||||
})
|
||||
|
||||
|
||||
def _h_account_content():
|
||||
from sx.sx_components import _account_main_panel_sx
|
||||
return _account_main_panel_sx({})
|
||||
|
||||
|
||||
def _h_newsletters_content():
|
||||
from quart import g
|
||||
d = getattr(g, "newsletters_data", None)
|
||||
if not d:
|
||||
from shared.sx.helpers import sx_call
|
||||
return sx_call("account-newsletter-empty")
|
||||
from shared.sx.page import get_template_context_sync
|
||||
from sx.sx_components import _newsletters_panel_sx
|
||||
# Build a minimal ctx with account_url
|
||||
ctx = {"account_url": getattr(g, "_account_url", None)}
|
||||
if ctx["account_url"] is None:
|
||||
from shared.infrastructure.urls import account_url
|
||||
ctx["account_url"] = account_url
|
||||
return _newsletters_panel_sx(ctx, d)
|
||||
|
||||
|
||||
def _h_fragment_content():
|
||||
from quart import g
|
||||
frag = getattr(g, "fragment_page_data", None)
|
||||
if not frag:
|
||||
return ""
|
||||
from sx.sx_components import _fragment_content
|
||||
return _fragment_content(frag)
|
||||
from shared.sx.layouts import register_sx_layout
|
||||
register_sx_layout("account", "account-layout-full", "account-layout-oob", "account-layout-mobile")
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
:path "/"
|
||||
:auth :login
|
||||
:layout :account
|
||||
:content (account-content))
|
||||
:content (~dashboard/content))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Newsletters
|
||||
@@ -18,7 +18,10 @@
|
||||
:path "/newsletters/"
|
||||
:auth :login
|
||||
:layout :account
|
||||
:content (newsletters-content))
|
||||
:data (service "account-page" "newsletters-data")
|
||||
:content (~newsletters/content
|
||||
:newsletter-list newsletter-list
|
||||
:account-url account-url))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Fragment pages (tickets, bookings, etc. from events service)
|
||||
@@ -28,4 +31,10 @@
|
||||
:path "/<slug>/"
|
||||
:auth :login
|
||||
:layout :account
|
||||
:content (fragment-content))
|
||||
:content (let* ((user (current-user))
|
||||
(result (frag "events" "account-page"
|
||||
:slug slug
|
||||
:user-id (str (get user "id")))))
|
||||
(if (or (nil? result) (empty? result))
|
||||
(abort 404)
|
||||
result)))
|
||||
|
||||
12
blog/actions.sx
Normal file
12
blog/actions.sx
Normal file
@@ -0,0 +1,12 @@
|
||||
;; Blog service — inter-service action endpoints
|
||||
|
||||
(defaction update-page-config (&key container-type container-id
|
||||
features sumup-merchant-code
|
||||
sumup-checkout-prefix sumup-api-key)
|
||||
"Create or update a PageConfig with features and SumUp settings."
|
||||
(service "page-config" "update"
|
||||
:container-type container-type :container-id container-id
|
||||
:features features
|
||||
:sumup-merchant-code sumup-merchant-code
|
||||
:sumup-checkout-prefix sumup-checkout-prefix
|
||||
:sumup-api-key sumup-api-key))
|
||||
23
blog/app.py
23
blog/app.py
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
import path_setup # noqa: F401 # adds shared/ to sys.path
|
||||
import sx.sx_components as sx_components # noqa: F401 # ensure Hypercorn --reload watches this file
|
||||
from pathlib import Path
|
||||
|
||||
from quart import g, request
|
||||
@@ -16,7 +15,6 @@ from bp import (
|
||||
register_admin,
|
||||
register_menu_items,
|
||||
register_snippets,
|
||||
register_fragments,
|
||||
register_data,
|
||||
register_actions,
|
||||
)
|
||||
@@ -108,7 +106,9 @@ def create_app() -> "Quart":
|
||||
app.register_blueprint(register_admin("/settings"))
|
||||
app.register_blueprint(register_menu_items())
|
||||
app.register_blueprint(register_snippets())
|
||||
app.register_blueprint(register_fragments())
|
||||
from shared.sx.handlers import auto_mount_fragment_handlers
|
||||
auto_mount_fragment_handlers(app, "blog")
|
||||
|
||||
app.register_blueprint(register_data())
|
||||
app.register_blueprint(register_actions())
|
||||
|
||||
@@ -162,6 +162,23 @@ def create_app() -> "Quart":
|
||||
)
|
||||
return jsonify(resp)
|
||||
|
||||
# Auto-mount all defpages with absolute paths
|
||||
from shared.sx.pages import auto_mount_pages
|
||||
auto_mount_pages(app, "blog")
|
||||
|
||||
# --- Pass defpage helper data to template context for layouts ---
|
||||
@app.context_processor
|
||||
async def inject_blog_data():
|
||||
import os
|
||||
from shared.config import config as get_config
|
||||
ctx = {
|
||||
"blog_title": get_config()["blog_title"],
|
||||
"base_title": get_config()["title"],
|
||||
"unsplash_api_key": os.environ.get("UNSPLASH_ACCESS_KEY", ""),
|
||||
}
|
||||
ctx.update(getattr(g, '_defpage_ctx', {}))
|
||||
return ctx
|
||||
|
||||
# --- debug: url rules ---
|
||||
@app.get("/__rules")
|
||||
async def dump_rules():
|
||||
|
||||
@@ -2,6 +2,5 @@ from .blog.routes import register as register_blog_bp
|
||||
from .admin.routes import register as register_admin
|
||||
from .menu_items.routes import register as register_menu_items
|
||||
from .snippets.routes import register as register_snippets
|
||||
from .fragments import register_fragments
|
||||
from .data import register_data
|
||||
from .actions.routes import register as register_actions
|
||||
|
||||
@@ -1,96 +1,14 @@
|
||||
"""Blog app action endpoints.
|
||||
|
||||
Exposes write operations at ``/internal/actions/<action_name>`` for
|
||||
cross-app callers via the internal action client.
|
||||
All actions are defined in ``blog/actions.sx``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, jsonify, request
|
||||
from quart import Blueprint
|
||||
|
||||
from shared.infrastructure.actions import ACTION_HEADER
|
||||
from shared.infrastructure.query_blueprint import create_action_blueprint
|
||||
|
||||
|
||||
def register() -> Blueprint:
|
||||
bp = Blueprint("actions", __name__, url_prefix="/internal/actions")
|
||||
|
||||
@bp.before_request
|
||||
async def _require_action_header():
|
||||
if not request.headers.get(ACTION_HEADER):
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
from shared.infrastructure.internal_auth import validate_internal_request
|
||||
if not validate_internal_request():
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
|
||||
_handlers: dict[str, object] = {}
|
||||
|
||||
@bp.post("/<action_name>")
|
||||
async def handle_action(action_name: str):
|
||||
handler = _handlers.get(action_name)
|
||||
if handler is None:
|
||||
return jsonify({"error": "unknown action"}), 404
|
||||
result = await handler()
|
||||
return jsonify(result or {"ok": True})
|
||||
|
||||
# --- update-page-config ---
|
||||
async def _update_page_config():
|
||||
"""Create or update a PageConfig (page_configs now lives in db_blog)."""
|
||||
from shared.models.page_config import PageConfig
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
data = await request.get_json(force=True)
|
||||
container_type = data.get("container_type", "page")
|
||||
container_id = data.get("container_id")
|
||||
if container_id is None:
|
||||
return {"error": "container_id required"}, 400
|
||||
|
||||
pc = (await g.s.execute(
|
||||
select(PageConfig).where(
|
||||
PageConfig.container_type == container_type,
|
||||
PageConfig.container_id == container_id,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
if pc is None:
|
||||
pc = PageConfig(
|
||||
container_type=container_type,
|
||||
container_id=container_id,
|
||||
features=data.get("features", {}),
|
||||
)
|
||||
g.s.add(pc)
|
||||
await g.s.flush()
|
||||
|
||||
if "features" in data:
|
||||
features = dict(pc.features or {})
|
||||
for key, val in data["features"].items():
|
||||
if isinstance(val, bool):
|
||||
features[key] = val
|
||||
elif val in ("true", "1", "on"):
|
||||
features[key] = True
|
||||
elif val in ("false", "0", "off", None):
|
||||
features[key] = False
|
||||
pc.features = features
|
||||
flag_modified(pc, "features")
|
||||
|
||||
if "sumup_merchant_code" in data:
|
||||
pc.sumup_merchant_code = data["sumup_merchant_code"] or None
|
||||
if "sumup_checkout_prefix" in data:
|
||||
pc.sumup_checkout_prefix = data["sumup_checkout_prefix"] or None
|
||||
if "sumup_api_key" in data:
|
||||
pc.sumup_api_key = data["sumup_api_key"] or None
|
||||
|
||||
await g.s.flush()
|
||||
|
||||
return {
|
||||
"id": pc.id,
|
||||
"container_type": pc.container_type,
|
||||
"container_id": pc.container_id,
|
||||
"features": pc.features or {},
|
||||
"sumup_merchant_code": pc.sumup_merchant_code,
|
||||
"sumup_checkout_prefix": pc.sumup_checkout_prefix,
|
||||
"sumup_configured": bool(pc.sumup_api_key),
|
||||
}
|
||||
|
||||
_handlers["update-page-config"] = _update_page_config
|
||||
|
||||
bp, _handlers = create_action_blueprint("blog")
|
||||
return bp
|
||||
|
||||
@@ -3,13 +3,9 @@ from __future__ import annotations
|
||||
#from quart import Blueprint, g
|
||||
|
||||
from quart import (
|
||||
render_template,
|
||||
make_response,
|
||||
Blueprint,
|
||||
redirect,
|
||||
url_for,
|
||||
request,
|
||||
jsonify
|
||||
)
|
||||
from shared.browser.app.redis_cacher import clear_all_cache
|
||||
from shared.browser.app.authz import require_admin
|
||||
@@ -27,23 +23,6 @@ def register(url_prefix):
|
||||
"base_title": f"{config()['title']} settings",
|
||||
}
|
||||
|
||||
@bp.before_request
|
||||
async def _prepare_page_data():
|
||||
ep = request.endpoint or ""
|
||||
if "defpage_settings_home" in ep:
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _settings_main_panel_sx
|
||||
tctx = await get_template_context()
|
||||
g.settings_content = _settings_main_panel_sx(tctx)
|
||||
elif "defpage_cache_page" in ep:
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _cache_main_panel_sx
|
||||
tctx = await get_template_context()
|
||||
g.cache_content = _cache_main_panel_sx(tctx)
|
||||
|
||||
from shared.sx.pages import mount_pages
|
||||
mount_pages(bp, "blog", names=["settings-home", "cache-page"])
|
||||
|
||||
@bp.post("/cache_clear/")
|
||||
@require_admin
|
||||
async def cache_clear():
|
||||
@@ -54,7 +33,7 @@ def register(url_prefix):
|
||||
html = render_comp("cache-cleared", time_str=now.strftime("%H:%M:%S"))
|
||||
return sx_response(html)
|
||||
|
||||
return redirect(url_for("settings.defpage_cache_page"))
|
||||
return redirect(url_for("defpage_cache_page"))
|
||||
return bp
|
||||
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
from quart import (
|
||||
render_template,
|
||||
make_response,
|
||||
Blueprint,
|
||||
redirect,
|
||||
url_for,
|
||||
@@ -13,9 +11,7 @@ from quart import (
|
||||
from sqlalchemy import select, delete
|
||||
|
||||
from shared.browser.app.authz import require_admin
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
from shared.browser.app.redis_cacher import invalidate_tag_cache
|
||||
from shared.sx.helpers import sx_response
|
||||
|
||||
from models.tag_group import TagGroup, TagGroupTag
|
||||
from models.ghost_content import Tag
|
||||
@@ -46,60 +42,13 @@ async def _unassigned_tags(session):
|
||||
def register():
|
||||
bp = Blueprint("tag_groups_admin", __name__, url_prefix="/settings/tag-groups")
|
||||
|
||||
@bp.before_request
|
||||
async def _prepare_page_data():
|
||||
ep = request.endpoint or ""
|
||||
if "defpage_tag_groups_page" in ep:
|
||||
groups = list(
|
||||
(await g.s.execute(
|
||||
select(TagGroup).order_by(TagGroup.sort_order, TagGroup.name)
|
||||
)).scalars()
|
||||
)
|
||||
unassigned = await _unassigned_tags(g.s)
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _tag_groups_main_panel_sx
|
||||
tctx = await get_template_context()
|
||||
tctx.update({"groups": groups, "unassigned_tags": unassigned})
|
||||
g.tag_groups_content = _tag_groups_main_panel_sx(tctx)
|
||||
elif "defpage_tag_group_edit" in ep:
|
||||
tag_id = (request.view_args or {}).get("id")
|
||||
tg = await g.s.get(TagGroup, tag_id)
|
||||
if not tg:
|
||||
from quart import abort
|
||||
abort(404)
|
||||
assigned_rows = list(
|
||||
(await g.s.execute(
|
||||
select(TagGroupTag.tag_id).where(TagGroupTag.tag_group_id == tag_id)
|
||||
)).scalars()
|
||||
)
|
||||
all_tags = list(
|
||||
(await g.s.execute(
|
||||
select(Tag).where(
|
||||
Tag.deleted_at.is_(None),
|
||||
(Tag.visibility == "public") | (Tag.visibility.is_(None)),
|
||||
).order_by(Tag.name)
|
||||
)).scalars()
|
||||
)
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _tag_groups_edit_main_panel_sx
|
||||
tctx = await get_template_context()
|
||||
tctx.update({
|
||||
"group": tg,
|
||||
"all_tags": all_tags,
|
||||
"assigned_tag_ids": set(assigned_rows),
|
||||
})
|
||||
g.tag_group_edit_content = _tag_groups_edit_main_panel_sx(tctx)
|
||||
|
||||
from shared.sx.pages import mount_pages
|
||||
mount_pages(bp, "blog", names=["tag-groups-page", "tag-group-edit"])
|
||||
|
||||
@bp.post("/")
|
||||
@require_admin
|
||||
async def create():
|
||||
form = await request.form
|
||||
name = (form.get("name") or "").strip()
|
||||
if not name:
|
||||
return redirect(url_for("blog.tag_groups_admin.defpage_tag_groups_page"))
|
||||
return redirect(url_for("defpage_tag_groups_page"))
|
||||
|
||||
slug = _slugify(name)
|
||||
feature_image = (form.get("feature_image") or "").strip() or None
|
||||
@@ -115,14 +64,14 @@ def register():
|
||||
await g.s.flush()
|
||||
|
||||
await invalidate_tag_cache("blog")
|
||||
return redirect(url_for("blog.tag_groups_admin.defpage_tag_groups_page"))
|
||||
return redirect(url_for("defpage_tag_groups_page"))
|
||||
|
||||
@bp.post("/<int:id>/")
|
||||
@require_admin
|
||||
async def save(id: int):
|
||||
tg = await g.s.get(TagGroup, id)
|
||||
if not tg:
|
||||
return redirect(url_for("blog.tag_groups_admin.defpage_tag_groups_page"))
|
||||
return redirect(url_for("defpage_tag_groups_page"))
|
||||
|
||||
form = await request.form
|
||||
name = (form.get("name") or "").strip()
|
||||
@@ -153,7 +102,7 @@ def register():
|
||||
await g.s.flush()
|
||||
|
||||
await invalidate_tag_cache("blog")
|
||||
return redirect(url_for("blog.tag_groups_admin.defpage_tag_group_edit", id=id))
|
||||
return redirect(url_for("defpage_tag_group_edit", id=id))
|
||||
|
||||
@bp.post("/<int:id>/delete/")
|
||||
@require_admin
|
||||
@@ -163,6 +112,6 @@ def register():
|
||||
await g.s.delete(tg)
|
||||
await g.s.flush()
|
||||
await invalidate_tag_cache("blog")
|
||||
return redirect(url_for("blog.tag_groups_admin.defpage_tag_groups_page"))
|
||||
return redirect(url_for("defpage_tag_groups_page"))
|
||||
|
||||
return bp
|
||||
|
||||
@@ -256,7 +256,7 @@ def _image(node: dict) -> str:
|
||||
parts.append(f':width "{_esc(width)}"')
|
||||
if href:
|
||||
parts.append(f':href "{_esc(href)}"')
|
||||
return "(~kg-image " + " ".join(parts) + ")"
|
||||
return "(~kg_cards/kg-image " + " ".join(parts) + ")"
|
||||
|
||||
|
||||
@_converter("gallery")
|
||||
@@ -282,14 +282,14 @@ def _gallery(node: dict) -> str:
|
||||
images_sx = "(list " + " ".join(rows) + ")"
|
||||
caption = node.get("caption", "")
|
||||
caption_attr = f" :caption {html_to_sx(caption)}" if caption else ""
|
||||
return f"(~kg-gallery :images {images_sx}{caption_attr})"
|
||||
return f"(~kg_cards/kg-gallery :images {images_sx}{caption_attr})"
|
||||
|
||||
|
||||
@_converter("html")
|
||||
def _html_card(node: dict) -> str:
|
||||
raw = node.get("html", "")
|
||||
inner = html_to_sx(raw)
|
||||
return f"(~kg-html {inner})"
|
||||
return f"(~kg_cards/kg-html {inner})"
|
||||
|
||||
|
||||
@_converter("embed")
|
||||
@@ -299,7 +299,7 @@ def _embed(node: dict) -> str:
|
||||
parts = [f':html "{_esc(embed_html)}"']
|
||||
if caption:
|
||||
parts.append(f":caption {html_to_sx(caption)}")
|
||||
return "(~kg-embed " + " ".join(parts) + ")"
|
||||
return "(~kg_cards/kg-embed " + " ".join(parts) + ")"
|
||||
|
||||
|
||||
@_converter("bookmark")
|
||||
@@ -330,7 +330,7 @@ def _bookmark(node: dict) -> str:
|
||||
if caption:
|
||||
parts.append(f":caption {html_to_sx(caption)}")
|
||||
|
||||
return "(~kg-bookmark " + " ".join(parts) + ")"
|
||||
return "(~kg_cards/kg-bookmark " + " ".join(parts) + ")"
|
||||
|
||||
|
||||
@_converter("callout")
|
||||
@@ -344,7 +344,7 @@ def _callout(node: dict) -> str:
|
||||
parts.append(f':emoji "{_esc(emoji)}"')
|
||||
if inner:
|
||||
parts.append(f':content {inner}')
|
||||
return "(~kg-callout " + " ".join(parts) + ")"
|
||||
return "(~kg_cards/kg-callout " + " ".join(parts) + ")"
|
||||
|
||||
|
||||
@_converter("button")
|
||||
@@ -352,7 +352,7 @@ def _button(node: dict) -> str:
|
||||
text = node.get("buttonText", "")
|
||||
url = node.get("buttonUrl", "")
|
||||
alignment = node.get("alignment", "center")
|
||||
return f'(~kg-button :url "{_esc(url)}" :text "{_esc(text)}" :alignment "{_esc(alignment)}")'
|
||||
return f'(~kg_cards/kg-button :url "{_esc(url)}" :text "{_esc(text)}" :alignment "{_esc(alignment)}")'
|
||||
|
||||
|
||||
@_converter("toggle")
|
||||
@@ -360,7 +360,7 @@ def _toggle(node: dict) -> str:
|
||||
heading = node.get("heading", "")
|
||||
inner = _convert_children(node.get("children", []))
|
||||
content_attr = f" :content {inner}" if inner else ""
|
||||
return f'(~kg-toggle :heading "{_esc(heading)}"{content_attr})'
|
||||
return f'(~kg_cards/kg-toggle :heading "{_esc(heading)}"{content_attr})'
|
||||
|
||||
|
||||
@_converter("audio")
|
||||
@@ -380,7 +380,7 @@ def _audio(node: dict) -> str:
|
||||
parts.append(f':duration "{duration_str}"')
|
||||
if thumbnail:
|
||||
parts.append(f':thumbnail "{_esc(thumbnail)}"')
|
||||
return "(~kg-audio " + " ".join(parts) + ")"
|
||||
return "(~kg_cards/kg-audio " + " ".join(parts) + ")"
|
||||
|
||||
|
||||
@_converter("video")
|
||||
@@ -400,7 +400,7 @@ def _video(node: dict) -> str:
|
||||
parts.append(f':thumbnail "{_esc(thumbnail)}"')
|
||||
if loop:
|
||||
parts.append(":loop true")
|
||||
return "(~kg-video " + " ".join(parts) + ")"
|
||||
return "(~kg_cards/kg-video " + " ".join(parts) + ")"
|
||||
|
||||
|
||||
@_converter("file")
|
||||
@@ -429,12 +429,12 @@ def _file(node: dict) -> str:
|
||||
parts.append(f':filesize "{size_str}"')
|
||||
if caption:
|
||||
parts.append(f":caption {html_to_sx(caption)}")
|
||||
return "(~kg-file " + " ".join(parts) + ")"
|
||||
return "(~kg_cards/kg-file " + " ".join(parts) + ")"
|
||||
|
||||
|
||||
@_converter("paywall")
|
||||
def _paywall(_node: dict) -> str:
|
||||
return "(~kg-paywall)"
|
||||
return "(~kg_cards/kg-paywall)"
|
||||
|
||||
|
||||
@_converter("markdown")
|
||||
@@ -442,4 +442,4 @@ def _markdown(node: dict) -> str:
|
||||
md_text = node.get("markdown", "")
|
||||
rendered = mistune.html(md_text)
|
||||
inner = html_to_sx(rendered)
|
||||
return f"(~kg-md {inner})"
|
||||
return f"(~kg_cards/kg-md {inner})"
|
||||
|
||||
@@ -21,7 +21,7 @@ from .services.pages_data import pages_data
|
||||
from shared.browser.app.redis_cacher import cache_page, invalidate_tag_cache
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
from shared.browser.app.authz import require_admin
|
||||
from shared.sx.helpers import sx_response
|
||||
from shared.sx.helpers import sx_response, sx_call
|
||||
from shared.utils import host_url
|
||||
|
||||
def register(url_prefix, title):
|
||||
@@ -53,16 +53,6 @@ def register(url_prefix, title):
|
||||
@blogs_bp.before_request
|
||||
async def route():
|
||||
g.makeqs_factory = makeqs_factory
|
||||
ep = request.endpoint or ""
|
||||
if "defpage_new_post" in ep:
|
||||
from sx.sx_components import render_editor_panel
|
||||
g.editor_content = render_editor_panel()
|
||||
elif "defpage_new_page" in ep:
|
||||
from sx.sx_components import render_editor_panel
|
||||
g.editor_page_content = render_editor_panel(is_page=True)
|
||||
|
||||
from shared.sx.pages import mount_pages
|
||||
mount_pages(blogs_bp, "blog", names=["new-post", "new-page"])
|
||||
|
||||
@blogs_bp.context_processor
|
||||
async def inject_root():
|
||||
@@ -72,6 +62,19 @@ def register(url_prefix, title):
|
||||
"unsplash_api_key": os.environ.get("UNSPLASH_ACCESS_KEY", ""),
|
||||
}
|
||||
|
||||
async def _render_new_post_page(tctx):
|
||||
"""Compose a full page with blog header for new post/page creation."""
|
||||
from shared.sx.helpers import root_header_sx, full_page_sx
|
||||
from shared.sx.parser import SxExpr
|
||||
root_hdr = await root_header_sx(tctx)
|
||||
blog_hdr = sx_call("menu-row-sx",
|
||||
id="blog-row", level=1,
|
||||
link_label_content=SxExpr("(div)"),
|
||||
child_id="blog-header-child")
|
||||
header_rows = "(<> " + root_hdr + " " + blog_hdr + ")"
|
||||
content = tctx.get("editor_html", "")
|
||||
return await full_page_sx(tctx, header_rows=header_rows, content=content)
|
||||
|
||||
SORT_MAP = {
|
||||
"newest": "published_at DESC",
|
||||
"oldest": "published_at ASC",
|
||||
@@ -128,100 +131,83 @@ def register(url_prefix, title):
|
||||
ctx["page_cart_total"] = float(page_summary.total + page_summary.calendar_total + page_summary.ticket_total)
|
||||
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_home_page, render_home_oob
|
||||
from shared.sx.helpers import (
|
||||
sx_call, root_header_sx, full_page_sx, oob_page_sx,
|
||||
post_header_sx, oob_header_sx, mobile_menu_sx,
|
||||
post_mobile_nav_sx, mobile_root_nav_sx,
|
||||
)
|
||||
from shared.sx.parser import SxExpr
|
||||
from shared.services.registry import services
|
||||
|
||||
tctx = await get_template_context()
|
||||
tctx.update(ctx)
|
||||
|
||||
post = ctx.get("post", {})
|
||||
content = sx_call("blog-home-main",
|
||||
html_content=post.get("html", ""),
|
||||
sx_content=SxExpr(post.get("sx_content", "")) if post.get("sx_content") else None)
|
||||
meta_data = services.blog_page.post_meta_data(post, ctx.get("base_title", ""))
|
||||
meta = sx_call("blog-meta", **meta_data)
|
||||
|
||||
if not is_htmx_request():
|
||||
html = await render_home_page(tctx)
|
||||
root_hdr = await root_header_sx(tctx)
|
||||
post_hdr = await post_header_sx(tctx)
|
||||
header_rows = "(<> " + root_hdr + " " + post_hdr + ")"
|
||||
menu = mobile_menu_sx(await post_mobile_nav_sx(tctx), await mobile_root_nav_sx(tctx))
|
||||
html = await full_page_sx(tctx, header_rows=header_rows, content=content,
|
||||
meta=meta, menu=menu)
|
||||
return await make_response(html)
|
||||
else:
|
||||
sx_src = await render_home_oob(tctx)
|
||||
root_hdr = await root_header_sx(tctx)
|
||||
post_hdr = await post_header_sx(tctx)
|
||||
rows = "(<> " + root_hdr + " " + post_hdr + ")"
|
||||
header_oob = await oob_header_sx("root-header-child", "post-header-child", rows)
|
||||
sx_src = await oob_page_sx(oobs=header_oob, content=content)
|
||||
return sx_response(sx_src)
|
||||
|
||||
@blogs_bp.get("/index")
|
||||
@blogs_bp.get("/index/")
|
||||
async def index():
|
||||
"""Blog listing — moved from / to /index."""
|
||||
|
||||
q = decode()
|
||||
content_type = request.args.get("type", "posts")
|
||||
|
||||
if content_type == "pages":
|
||||
data = await pages_data(g.s, q.page, q.search)
|
||||
context = {
|
||||
**data,
|
||||
"content_type": "pages",
|
||||
"search": q.search,
|
||||
"selected_tags": (),
|
||||
"selected_authors": (),
|
||||
"selected_groups": (),
|
||||
"sort": None,
|
||||
"view": None,
|
||||
"drafts": None,
|
||||
"draft_count": 0,
|
||||
"tags": [],
|
||||
"authors": [],
|
||||
"tag_groups": [],
|
||||
"posts": data.get("pages", []),
|
||||
}
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_blog_page, render_blog_oob, render_blog_page_cards
|
||||
|
||||
tctx = await get_template_context()
|
||||
tctx.update(context)
|
||||
if not is_htmx_request():
|
||||
html = await render_blog_page(tctx)
|
||||
return await make_response(html)
|
||||
elif q.page > 1:
|
||||
sx_src = await render_blog_page_cards(tctx)
|
||||
return sx_response(sx_src)
|
||||
else:
|
||||
sx_src = await render_blog_oob(tctx)
|
||||
return sx_response(sx_src)
|
||||
|
||||
# Default: posts listing
|
||||
# Drafts filter requires login; ignore if not logged in
|
||||
show_drafts = bool(q.drafts and g.user)
|
||||
is_admin = bool((g.get("rights") or {}).get("admin"))
|
||||
drafts_user_id = None if (not show_drafts or is_admin) else g.user.id
|
||||
|
||||
# For the draft count badge: admin sees all drafts, non-admin sees own
|
||||
count_drafts_uid = None if (g.user and is_admin) else (g.user.id if g.user else False)
|
||||
|
||||
data = await posts_data(
|
||||
g.s, q.page, q.search, q.sort, q.selected_tags, q.selected_authors, q.liked,
|
||||
drafts=show_drafts, drafts_user_id=drafts_user_id,
|
||||
count_drafts_for_user_id=count_drafts_uid,
|
||||
selected_groups=q.selected_groups,
|
||||
from shared.services.registry import services
|
||||
from shared.sx.helpers import (
|
||||
sx_call, root_header_sx, full_page_sx, oob_page_sx, oob_header_sx,
|
||||
)
|
||||
from shared.sx.parser import SxExpr
|
||||
|
||||
context = {
|
||||
**data,
|
||||
"content_type": "posts",
|
||||
"selected_tags": q.selected_tags,
|
||||
"selected_authors": q.selected_authors,
|
||||
"selected_groups": q.selected_groups,
|
||||
"sort": q.sort,
|
||||
"search": q.search,
|
||||
"view": q.view,
|
||||
"drafts": q.drafts if show_drafts else None,
|
||||
}
|
||||
def _blog_hdr(ctx, oob=False):
|
||||
return sx_call("menu-row-sx",
|
||||
id="blog-row", level=1,
|
||||
link_label_content=SxExpr("(div)"),
|
||||
child_id="blog-header-child", oob=oob)
|
||||
|
||||
data = await services.blog_page.index_data(g.s)
|
||||
|
||||
# Render content, aside, and filter via .sx defcomps
|
||||
content = sx_call("blog-index-main-content", **data)
|
||||
aside = sx_call("blog-index-aside-content", **data)
|
||||
filter_sx = sx_call("blog-index-filter-content", **data)
|
||||
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_blog_page, render_blog_oob, render_blog_cards
|
||||
|
||||
tctx = await get_template_context()
|
||||
tctx.update(context)
|
||||
|
||||
if not is_htmx_request():
|
||||
html = await render_blog_page(tctx)
|
||||
root_hdr = await root_header_sx(tctx)
|
||||
blog_hdr = _blog_hdr(tctx)
|
||||
header_rows = "(<> " + root_hdr + " " + blog_hdr + ")"
|
||||
html = await full_page_sx(tctx, header_rows=header_rows,
|
||||
content=content, aside=aside, filter=filter_sx)
|
||||
return await make_response(html)
|
||||
elif q.page > 1:
|
||||
# Sx wire format — client renders blog cards
|
||||
sx_src = await render_blog_cards(tctx)
|
||||
return sx_response(sx_src)
|
||||
elif data.get("page", 1) > 1:
|
||||
# Pagination — return just the cards
|
||||
return sx_response(content)
|
||||
else:
|
||||
sx_src = await render_blog_oob(tctx)
|
||||
root_hdr = await root_header_sx(tctx)
|
||||
blog_hdr = _blog_hdr(tctx)
|
||||
rows = "(<> " + root_hdr + " " + blog_hdr + ")"
|
||||
header_oob = await oob_header_sx("root-header-child", "blog-header-child", rows)
|
||||
sx_src = await oob_page_sx(oobs=header_oob, content=content,
|
||||
aside=aside, filter=filter_sx)
|
||||
return sx_response(sx_src)
|
||||
|
||||
@blogs_bp.post("/new/")
|
||||
@@ -243,19 +229,19 @@ def register(url_prefix, title):
|
||||
lexical_doc = json.loads(lexical_raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_new_post_page, render_editor_panel
|
||||
from sxc.pages.renders import render_editor_panel
|
||||
tctx = await get_template_context()
|
||||
tctx["editor_html"] = render_editor_panel(save_error="Invalid JSON in editor content.")
|
||||
html = await render_new_post_page(tctx)
|
||||
html = await _render_new_post_page(tctx)
|
||||
return await make_response(html, 400)
|
||||
|
||||
ok, reason = validate_lexical(lexical_doc)
|
||||
if not ok:
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_new_post_page, render_editor_panel
|
||||
from sxc.pages.renders import render_editor_panel
|
||||
tctx = await get_template_context()
|
||||
tctx["editor_html"] = render_editor_panel(save_error=reason)
|
||||
html = await render_new_post_page(tctx)
|
||||
html = await _render_new_post_page(tctx)
|
||||
return await make_response(html, 400)
|
||||
|
||||
# Create directly in db_blog
|
||||
@@ -277,7 +263,7 @@ def register(url_prefix, title):
|
||||
await invalidate_tag_cache("blog")
|
||||
|
||||
# Redirect to the edit page
|
||||
return redirect(host_url(url_for("blog.post.admin.defpage_post_edit", slug=post.slug)))
|
||||
return redirect(host_url(url_for("defpage_post_edit", slug=post.slug)))
|
||||
|
||||
|
||||
@blogs_bp.post("/new-page/")
|
||||
@@ -299,21 +285,21 @@ def register(url_prefix, title):
|
||||
lexical_doc = json.loads(lexical_raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_new_post_page, render_editor_panel
|
||||
from sxc.pages.renders import render_editor_panel
|
||||
tctx = await get_template_context()
|
||||
tctx["editor_html"] = render_editor_panel(save_error="Invalid JSON in editor content.", is_page=True)
|
||||
tctx["is_page"] = True
|
||||
html = await render_new_post_page(tctx)
|
||||
html = await _render_new_post_page(tctx)
|
||||
return await make_response(html, 400)
|
||||
|
||||
ok, reason = validate_lexical(lexical_doc)
|
||||
if not ok:
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_new_post_page, render_editor_panel
|
||||
from sxc.pages.renders import render_editor_panel
|
||||
tctx = await get_template_context()
|
||||
tctx["editor_html"] = render_editor_panel(save_error=reason, is_page=True)
|
||||
tctx["is_page"] = True
|
||||
html = await render_new_post_page(tctx)
|
||||
html = await _render_new_post_page(tctx)
|
||||
return await make_response(html, 400)
|
||||
|
||||
# Create directly in db_blog
|
||||
@@ -335,7 +321,7 @@ def register(url_prefix, title):
|
||||
await invalidate_tag_cache("blog")
|
||||
|
||||
# Redirect to the page admin
|
||||
return redirect(host_url(url_for("blog.post.admin.defpage_post_edit", slug=page.slug)))
|
||||
return redirect(host_url(url_for("defpage_post_edit", slug=page.slug)))
|
||||
|
||||
|
||||
@blogs_bp.get("/drafts/")
|
||||
|
||||
@@ -126,7 +126,7 @@ _CARD_MARKER_RE = re.compile(
|
||||
def _parse_card_fragments(html: str) -> dict[str, str]:
|
||||
"""Parse the container-cards fragment into {post_id_str: html} dict."""
|
||||
result = {}
|
||||
for m in _CARD_MARKER_RE.finditer(html):
|
||||
for m in _CARD_MARKER_RE.finditer(str(html)):
|
||||
post_id_str = m.group(1)
|
||||
inner = m.group(2).strip()
|
||||
if inner:
|
||||
|
||||
@@ -1,185 +1,14 @@
|
||||
"""Blog app data endpoints.
|
||||
|
||||
Exposes read-only JSON queries at ``/internal/data/<query_name>`` for
|
||||
cross-app callers via the internal data client.
|
||||
All queries are defined in ``blog/queries.sx``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, jsonify, request
|
||||
from quart import Blueprint
|
||||
|
||||
from shared.infrastructure.data_client import DATA_HEADER
|
||||
from shared.contracts.dtos import dto_to_dict
|
||||
from services import blog_service
|
||||
from shared.infrastructure.query_blueprint import create_data_blueprint
|
||||
|
||||
|
||||
def register() -> Blueprint:
|
||||
bp = Blueprint("data", __name__, url_prefix="/internal/data")
|
||||
|
||||
@bp.before_request
|
||||
async def _require_data_header():
|
||||
if not request.headers.get(DATA_HEADER):
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
from shared.infrastructure.internal_auth import validate_internal_request
|
||||
if not validate_internal_request():
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
|
||||
_handlers: dict[str, object] = {}
|
||||
|
||||
@bp.get("/<query_name>")
|
||||
async def handle_query(query_name: str):
|
||||
handler = _handlers.get(query_name)
|
||||
if handler is None:
|
||||
return jsonify({"error": "unknown query"}), 404
|
||||
result = await handler()
|
||||
return jsonify(result)
|
||||
|
||||
# --- post-by-slug ---
|
||||
async def _post_by_slug():
|
||||
slug = request.args.get("slug", "")
|
||||
post = await blog_service.get_post_by_slug(g.s, slug)
|
||||
if not post:
|
||||
return None
|
||||
return dto_to_dict(post)
|
||||
|
||||
_handlers["post-by-slug"] = _post_by_slug
|
||||
|
||||
# --- post-by-id ---
|
||||
async def _post_by_id():
|
||||
post_id = int(request.args.get("id", 0))
|
||||
post = await blog_service.get_post_by_id(g.s, post_id)
|
||||
if not post:
|
||||
return None
|
||||
return dto_to_dict(post)
|
||||
|
||||
_handlers["post-by-id"] = _post_by_id
|
||||
|
||||
# --- posts-by-ids ---
|
||||
async def _posts_by_ids():
|
||||
ids_raw = request.args.get("ids", "")
|
||||
if not ids_raw:
|
||||
return []
|
||||
ids = [int(x.strip()) for x in ids_raw.split(",") if x.strip()]
|
||||
posts = await blog_service.get_posts_by_ids(g.s, ids)
|
||||
return [dto_to_dict(p) for p in posts]
|
||||
|
||||
_handlers["posts-by-ids"] = _posts_by_ids
|
||||
|
||||
# --- search-posts ---
|
||||
async def _search_posts():
|
||||
query = request.args.get("query", "")
|
||||
page = int(request.args.get("page", 1))
|
||||
per_page = int(request.args.get("per_page", 10))
|
||||
posts, total = await blog_service.search_posts(g.s, query, page, per_page)
|
||||
return {"posts": [dto_to_dict(p) for p in posts], "total": total}
|
||||
|
||||
_handlers["search-posts"] = _search_posts
|
||||
|
||||
# --- page-config-ensure ---
|
||||
async def _page_config_ensure():
|
||||
"""Get or create a PageConfig for a container_type + container_id."""
|
||||
from sqlalchemy import select
|
||||
from shared.models.page_config import PageConfig
|
||||
|
||||
container_type = request.args.get("container_type", "page")
|
||||
container_id = request.args.get("container_id", type=int)
|
||||
if container_id is None:
|
||||
return {"error": "container_id required"}, 400
|
||||
|
||||
row = (await g.s.execute(
|
||||
select(PageConfig).where(
|
||||
PageConfig.container_type == container_type,
|
||||
PageConfig.container_id == container_id,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
if row is None:
|
||||
row = PageConfig(
|
||||
container_type=container_type,
|
||||
container_id=container_id,
|
||||
features={},
|
||||
)
|
||||
g.s.add(row)
|
||||
await g.s.flush()
|
||||
|
||||
return {
|
||||
"id": row.id,
|
||||
"container_type": row.container_type,
|
||||
"container_id": row.container_id,
|
||||
}
|
||||
|
||||
_handlers["page-config-ensure"] = _page_config_ensure
|
||||
|
||||
# --- page-config ---
|
||||
async def _page_config():
|
||||
"""Return a single PageConfig by container_type + container_id."""
|
||||
from sqlalchemy import select
|
||||
from shared.models.page_config import PageConfig
|
||||
|
||||
ct = request.args.get("container_type", "page")
|
||||
cid = request.args.get("container_id", type=int)
|
||||
if cid is None:
|
||||
return None
|
||||
pc = (await g.s.execute(
|
||||
select(PageConfig).where(
|
||||
PageConfig.container_type == ct,
|
||||
PageConfig.container_id == cid,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if not pc:
|
||||
return None
|
||||
return _page_config_dict(pc)
|
||||
|
||||
_handlers["page-config"] = _page_config
|
||||
|
||||
# --- page-config-by-id ---
|
||||
async def _page_config_by_id():
|
||||
"""Return a single PageConfig by its primary key."""
|
||||
from shared.models.page_config import PageConfig
|
||||
|
||||
pc_id = request.args.get("id", type=int)
|
||||
if pc_id is None:
|
||||
return None
|
||||
pc = await g.s.get(PageConfig, pc_id)
|
||||
if not pc:
|
||||
return None
|
||||
return _page_config_dict(pc)
|
||||
|
||||
_handlers["page-config-by-id"] = _page_config_by_id
|
||||
|
||||
# --- page-configs-batch ---
|
||||
async def _page_configs_batch():
|
||||
"""Return PageConfigs for multiple container_ids (comma-separated)."""
|
||||
from sqlalchemy import select
|
||||
from shared.models.page_config import PageConfig
|
||||
|
||||
ct = request.args.get("container_type", "page")
|
||||
ids_raw = request.args.get("ids", "")
|
||||
if not ids_raw:
|
||||
return []
|
||||
ids = [int(x.strip()) for x in ids_raw.split(",") if x.strip()]
|
||||
if not ids:
|
||||
return []
|
||||
result = await g.s.execute(
|
||||
select(PageConfig).where(
|
||||
PageConfig.container_type == ct,
|
||||
PageConfig.container_id.in_(ids),
|
||||
)
|
||||
)
|
||||
return [_page_config_dict(pc) for pc in result.scalars().all()]
|
||||
|
||||
_handlers["page-configs-batch"] = _page_configs_batch
|
||||
|
||||
bp, _handlers = create_data_blueprint("blog")
|
||||
return bp
|
||||
|
||||
|
||||
def _page_config_dict(pc) -> dict:
|
||||
"""Serialize PageConfig to a JSON-safe dict."""
|
||||
return {
|
||||
"id": pc.id,
|
||||
"container_type": pc.container_type,
|
||||
"container_id": pc.container_id,
|
||||
"features": pc.features or {},
|
||||
"sumup_merchant_code": pc.sumup_merchant_code,
|
||||
"sumup_api_key": pc.sumup_api_key,
|
||||
"sumup_checkout_prefix": pc.sumup_checkout_prefix,
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
from .routes import register as register_fragments
|
||||
@@ -1,36 +0,0 @@
|
||||
"""Blog app fragment endpoints.
|
||||
|
||||
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
|
||||
by other coop apps via the fragment client.
|
||||
|
||||
All handlers are defined declaratively in .sx files under
|
||||
``blog/sx/handlers/`` and dispatched via the sx handler registry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, Response, request
|
||||
|
||||
from shared.infrastructure.fragments import FRAGMENT_HEADER
|
||||
from shared.sx.handlers import get_handler, execute_handler
|
||||
|
||||
|
||||
def register():
|
||||
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
|
||||
|
||||
@bp.before_request
|
||||
async def _require_fragment_header():
|
||||
if not request.headers.get(FRAGMENT_HEADER):
|
||||
return Response("", status=403)
|
||||
|
||||
@bp.get("/<fragment_type>")
|
||||
async def get_fragment(fragment_type: str):
|
||||
handler_def = get_handler("blog", fragment_type)
|
||||
if handler_def is not None:
|
||||
result = await execute_handler(
|
||||
handler_def, "blog", args=dict(request.args),
|
||||
)
|
||||
return Response(result, status=200, content_type="text/sx")
|
||||
return Response("", status=200, content_type="text/sx")
|
||||
|
||||
return bp
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, make_response, request, jsonify, g
|
||||
from quart import Blueprint, make_response, request, jsonify, g, url_for
|
||||
|
||||
from shared.browser.app.authz import require_admin
|
||||
from .services.menu_items import (
|
||||
@@ -12,37 +12,217 @@ from .services.menu_items import (
|
||||
search_pages,
|
||||
MenuItemError,
|
||||
)
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
from shared.sx.helpers import sx_response
|
||||
from markupsafe import escape
|
||||
from shared.sx.helpers import sx_response, sx_call
|
||||
from shared.sx.parser import SxExpr
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
|
||||
|
||||
def _render_menu_items_list(menu_items):
|
||||
"""Serialize ORM menu items and render via .sx defcomp."""
|
||||
csrf = generate_csrf_token()
|
||||
items = []
|
||||
for item in menu_items:
|
||||
items.append({
|
||||
"feature_image": getattr(item, "feature_image", None),
|
||||
"label": getattr(item, "label", "") or "",
|
||||
"url": getattr(item, "url", "") or "",
|
||||
"sort_order": getattr(item, "position", 0) or 0,
|
||||
"edit_url": url_for("menu_items.edit_menu_item", item_id=item.id),
|
||||
"delete_url": url_for("menu_items.delete_menu_item_route", item_id=item.id),
|
||||
})
|
||||
new_url = url_for("menu_items.new_menu_item")
|
||||
return sx_call("blog-menu-items-content",
|
||||
menu_items=items, new_url=new_url, csrf=csrf)
|
||||
|
||||
|
||||
def _render_menu_item_form(menu_item=None) -> str:
|
||||
"""Render menu item add/edit form."""
|
||||
csrf = generate_csrf_token()
|
||||
search_url = url_for("menu_items.search_pages_route")
|
||||
is_edit = menu_item is not None
|
||||
|
||||
if is_edit:
|
||||
action_url = url_for("menu_items.update_menu_item_route", item_id=menu_item.id)
|
||||
action_attr = f'sx-put="{action_url}"'
|
||||
post_id = str(menu_item.container_id) if menu_item.container_id else ""
|
||||
label = getattr(menu_item, "label", "") or ""
|
||||
slug = getattr(menu_item, "slug", "") or ""
|
||||
fi = getattr(menu_item, "feature_image", None) or ""
|
||||
else:
|
||||
action_url = url_for("menu_items.create_menu_item_route")
|
||||
action_attr = f'sx-post="{action_url}"'
|
||||
post_id = ""
|
||||
label = ""
|
||||
slug = ""
|
||||
fi = ""
|
||||
|
||||
if post_id:
|
||||
img_html = (f'<img src="{fi}" alt="{label}" class="w-10 h-10 rounded-full object-cover" />'
|
||||
if fi else '<div class="w-10 h-10 rounded-full bg-stone-200"></div>')
|
||||
selected = (f'<div id="selected-page-display" class="mb-3 p-3 bg-stone-50 rounded flex items-center gap-3">'
|
||||
f'{img_html}<div class="flex-1"><div class="font-medium">{label}</div>'
|
||||
f'<div class="text-xs text-stone-500">{slug}</div></div></div>')
|
||||
else:
|
||||
selected = '<div id="selected-page-display" class="mb-3 hidden"></div>'
|
||||
|
||||
close_js = "document.getElementById('menu-item-form').innerHTML = ''"
|
||||
title = "Edit Menu Item" if is_edit else "Add Menu Item"
|
||||
|
||||
html = f'''<div class="bg-white rounded-lg shadow p-6 mb-6" id="menu-item-form-container">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-semibold">{title}</h2>
|
||||
<button type="button" onclick="{close_js}" class="text-stone-400 hover:text-stone-600">
|
||||
<i class="fa fa-times"></i></button>
|
||||
</div>
|
||||
<input type="hidden" name="post_id" id="selected-post-id" value="{post_id}" />
|
||||
{selected}
|
||||
<form {action_attr} sx-target="#menu-items-list" sx-swap="innerHTML"
|
||||
sx-include="#selected-post-id"
|
||||
sx-on:afterRequest="if(event.detail.successful) {{ {close_js} }}"
|
||||
class="space-y-4">
|
||||
<input type="hidden" name="csrf_token" value="{csrf}">
|
||||
<div class="flex gap-2 pb-3 border-b">
|
||||
<button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
||||
<i class="fa fa-save"></i> Save</button>
|
||||
<button type="button" onclick="{close_js}"
|
||||
class="px-4 py-2 border border-stone-300 rounded hover:bg-stone-50">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="mt-4">
|
||||
<label class="block text-sm font-medium text-stone-700 mb-2">Select Page</label>
|
||||
<input type="text" placeholder="Search for a page... (or leave blank for all)"
|
||||
sx-get="{search_url}" sx-trigger="keyup changed delay:300ms, focus once"
|
||||
sx-target="#page-search-results" sx-swap="innerHTML"
|
||||
name="q" id="page-search-input"
|
||||
class="w-full px-3 py-2 border border-stone-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
<div id="page-search-results" class="mt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
document.addEventListener('click', function(e) {{
|
||||
var pageOption = e.target.closest('[data-page-id]');
|
||||
if (pageOption) {{
|
||||
var postId = pageOption.dataset.pageId;
|
||||
var postTitle = pageOption.dataset.pageTitle;
|
||||
var postSlug = pageOption.dataset.pageSlug;
|
||||
var postImage = pageOption.dataset.pageImage;
|
||||
document.getElementById('selected-post-id').value = postId;
|
||||
var display = document.getElementById('selected-page-display');
|
||||
display.innerHTML = '<div class="p-3 bg-stone-50 rounded flex items-center gap-3">' +
|
||||
(postImage ? '<img src="' + postImage + '" alt="' + postTitle + '" class="w-10 h-10 rounded-full object-cover" />' : '<div class="w-10 h-10 rounded-full bg-stone-200"></div>') +
|
||||
'<div class="flex-1"><div class="font-medium">' + postTitle + '</div><div class="text-xs text-stone-500">' + postSlug + '</div></div></div>';
|
||||
display.classList.remove('hidden');
|
||||
document.getElementById('page-search-results').innerHTML = '';
|
||||
}}
|
||||
}});
|
||||
</script>'''
|
||||
return html
|
||||
|
||||
|
||||
def _render_page_search_results(pages, query, page, has_more) -> str:
|
||||
"""Render page search results."""
|
||||
if not pages and query:
|
||||
return sx_call("page-search-empty", query=query)
|
||||
if not pages:
|
||||
return ""
|
||||
|
||||
items = []
|
||||
for post in pages:
|
||||
items.append(sx_call("page-search-item",
|
||||
id=post.id, title=post.title,
|
||||
slug=post.slug,
|
||||
feature_image=post.feature_image or None))
|
||||
|
||||
sentinel = ""
|
||||
if has_more:
|
||||
search_url = url_for("menu_items.search_pages_route")
|
||||
sentinel = sx_call("page-search-sentinel",
|
||||
url=search_url, query=query,
|
||||
next_page=page + 1)
|
||||
|
||||
items_sx = "(<> " + " ".join(items) + ")"
|
||||
return sx_call("page-search-results",
|
||||
items=SxExpr(items_sx),
|
||||
sentinel=sentinel or None)
|
||||
|
||||
|
||||
def _render_menu_items_nav_oob(menu_items) -> str:
|
||||
"""Render OOB nav update for menu items."""
|
||||
from quart import request as qrequest
|
||||
|
||||
if not menu_items:
|
||||
return sx_call("blog-nav-empty", wrapper_id="menu-items-nav-wrapper")
|
||||
|
||||
first_seg = qrequest.path.strip("/").split("/")[0] if qrequest else ""
|
||||
|
||||
select_colours = (
|
||||
"[.hover-capable_&]:hover:bg-yellow-300"
|
||||
" aria-selected:bg-stone-500 aria-selected:text-white"
|
||||
" [.hover-capable_&[aria-selected=true]:hover]:bg-orange-500"
|
||||
)
|
||||
nav_button_cls = (
|
||||
f"justify-center cursor-pointer flex flex-row items-center gap-2"
|
||||
f" rounded bg-stone-200 text-black {select_colours} p-3"
|
||||
)
|
||||
|
||||
container_id = "menu-items-container"
|
||||
arrow_cls = f"scrolling-menu-arrow-{container_id}"
|
||||
|
||||
scroll_hs = (
|
||||
f"on load or scroll"
|
||||
f" if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth"
|
||||
f" remove .hidden from .{arrow_cls} add .flex to .{arrow_cls}"
|
||||
f" else add .hidden to .{arrow_cls} remove .flex from .{arrow_cls} end"
|
||||
)
|
||||
|
||||
item_parts = []
|
||||
for item in menu_items:
|
||||
item_slug = getattr(item, "slug", "") if hasattr(item, "slug") else item.get("slug", "")
|
||||
label = getattr(item, "label", "") if hasattr(item, "label") else item.get("label", "")
|
||||
fi = getattr(item, "feature_image", None) if hasattr(item, "feature_image") else item.get("feature_image")
|
||||
|
||||
href = f"/{item_slug}/"
|
||||
selected = "true" if item_slug == first_seg else "false"
|
||||
|
||||
img_sx = sx_call("img-or-placeholder", src=fi, alt=label,
|
||||
size_cls="w-8 h-8 rounded-full object-cover flex-shrink-0")
|
||||
|
||||
if item_slug != "cart":
|
||||
item_parts.append(sx_call("blog-nav-item-link",
|
||||
href=href, hx_get=f"/{item_slug}/", selected=selected,
|
||||
nav_cls=nav_button_cls, img=img_sx, label=label,
|
||||
))
|
||||
else:
|
||||
item_parts.append(sx_call("blog-nav-item-plain",
|
||||
href=href, selected=selected, nav_cls=nav_button_cls,
|
||||
img=img_sx, label=label,
|
||||
))
|
||||
|
||||
items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else ""
|
||||
|
||||
return sx_call("scroll-nav-wrapper",
|
||||
wrapper_id="menu-items-nav-wrapper", container_id=container_id,
|
||||
arrow_cls=arrow_cls,
|
||||
left_hs=f"on click set #{container_id}.scrollLeft to #{container_id}.scrollLeft - 200",
|
||||
scroll_hs=scroll_hs,
|
||||
right_hs=f"on click set #{container_id}.scrollLeft to #{container_id}.scrollLeft + 200",
|
||||
items=SxExpr(items_sx) if items_sx else None, oob=True,
|
||||
)
|
||||
|
||||
|
||||
def register():
|
||||
bp = Blueprint("menu_items", __name__, url_prefix='/settings/menu_items')
|
||||
|
||||
def get_menu_items_nav_oob_sync(menu_items):
|
||||
"""Helper to generate OOB update for root nav menu items"""
|
||||
from sx.sx_components import render_menu_items_nav_oob
|
||||
return render_menu_items_nav_oob(menu_items)
|
||||
|
||||
@bp.before_request
|
||||
async def _prepare_page_data():
|
||||
if "defpage_" not in (request.endpoint or ""):
|
||||
return
|
||||
menu_items = await get_all_menu_items(g.s)
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _menu_items_main_panel_sx
|
||||
tctx = await get_template_context()
|
||||
tctx["menu_items"] = menu_items
|
||||
g.menu_items_content = _menu_items_main_panel_sx(tctx)
|
||||
|
||||
from shared.sx.pages import mount_pages
|
||||
mount_pages(bp, "blog", names=["menu-items-page"])
|
||||
return _render_menu_items_nav_oob(menu_items)
|
||||
|
||||
@bp.get("/new/")
|
||||
@require_admin
|
||||
async def new_menu_item():
|
||||
"""Show form to create new menu item"""
|
||||
from sx.sx_components import render_menu_item_form
|
||||
return sx_response(render_menu_item_form())
|
||||
return sx_response(_render_menu_item_form())
|
||||
|
||||
@bp.post("/")
|
||||
@require_admin
|
||||
@@ -65,8 +245,7 @@ def register():
|
||||
|
||||
# Get updated list and nav OOB
|
||||
menu_items = await get_all_menu_items(g.s)
|
||||
from sx.sx_components import render_menu_items_list
|
||||
html = render_menu_items_list(menu_items)
|
||||
html = _render_menu_items_list(menu_items)
|
||||
nav_oob = get_menu_items_nav_oob_sync(menu_items)
|
||||
return sx_response(html + nav_oob)
|
||||
|
||||
@@ -81,8 +260,7 @@ def register():
|
||||
if not menu_item:
|
||||
return await make_response("Menu item not found", 404)
|
||||
|
||||
from sx.sx_components import render_menu_item_form
|
||||
return sx_response(render_menu_item_form(menu_item=menu_item))
|
||||
return sx_response(_render_menu_item_form(menu_item=menu_item))
|
||||
|
||||
@bp.put("/<int:item_id>/")
|
||||
@require_admin
|
||||
@@ -105,8 +283,7 @@ def register():
|
||||
|
||||
# Get updated list and nav OOB
|
||||
menu_items = await get_all_menu_items(g.s)
|
||||
from sx.sx_components import render_menu_items_list
|
||||
html = render_menu_items_list(menu_items)
|
||||
html = _render_menu_items_list(menu_items)
|
||||
nav_oob = get_menu_items_nav_oob_sync(menu_items)
|
||||
return sx_response(html + nav_oob)
|
||||
|
||||
@@ -126,8 +303,7 @@ def register():
|
||||
|
||||
# Get updated list and nav OOB
|
||||
menu_items = await get_all_menu_items(g.s)
|
||||
from sx.sx_components import render_menu_items_list
|
||||
html = render_menu_items_list(menu_items)
|
||||
html = _render_menu_items_list(menu_items)
|
||||
nav_oob = get_menu_items_nav_oob_sync(menu_items)
|
||||
return sx_response(html + nav_oob)
|
||||
|
||||
@@ -142,8 +318,7 @@ def register():
|
||||
pages, total = await search_pages(g.s, query, page, per_page)
|
||||
has_more = (page * per_page) < total
|
||||
|
||||
from sx.sx_components import render_page_search_results
|
||||
return sx_response(render_page_search_results(pages, query, page, has_more))
|
||||
return sx_response(_render_page_search_results(pages, query, page, has_more))
|
||||
|
||||
@bp.post("/reorder/")
|
||||
@require_admin
|
||||
@@ -167,8 +342,7 @@ def register():
|
||||
|
||||
# Get updated list and nav OOB
|
||||
menu_items = await get_all_menu_items(g.s)
|
||||
from sx.sx_components import render_menu_items_list
|
||||
html = render_menu_items_list(menu_items)
|
||||
html = _render_menu_items_list(menu_items)
|
||||
nav_oob = get_menu_items_nav_oob_sync(menu_items)
|
||||
return sx_response(html + nav_oob)
|
||||
|
||||
|
||||
@@ -10,10 +10,18 @@ from quart import (
|
||||
url_for,
|
||||
)
|
||||
from shared.browser.app.authz import require_admin, require_post_author
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
from shared.sx.helpers import sx_response
|
||||
from markupsafe import escape
|
||||
from shared.sx.helpers import sx_response, sx_call
|
||||
from shared.sx.parser import SxExpr, serialize as sx_serialize
|
||||
from shared.utils import host_url
|
||||
|
||||
|
||||
def _raw_html_sx(html: str) -> str:
|
||||
"""Wrap raw HTML in (raw! "...") so it's valid inside sx source."""
|
||||
if not html:
|
||||
return ""
|
||||
return "(raw! " + sx_serialize(html) + ")"
|
||||
|
||||
def _post_to_edit_dict(post) -> dict:
|
||||
"""Convert an ORM Post to a dict matching the shape templates expect.
|
||||
|
||||
@@ -52,158 +60,225 @@ def _post_to_edit_dict(post) -> dict:
|
||||
return d
|
||||
|
||||
|
||||
def _render_features(features, post, result):
|
||||
"""Render features panel via .sx defcomp."""
|
||||
slug = post.get("slug", "")
|
||||
return sx_call("blog-features-panel-content",
|
||||
features_url=host_url(url_for("blog.post.admin.update_features", slug=slug)),
|
||||
calendar_checked=bool(features.get("calendar")),
|
||||
market_checked=bool(features.get("market")),
|
||||
show_sumup=bool(features.get("calendar") or features.get("market")),
|
||||
sumup_url=host_url(url_for("blog.post.admin.update_sumup", slug=slug)),
|
||||
merchant_code=result.get("sumup_merchant_code") or "",
|
||||
placeholder="\u2022" * 8 if result.get("sumup_configured") else "sup_sk_...",
|
||||
sumup_configured=result.get("sumup_configured", False),
|
||||
checkout_prefix=result.get("sumup_checkout_prefix") or "",
|
||||
)
|
||||
|
||||
|
||||
def _serialize_markets(markets, slug):
|
||||
"""Serialize ORM/DTO market objects to dicts for .sx defcomp."""
|
||||
result = []
|
||||
for m in markets:
|
||||
m_name = getattr(m, "name", "") if hasattr(m, "name") else m.get("name", "")
|
||||
m_slug = getattr(m, "slug", "") if hasattr(m, "slug") else m.get("slug", "")
|
||||
result.append({
|
||||
"name": m_name, "slug": m_slug,
|
||||
"delete_url": host_url(url_for("blog.post.admin.delete_market",
|
||||
slug=slug, market_slug=m_slug)),
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def _render_calendar_view(
|
||||
calendar, year, month, month_name, weekday_names, weeks,
|
||||
prev_month, prev_month_year, next_month, next_month_year,
|
||||
prev_year, next_year, month_entries, associated_entry_ids,
|
||||
post_slug: str,
|
||||
) -> str:
|
||||
"""Build calendar month grid HTML."""
|
||||
from quart import url_for as qurl
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
esc = escape
|
||||
|
||||
csrf = generate_csrf_token()
|
||||
cal_id = calendar.id
|
||||
|
||||
def cal_url(y, m):
|
||||
return esc(host_url(qurl("blog.post.admin.calendar_view", slug=post_slug, calendar_id=cal_id, year=y, month=m)))
|
||||
|
||||
cur_url = cal_url(year, month)
|
||||
toggle_url_fn = lambda eid: esc(host_url(qurl("blog.post.admin.toggle_entry", slug=post_slug, entry_id=eid)))
|
||||
|
||||
nav = (
|
||||
f'<header class="flex items-center justify-center mb-4">'
|
||||
f'<nav class="flex items-center gap-2 text-xl">'
|
||||
f'<a class="px-2 py-1 hover:bg-stone-100 rounded" sx-get="{cal_url(prev_year, month)}" sx-target="#calendar-view-{cal_id}" sx-swap="outerHTML">«</a>'
|
||||
f'<a class="px-2 py-1 hover:bg-stone-100 rounded" sx-get="{cal_url(prev_month_year, prev_month)}" sx-target="#calendar-view-{cal_id}" sx-swap="outerHTML">‹</a>'
|
||||
f'<div class="px-3 font-medium">{esc(month_name)} {year}</div>'
|
||||
f'<a class="px-2 py-1 hover:bg-stone-100 rounded" sx-get="{cal_url(next_month_year, next_month)}" sx-target="#calendar-view-{cal_id}" sx-swap="outerHTML">›</a>'
|
||||
f'<a class="px-2 py-1 hover:bg-stone-100 rounded" sx-get="{cal_url(next_year, month)}" sx-target="#calendar-view-{cal_id}" sx-swap="outerHTML">»</a>'
|
||||
f'</nav></header>'
|
||||
)
|
||||
|
||||
wd_cells = "".join(f'<div class="py-2">{esc(wd)}</div>' for wd in weekday_names)
|
||||
wd_row = f'<div class="hidden sm:grid grid-cols-7 text-center text-xs font-semibold text-stone-700 bg-stone-50 border-b">{wd_cells}</div>'
|
||||
|
||||
cells: list[str] = []
|
||||
for week in weeks:
|
||||
for day in week:
|
||||
extra_cls = " bg-stone-50 text-stone-400" if not day.in_month else ""
|
||||
day_date = day.date
|
||||
|
||||
entry_btns: list[str] = []
|
||||
for e in month_entries:
|
||||
e_start = getattr(e, "start_at", None)
|
||||
if not e_start or e_start.date() != day_date:
|
||||
continue
|
||||
e_id = getattr(e, "id", None)
|
||||
e_name = esc(getattr(e, "name", ""))
|
||||
t_url = toggle_url_fn(e_id)
|
||||
hx_hdrs = '{:X-CSRFToken "' + csrf + '"}'
|
||||
|
||||
if e_id in associated_entry_ids:
|
||||
entry_btns.append(
|
||||
f'<div class="flex items-center gap-1 text-[10px] rounded px-1 py-0.5 bg-green-200 text-green-900">'
|
||||
f'<span class="truncate flex-1">{e_name}</span>'
|
||||
f'<button type="button" class="flex-shrink-0 hover:text-red-600"'
|
||||
f' data-confirm data-confirm-title="Remove entry?"'
|
||||
f' data-confirm-text="Remove {e_name} from this post?"'
|
||||
f' data-confirm-icon="warning" data-confirm-confirm-text="Yes, remove it"'
|
||||
f' data-confirm-cancel-text="Cancel" data-confirm-event="confirmed"'
|
||||
f' sx-post="{t_url}" sx-trigger="confirmed"'
|
||||
f' sx-target="#associated-entries-list" sx-swap="outerHTML"'
|
||||
f""" sx-headers='{hx_hdrs}'"""
|
||||
f' sx-on:afterSwap="document.body.dispatchEvent(new CustomEvent(\'entryToggled\'))"'
|
||||
f'><i class="fa fa-times"></i></button></div>'
|
||||
)
|
||||
else:
|
||||
entry_btns.append(
|
||||
f'<button type="button" class="w-full text-left text-[10px] rounded px-1 py-0.5 bg-stone-100 text-stone-700 hover:bg-stone-200"'
|
||||
f' data-confirm data-confirm-title="Add entry?"'
|
||||
f' data-confirm-text="Add {e_name} to this post?"'
|
||||
f' data-confirm-icon="question" data-confirm-confirm-text="Yes, add it"'
|
||||
f' data-confirm-cancel-text="Cancel" data-confirm-event="confirmed"'
|
||||
f' sx-post="{t_url}" sx-trigger="confirmed"'
|
||||
f' sx-target="#associated-entries-list" sx-swap="outerHTML"'
|
||||
f""" sx-headers='{hx_hdrs}'"""
|
||||
f' sx-on:afterSwap="document.body.dispatchEvent(new CustomEvent(\'entryToggled\'))"'
|
||||
f'><span class="truncate block">{e_name}</span></button>'
|
||||
)
|
||||
|
||||
entries_html = '<div class="space-y-0.5">' + "".join(entry_btns) + '</div>' if entry_btns else ''
|
||||
cells.append(
|
||||
f'<div class="min-h-20 bg-white px-2 py-2 text-xs{extra_cls}">'
|
||||
f'<div class="font-medium mb-1">{day_date.day}</div>{entries_html}</div>'
|
||||
)
|
||||
|
||||
grid = f'<div class="grid grid-cols-1 sm:grid-cols-7 gap-px bg-stone-200">{"".join(cells)}</div>'
|
||||
|
||||
html = (
|
||||
f'<div id="calendar-view-{cal_id}"'
|
||||
f' sx-get="{cur_url}" sx-trigger="entryToggled from:body" sx-swap="outerHTML">'
|
||||
f'{nav}'
|
||||
f'<div class="rounded border bg-white">{wd_row}{grid}</div>'
|
||||
f'</div>'
|
||||
)
|
||||
return _raw_html_sx(html)
|
||||
|
||||
|
||||
def _render_associated_entries(all_calendars, associated_entry_ids, post_slug: str) -> str:
|
||||
"""Render the associated entries panel."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from sxc.pages.helpers import _extract_associated_entries_data
|
||||
|
||||
csrf = generate_csrf_token()
|
||||
entry_data = _extract_associated_entries_data(
|
||||
all_calendars, associated_entry_ids, post_slug)
|
||||
|
||||
return sx_call("blog-associated-entries-from-data",
|
||||
entries=entry_data, csrf=csrf)
|
||||
|
||||
|
||||
def _render_nav_entries_oob(associated_entries, calendars, post: dict) -> str:
|
||||
"""Render the OOB nav entries swap."""
|
||||
entries_list = []
|
||||
if associated_entries and hasattr(associated_entries, "entries"):
|
||||
entries_list = associated_entries.entries or []
|
||||
|
||||
has_items = bool(entries_list or calendars)
|
||||
|
||||
if not has_items:
|
||||
return sx_call("blog-nav-entries-empty")
|
||||
|
||||
select_colours = (
|
||||
"[.hover-capable_&]:hover:bg-yellow-300"
|
||||
" aria-selected:bg-stone-500 aria-selected:text-white"
|
||||
" [.hover-capable_&[aria-selected=true]:hover]:bg-orange-500"
|
||||
)
|
||||
nav_cls = (
|
||||
f"justify-center cursor-pointer flex flex-row items-center gap-2"
|
||||
f" rounded bg-stone-200 text-black {select_colours} p-2"
|
||||
)
|
||||
|
||||
post_slug = post.get("slug", "")
|
||||
|
||||
scroll_hs = (
|
||||
"on load or scroll"
|
||||
" if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth"
|
||||
" remove .hidden from .entries-nav-arrow add .flex to .entries-nav-arrow"
|
||||
" else add .hidden to .entries-nav-arrow remove .flex from .entries-nav-arrow end"
|
||||
)
|
||||
|
||||
item_parts = []
|
||||
|
||||
for entry in entries_list:
|
||||
e_name = getattr(entry, "name", "")
|
||||
e_start = getattr(entry, "start_at", None)
|
||||
e_end = getattr(entry, "end_at", None)
|
||||
cal_slug = getattr(entry, "calendar_slug", "")
|
||||
|
||||
if e_start:
|
||||
entry_path = (
|
||||
f"/{post_slug}/{cal_slug}/"
|
||||
f"{e_start.year}/{e_start.month}/{e_start.day}"
|
||||
f"/entries/{getattr(entry, 'id', '')}/"
|
||||
)
|
||||
date_str = e_start.strftime("%b %d, %Y at %H:%M")
|
||||
if e_end:
|
||||
date_str += f" \u2013 {e_end.strftime('%H:%M')}"
|
||||
else:
|
||||
entry_path = f"/{post_slug}/{cal_slug}/"
|
||||
date_str = ""
|
||||
|
||||
item_parts.append(sx_call("calendar-entry-nav",
|
||||
href=entry_path, nav_class=nav_cls, name=e_name, date_str=date_str,
|
||||
))
|
||||
|
||||
for calendar in (calendars or []):
|
||||
cal_name = getattr(calendar, "name", "")
|
||||
cal_slug = getattr(calendar, "slug", "")
|
||||
cal_path = f"/{post_slug}/{cal_slug}/"
|
||||
|
||||
item_parts.append(sx_call("blog-nav-calendar-item",
|
||||
href=cal_path, nav_cls=nav_cls, name=cal_name,
|
||||
))
|
||||
|
||||
items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else ""
|
||||
|
||||
return sx_call("scroll-nav-wrapper",
|
||||
wrapper_id="entries-calendars-nav-wrapper", container_id="associated-items-container",
|
||||
arrow_cls="entries-nav-arrow",
|
||||
left_hs="on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200",
|
||||
scroll_hs=scroll_hs,
|
||||
right_hs="on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200",
|
||||
items=SxExpr(items_sx) if items_sx else None, oob=True,
|
||||
)
|
||||
|
||||
|
||||
def register():
|
||||
bp = Blueprint("admin", __name__, url_prefix='/admin')
|
||||
|
||||
@bp.before_request
|
||||
async def _prepare_page_data():
|
||||
ep = request.endpoint or ""
|
||||
if "defpage_post_admin" in ep:
|
||||
from sqlalchemy import select
|
||||
from shared.models.page_config import PageConfig
|
||||
post = (g.post_data or {}).get("post", {})
|
||||
features = {}
|
||||
sumup_configured = False
|
||||
sumup_merchant_code = ""
|
||||
sumup_checkout_prefix = ""
|
||||
if post.get("is_page"):
|
||||
pc = (await g.s.execute(
|
||||
select(PageConfig).where(
|
||||
PageConfig.container_type == "page",
|
||||
PageConfig.container_id == post["id"],
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if pc:
|
||||
features = pc.features or {}
|
||||
sumup_configured = bool(pc.sumup_api_key)
|
||||
sumup_merchant_code = pc.sumup_merchant_code or ""
|
||||
sumup_checkout_prefix = pc.sumup_checkout_prefix or ""
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _post_admin_main_panel_sx
|
||||
tctx = await get_template_context()
|
||||
tctx.update({
|
||||
"features": features,
|
||||
"sumup_configured": sumup_configured,
|
||||
"sumup_merchant_code": sumup_merchant_code,
|
||||
"sumup_checkout_prefix": sumup_checkout_prefix,
|
||||
})
|
||||
g.post_admin_content = _post_admin_main_panel_sx(tctx)
|
||||
|
||||
elif "defpage_post_data" in ep:
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _post_data_content_sx
|
||||
tctx = await get_template_context()
|
||||
g.post_data_content = _post_data_content_sx(tctx)
|
||||
|
||||
elif "defpage_post_preview" in ep:
|
||||
from models.ghost_content import Post
|
||||
from sqlalchemy import select as sa_select
|
||||
post_id = g.post_data["post"]["id"]
|
||||
post = (await g.s.execute(
|
||||
sa_select(Post).where(Post.id == post_id)
|
||||
)).scalar_one_or_none()
|
||||
preview_ctx = {}
|
||||
sx_content = getattr(post, "sx_content", None) or ""
|
||||
if sx_content:
|
||||
from shared.sx.prettify import sx_to_pretty_sx
|
||||
preview_ctx["sx_pretty"] = sx_to_pretty_sx(sx_content)
|
||||
lexical_raw = getattr(post, "lexical", None) or ""
|
||||
if lexical_raw:
|
||||
from shared.sx.prettify import json_to_pretty_sx
|
||||
preview_ctx["json_pretty"] = json_to_pretty_sx(lexical_raw)
|
||||
if sx_content:
|
||||
from shared.sx.parser import parse as sx_parse
|
||||
from shared.sx.html import render as sx_html_render
|
||||
from shared.sx.jinja_bridge import _COMPONENT_ENV
|
||||
try:
|
||||
parsed = sx_parse(sx_content)
|
||||
preview_ctx["sx_rendered"] = sx_html_render(parsed, dict(_COMPONENT_ENV))
|
||||
except Exception:
|
||||
preview_ctx["sx_rendered"] = "<em>Error rendering sx</em>"
|
||||
if lexical_raw:
|
||||
from bp.blog.ghost.lexical_renderer import render_lexical
|
||||
try:
|
||||
preview_ctx["lex_rendered"] = render_lexical(lexical_raw)
|
||||
except Exception:
|
||||
preview_ctx["lex_rendered"] = "<em>Error rendering lexical</em>"
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _preview_main_panel_sx
|
||||
tctx = await get_template_context()
|
||||
tctx.update(preview_ctx)
|
||||
g.post_preview_content = _preview_main_panel_sx(tctx)
|
||||
|
||||
elif "defpage_post_entries" in ep:
|
||||
from sqlalchemy import select
|
||||
from shared.models.calendars import Calendar
|
||||
from ..services.entry_associations import get_post_entry_ids
|
||||
post_id = g.post_data["post"]["id"]
|
||||
associated_entry_ids = await get_post_entry_ids(post_id)
|
||||
result = await g.s.execute(
|
||||
select(Calendar)
|
||||
.where(Calendar.deleted_at.is_(None))
|
||||
.order_by(Calendar.name.asc())
|
||||
)
|
||||
all_calendars = result.scalars().all()
|
||||
for calendar in all_calendars:
|
||||
await g.s.refresh(calendar, ["entries", "post"])
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _post_entries_content_sx
|
||||
tctx = await get_template_context()
|
||||
tctx["all_calendars"] = all_calendars
|
||||
tctx["associated_entry_ids"] = associated_entry_ids
|
||||
g.post_entries_content = _post_entries_content_sx(tctx)
|
||||
|
||||
elif "defpage_post_settings" in ep:
|
||||
from models.ghost_content import Post
|
||||
from sqlalchemy import select as sa_select
|
||||
from sqlalchemy.orm import selectinload
|
||||
post_id = g.post_data["post"]["id"]
|
||||
post = (await g.s.execute(
|
||||
sa_select(Post)
|
||||
.where(Post.id == post_id)
|
||||
.options(selectinload(Post.tags))
|
||||
)).scalar_one_or_none()
|
||||
ghost_post = _post_to_edit_dict(post) if post else {}
|
||||
save_success = request.args.get("saved") == "1"
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _post_settings_content_sx
|
||||
tctx = await get_template_context()
|
||||
tctx["ghost_post"] = ghost_post
|
||||
tctx["save_success"] = save_success
|
||||
g.post_settings_content = _post_settings_content_sx(tctx)
|
||||
|
||||
elif "defpage_post_edit" in ep:
|
||||
from models.ghost_content import Post
|
||||
from sqlalchemy import select as sa_select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from shared.infrastructure.data_client import fetch_data
|
||||
post_id = g.post_data["post"]["id"]
|
||||
post = (await g.s.execute(
|
||||
sa_select(Post)
|
||||
.where(Post.id == post_id)
|
||||
.options(selectinload(Post.tags))
|
||||
)).scalar_one_or_none()
|
||||
ghost_post = _post_to_edit_dict(post) if post else {}
|
||||
save_success = request.args.get("saved") == "1"
|
||||
save_error = request.args.get("error", "")
|
||||
raw_newsletters = await fetch_data("account", "newsletters", required=False) or []
|
||||
from types import SimpleNamespace
|
||||
newsletters = [SimpleNamespace(**nl) for nl in raw_newsletters]
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _post_edit_content_sx
|
||||
tctx = await get_template_context()
|
||||
tctx["ghost_post"] = ghost_post
|
||||
tctx["save_success"] = save_success
|
||||
tctx["save_error"] = save_error
|
||||
tctx["newsletters"] = newsletters
|
||||
g.post_edit_content = _post_edit_content_sx(tctx)
|
||||
|
||||
from shared.sx.pages import mount_pages
|
||||
mount_pages(bp, "blog", names=[
|
||||
"post-admin", "post-data", "post-preview",
|
||||
"post-entries", "post-settings", "post-edit",
|
||||
])
|
||||
|
||||
@bp.put("/features/")
|
||||
@require_admin
|
||||
async def update_features(slug: str):
|
||||
@@ -238,14 +313,7 @@ def register():
|
||||
})
|
||||
|
||||
features = result.get("features", {})
|
||||
|
||||
from sx.sx_components import render_features_panel
|
||||
html = render_features_panel(
|
||||
features, post,
|
||||
sumup_configured=result.get("sumup_configured", False),
|
||||
sumup_merchant_code=result.get("sumup_merchant_code") or "",
|
||||
sumup_checkout_prefix=result.get("sumup_checkout_prefix") or "",
|
||||
)
|
||||
html = _render_features(features, post, result)
|
||||
return sx_response(html)
|
||||
|
||||
@bp.put("/admin/sumup/")
|
||||
@@ -278,13 +346,7 @@ def register():
|
||||
result = await call_action("blog", "update-page-config", payload=payload)
|
||||
|
||||
features = result.get("features", {})
|
||||
from sx.sx_components import render_features_panel
|
||||
html = render_features_panel(
|
||||
features, post,
|
||||
sumup_configured=result.get("sumup_configured", False),
|
||||
sumup_merchant_code=result.get("sumup_merchant_code") or "",
|
||||
sumup_checkout_prefix=result.get("sumup_checkout_prefix") or "",
|
||||
)
|
||||
html = _render_features(features, post, result)
|
||||
return sx_response(html)
|
||||
|
||||
@bp.get("/entries/calendar/<int:calendar_id>/")
|
||||
@@ -353,8 +415,7 @@ def register():
|
||||
post_id = g.post_data["post"]["id"]
|
||||
associated_entry_ids = await get_post_entry_ids(post_id)
|
||||
|
||||
from sx.sx_components import render_calendar_view
|
||||
html = render_calendar_view(
|
||||
html = _render_calendar_view(
|
||||
calendar_obj, year, month, month_name, weekday_names, weeks,
|
||||
prev_month, prev_month_year, next_month, next_month_year,
|
||||
prev_year, next_year, month_entries, associated_entry_ids,
|
||||
@@ -406,11 +467,9 @@ def register():
|
||||
).scalars().all()
|
||||
|
||||
# Return the associated entries admin list + OOB update for nav entries
|
||||
from sx.sx_components import render_associated_entries, render_nav_entries_oob
|
||||
|
||||
post = g.post_data["post"]
|
||||
admin_list = render_associated_entries(all_calendars, associated_entry_ids, post["slug"])
|
||||
nav_entries_html = render_nav_entries_oob(associated_entries, calendars, post)
|
||||
admin_list = _render_associated_entries(all_calendars, associated_entry_ids, post["slug"])
|
||||
nav_entries_html = _render_nav_entries_oob(associated_entries, calendars, post)
|
||||
|
||||
return sx_response(admin_list + nav_entries_html)
|
||||
|
||||
@@ -468,7 +527,7 @@ def register():
|
||||
except OptimisticLockError:
|
||||
from urllib.parse import quote
|
||||
return redirect(
|
||||
host_url(url_for("blog.post.admin.defpage_post_settings", slug=slug))
|
||||
host_url(url_for("defpage_post_settings", slug=slug))
|
||||
+ "?error=" + quote("Someone else edited this post. Please reload and try again.")
|
||||
)
|
||||
|
||||
@@ -479,7 +538,7 @@ def register():
|
||||
await invalidate_tag_cache("post.post_detail")
|
||||
|
||||
# Redirect using the (possibly new) slug
|
||||
return redirect(host_url(url_for("blog.post.admin.defpage_post_settings", slug=post.slug)) + "?saved=1")
|
||||
return redirect(host_url(url_for("defpage_post_settings", slug=post.slug)) + "?saved=1")
|
||||
|
||||
@bp.post("/edit/")
|
||||
@require_post_author
|
||||
@@ -504,11 +563,11 @@ def register():
|
||||
try:
|
||||
lexical_doc = json.loads(lexical_raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return redirect(host_url(url_for("blog.post.admin.defpage_post_edit", slug=slug)) + "?error=" + quote("Invalid JSON in editor content."))
|
||||
return redirect(host_url(url_for("defpage_post_edit", slug=slug)) + "?error=" + quote("Invalid JSON in editor content."))
|
||||
|
||||
ok, reason = validate_lexical(lexical_doc)
|
||||
if not ok:
|
||||
return redirect(host_url(url_for("blog.post.admin.defpage_post_edit", slug=slug)) + "?error=" + quote(reason))
|
||||
return redirect(host_url(url_for("defpage_post_edit", slug=slug)) + "?error=" + quote(reason))
|
||||
|
||||
# Publish workflow
|
||||
is_admin = bool((g.get("rights") or {}).get("admin"))
|
||||
@@ -544,7 +603,7 @@ def register():
|
||||
)
|
||||
except OptimisticLockError:
|
||||
return redirect(
|
||||
host_url(url_for("blog.post.admin.defpage_post_edit", slug=slug))
|
||||
host_url(url_for("defpage_post_edit", slug=slug))
|
||||
+ "?error=" + quote("Someone else edited this post. Please reload and try again.")
|
||||
)
|
||||
|
||||
@@ -560,7 +619,7 @@ def register():
|
||||
await invalidate_tag_cache("post.post_detail")
|
||||
|
||||
# Redirect to GET (PRG pattern) — use post.slug in case it changed
|
||||
redirect_url = host_url(url_for("blog.post.admin.defpage_post_edit", slug=post.slug)) + "?saved=1"
|
||||
redirect_url = host_url(url_for("defpage_post_edit", slug=post.slug)) + "?saved=1"
|
||||
if publish_requested_msg:
|
||||
redirect_url += "&publish_requested=1"
|
||||
return redirect(redirect_url)
|
||||
@@ -585,8 +644,11 @@ def register():
|
||||
|
||||
page_markets = await _fetch_page_markets(post_id)
|
||||
|
||||
from sx.sx_components import render_markets_panel
|
||||
return sx_response(render_markets_panel(page_markets, post))
|
||||
slug = post.get("slug", "")
|
||||
create_url = host_url(url_for("blog.post.admin.create_market", slug=slug))
|
||||
html = sx_call("blog-markets-panel-content",
|
||||
markets=_serialize_markets(page_markets, slug), create_url=create_url)
|
||||
return sx_response(html)
|
||||
|
||||
@bp.post("/markets/new/")
|
||||
@require_admin
|
||||
@@ -611,8 +673,11 @@ def register():
|
||||
# Return updated markets list
|
||||
page_markets = await _fetch_page_markets(post_id)
|
||||
|
||||
from sx.sx_components import render_markets_panel
|
||||
return sx_response(render_markets_panel(page_markets, post))
|
||||
slug = post.get("slug", "")
|
||||
create_url = host_url(url_for("blog.post.admin.create_market", slug=slug))
|
||||
html = sx_call("blog-markets-panel-content",
|
||||
markets=_serialize_markets(page_markets, slug), create_url=create_url)
|
||||
return sx_response(html)
|
||||
|
||||
@bp.delete("/markets/<market_slug>/")
|
||||
@require_admin
|
||||
@@ -631,7 +696,10 @@ def register():
|
||||
# Return updated markets list
|
||||
page_markets = await _fetch_page_markets(post_id)
|
||||
|
||||
from sx.sx_components import render_markets_panel
|
||||
return sx_response(render_markets_panel(page_markets, post))
|
||||
slug = post.get("slug", "")
|
||||
create_url = host_url(url_for("blog.post.admin.create_market", slug=slug))
|
||||
html = sx_call("blog-markets-panel-content",
|
||||
markets=_serialize_markets(page_markets, slug), create_url=create_url)
|
||||
return sx_response(html)
|
||||
|
||||
return bp
|
||||
|
||||
@@ -105,27 +105,64 @@ def register():
|
||||
@cache_page(tag="post.post_detail")
|
||||
async def post_detail(slug: str):
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_post_page, render_post_oob
|
||||
from shared.sx.helpers import (
|
||||
sx_call, root_header_sx, full_page_sx, oob_page_sx,
|
||||
post_header_sx, oob_header_sx, mobile_menu_sx,
|
||||
post_mobile_nav_sx, mobile_root_nav_sx,
|
||||
)
|
||||
from shared.services.registry import services
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from shared.utils import host_url
|
||||
|
||||
tctx = await get_template_context()
|
||||
|
||||
# Render post content via .sx defcomp
|
||||
post = tctx.get("post") or {}
|
||||
user = getattr(g, "user", None)
|
||||
rights = tctx.get("rights") or {}
|
||||
blog_url_base = host_url(url_for("blog.index")).rstrip("/index").rstrip("/")
|
||||
csrf = generate_csrf_token()
|
||||
svc = services.blog_page
|
||||
detail_data = svc.post_detail_data(post, user, rights, csrf, blog_url_base)
|
||||
content = sx_call("blog-post-detail-content", **detail_data)
|
||||
meta_data = svc.post_meta_data(post, tctx.get("base_title", ""))
|
||||
meta = sx_call("blog-meta", **meta_data)
|
||||
|
||||
if not is_htmx_request():
|
||||
html = await render_post_page(tctx)
|
||||
root_hdr = await root_header_sx(tctx)
|
||||
post_hdr = await post_header_sx(tctx)
|
||||
header_rows = "(<> " + root_hdr + " " + post_hdr + ")"
|
||||
menu = mobile_menu_sx(await post_mobile_nav_sx(tctx), await mobile_root_nav_sx(tctx))
|
||||
html = await full_page_sx(tctx, header_rows=header_rows, content=content,
|
||||
meta=meta, menu=menu)
|
||||
return await make_response(html)
|
||||
else:
|
||||
sx_src = await render_post_oob(tctx)
|
||||
root_hdr = await root_header_sx(tctx)
|
||||
post_hdr = await post_header_sx(tctx)
|
||||
rows = "(<> " + root_hdr + " " + post_hdr + ")"
|
||||
header_oob = await oob_header_sx("root-header-child", "post-header-child", rows)
|
||||
sx_src = await oob_page_sx(oobs=header_oob, content=content, menu=
|
||||
mobile_menu_sx(await post_mobile_nav_sx(tctx), await mobile_root_nav_sx(tctx)))
|
||||
return sx_response(sx_src)
|
||||
|
||||
@bp.post("/like/toggle/")
|
||||
@clear_cache(tag="post.post_detail", tag_scope="user")
|
||||
async def like_toggle(slug: str):
|
||||
from shared.utils import host_url
|
||||
from sx.sx_components import render_like_toggle_button
|
||||
from shared.sx.helpers import sx_call
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
|
||||
like_url = host_url(url_for('blog.post.like_toggle', slug=slug))
|
||||
csrf = generate_csrf_token()
|
||||
|
||||
def _like_btn(liked):
|
||||
return sx_call("blog-like-toggle",
|
||||
like_url=like_url,
|
||||
hx_headers={"X-CSRFToken": csrf},
|
||||
heart="\u2764\ufe0f" if liked else "\U0001f90d")
|
||||
|
||||
# Get post_id from g.post_data
|
||||
if not g.user:
|
||||
return sx_response(render_like_toggle_button(slug, False, like_url), status=403)
|
||||
return sx_response(_like_btn(False), status=403)
|
||||
|
||||
post_id = g.post_data["post"]["id"]
|
||||
user_id = g.user.id
|
||||
@@ -133,9 +170,8 @@ def register():
|
||||
result = await call_action("likes", "toggle", payload={
|
||||
"user_id": user_id, "target_type": "post", "target_id": post_id,
|
||||
})
|
||||
liked = result["liked"]
|
||||
|
||||
return sx_response(render_like_toggle_button(slug, liked, like_url))
|
||||
return sx_response(_like_btn(result["liked"]))
|
||||
|
||||
@bp.get("/w/<widget_domain>/")
|
||||
async def widget_paginate(slug: str, widget_domain: str):
|
||||
|
||||
@@ -1,53 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, make_response, request, g, abort
|
||||
from sqlalchemy import select, or_
|
||||
from sqlalchemy.orm import selectinload
|
||||
from quart import Blueprint, request, g, abort
|
||||
|
||||
from shared.browser.app.authz import require_login
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
from shared.sx.helpers import sx_response
|
||||
from shared.sx.helpers import sx_response, sx_call
|
||||
from models import Snippet
|
||||
|
||||
|
||||
VALID_VISIBILITY = frozenset({"private", "shared", "admin"})
|
||||
|
||||
|
||||
async def _visible_snippets(session):
|
||||
"""Return snippets visible to the current user (own + shared + admin-if-admin)."""
|
||||
uid = g.user.id
|
||||
is_admin = g.rights.get("admin")
|
||||
|
||||
filters = [Snippet.user_id == uid, Snippet.visibility == "shared"]
|
||||
if is_admin:
|
||||
filters.append(Snippet.visibility == "admin")
|
||||
|
||||
rows = (await session.execute(
|
||||
select(Snippet).where(or_(*filters)).order_by(Snippet.name)
|
||||
)).scalars().all()
|
||||
|
||||
return rows
|
||||
async def _render_snippets():
|
||||
"""Render snippets list via service data + .sx defcomp."""
|
||||
from shared.services.registry import services
|
||||
data = await services.blog_page.snippets_data(g.s)
|
||||
return sx_call("blog-snippets-content", **data)
|
||||
|
||||
|
||||
def register():
|
||||
bp = Blueprint("snippets", __name__, url_prefix="/settings/snippets")
|
||||
|
||||
@bp.before_request
|
||||
async def _prepare_page_data():
|
||||
if "defpage_" not in (request.endpoint or ""):
|
||||
return
|
||||
snippets = await _visible_snippets(g.s)
|
||||
is_admin = g.rights.get("admin")
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _snippets_main_panel_sx
|
||||
tctx = await get_template_context()
|
||||
tctx["snippets"] = snippets
|
||||
tctx["is_admin"] = is_admin
|
||||
g.snippets_content = _snippets_main_panel_sx(tctx)
|
||||
|
||||
from shared.sx.pages import mount_pages
|
||||
mount_pages(bp, "blog", names=["snippets-page"])
|
||||
|
||||
@bp.delete("/<int:snippet_id>/")
|
||||
@require_login
|
||||
async def delete_snippet(snippet_id: int):
|
||||
@@ -63,9 +35,7 @@ def register():
|
||||
await g.s.delete(snippet)
|
||||
await g.s.flush()
|
||||
|
||||
snippets = await _visible_snippets(g.s)
|
||||
from sx.sx_components import render_snippets_list
|
||||
return sx_response(render_snippets_list(snippets, is_admin))
|
||||
return sx_response(await _render_snippets())
|
||||
|
||||
@bp.patch("/<int:snippet_id>/visibility/")
|
||||
@require_login
|
||||
@@ -87,8 +57,6 @@ def register():
|
||||
snippet.visibility = visibility
|
||||
await g.s.flush()
|
||||
|
||||
snippets = await _visible_snippets(g.s)
|
||||
from sx.sx_components import render_snippets_list
|
||||
return sx_response(render_snippets_list(snippets, True))
|
||||
return sx_response(await _render_snippets())
|
||||
|
||||
return bp
|
||||
|
||||
40
blog/queries.sx
Normal file
40
blog/queries.sx
Normal file
@@ -0,0 +1,40 @@
|
||||
;; Blog service — inter-service data queries
|
||||
|
||||
(defquery post-by-slug (&key slug)
|
||||
"Fetch a single blog post by its URL slug."
|
||||
(service "blog" "get-post-by-slug" :slug slug))
|
||||
|
||||
(defquery post-by-id (&key id)
|
||||
"Fetch a single blog post by its primary key."
|
||||
(service "blog" "get-post-by-id" :id id))
|
||||
|
||||
(defquery posts-by-ids (&key ids)
|
||||
"Fetch multiple blog posts by comma-separated IDs."
|
||||
(service "blog" "get-posts-by-ids"
|
||||
:ids (map parse-int (filter (fn (s) (not (empty? s))) (split (str ids) ",")))))
|
||||
|
||||
(defquery search-posts (&key query page per-page)
|
||||
"Search blog posts by text query, paginated."
|
||||
(let ((result (service "blog" "search-posts"
|
||||
:query query :page page :per-page per-page)))
|
||||
{"posts" (nth result 0) "total" (nth result 1)}))
|
||||
|
||||
(defquery page-config-ensure (&key container-type container-id)
|
||||
"Get or create a PageConfig for a container."
|
||||
(service "page-config" "ensure"
|
||||
:container-type container-type :container-id container-id))
|
||||
|
||||
(defquery page-config (&key container-type container-id)
|
||||
"Return a single PageConfig by container type + id."
|
||||
(service "page-config" "get-by-container"
|
||||
:container-type container-type :container-id container-id))
|
||||
|
||||
(defquery page-config-by-id (&key id)
|
||||
"Return a single PageConfig by primary key."
|
||||
(service "page-config" "get-by-id" :id id))
|
||||
|
||||
(defquery page-configs-batch (&key container-type ids)
|
||||
"Return PageConfigs for multiple container IDs (comma-separated)."
|
||||
(service "page-config" "get-batch"
|
||||
:container-type container-type
|
||||
:ids (map parse-int (filter (fn (s) (not (empty? s))) (split (str ids) ",")))))
|
||||
@@ -1,10 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Re-convert sx_content from lexical JSON to eliminate ~kg-html wrappers and
|
||||
Re-convert sx_content from lexical JSON to eliminate ~kg_cards/kg-html wrappers and
|
||||
raw caption strings.
|
||||
|
||||
The updated lexical_to_sx converter now produces native sx expressions instead
|
||||
of (1) wrapping HTML/markdown cards in (~kg-html :html "...") and (2) storing
|
||||
of (1) wrapping HTML/markdown cards in (~kg_cards/kg-html :html "...") and (2) storing
|
||||
captions as escaped HTML strings. This script re-runs the conversion on all
|
||||
posts that already have sx_content, overwriting the old output.
|
||||
|
||||
@@ -50,11 +50,11 @@ async def migrate(dry_run: bool = False) -> int:
|
||||
continue
|
||||
|
||||
if dry_run:
|
||||
old_has_kg = "~kg-html" in (post.sx_content or "")
|
||||
old_has_kg = "~kg_cards/kg-html" in (post.sx_content or "")
|
||||
old_has_raw = "raw! caption" in (post.sx_content or "")
|
||||
markers = []
|
||||
if old_has_kg:
|
||||
markers.append("~kg-html")
|
||||
markers.append("~kg_cards/kg-html")
|
||||
if old_has_raw:
|
||||
markers.append("raw-caption")
|
||||
tag = f" [{', '.join(markers)}]" if markers else ""
|
||||
@@ -76,7 +76,7 @@ async def migrate(dry_run: bool = False) -> int:
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Re-convert sx_content to eliminate ~kg-html and raw captions"
|
||||
description="Re-convert sx_content to eliminate ~kg_cards/kg-html and raw captions"
|
||||
)
|
||||
parser.add_argument("--dry-run", action="store_true",
|
||||
help="Preview changes without writing to database")
|
||||
|
||||
@@ -71,8 +71,16 @@ def register_domain_services() -> None:
|
||||
Blog owns: Post, Tag, Author, PostAuthor, PostTag.
|
||||
Cross-app calls go over HTTP via call_action() / fetch_data().
|
||||
"""
|
||||
# Federation needed for AP shared infrastructure (activitypub blueprint)
|
||||
from shared.services.registry import services
|
||||
services.register("blog", blog_service)
|
||||
|
||||
from shared.services.page_config_impl import SqlPageConfigService
|
||||
services.register("page_config", SqlPageConfigService())
|
||||
|
||||
# Federation needed for AP shared infrastructure (activitypub blueprint)
|
||||
if not services.has("federation"):
|
||||
from shared.services.federation_impl import SqlFederationService
|
||||
services.federation = SqlFederationService()
|
||||
|
||||
from .blog_page import BlogPageService
|
||||
services.register("blog_page", BlogPageService())
|
||||
|
||||
472
blog/services/blog_page.py
Normal file
472
blog/services/blog_page.py
Normal file
@@ -0,0 +1,472 @@
|
||||
"""Blog page data service — provides serialized dicts for .sx defpages."""
|
||||
from __future__ import annotations
|
||||
|
||||
from shared.sx.parser import SxExpr
|
||||
|
||||
|
||||
def _sx_content_expr(raw: str) -> SxExpr | None:
|
||||
"""Wrap non-empty sx_content as SxExpr so it serializes unquoted."""
|
||||
return SxExpr(raw) if raw else None
|
||||
|
||||
|
||||
class BlogPageService:
|
||||
"""Service for blog page data, callable via (service "blog-page" ...)."""
|
||||
|
||||
async def cache_data(self, session, **kw):
|
||||
from quart import url_for as qurl
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
return {
|
||||
"clear_url": qurl("settings.cache_clear"),
|
||||
"csrf": generate_csrf_token(),
|
||||
}
|
||||
|
||||
async def snippets_data(self, session, **kw):
|
||||
from quart import g, url_for as qurl
|
||||
from sqlalchemy import select, or_
|
||||
from models import Snippet
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
|
||||
uid = g.user.id
|
||||
is_admin = g.rights.get("admin")
|
||||
csrf = generate_csrf_token()
|
||||
filters = [Snippet.user_id == uid, Snippet.visibility == "shared"]
|
||||
if is_admin:
|
||||
filters.append(Snippet.visibility == "admin")
|
||||
rows = (await session.execute(
|
||||
select(Snippet).where(or_(*filters)).order_by(Snippet.name)
|
||||
)).scalars().all()
|
||||
|
||||
snippets = []
|
||||
for s in rows:
|
||||
s_id = s.id
|
||||
s_vis = s.visibility or "private"
|
||||
s_uid = s.user_id
|
||||
owner = "You" if s_uid == uid else f"User #{s_uid}"
|
||||
can_delete = s_uid == uid or is_admin
|
||||
d = {
|
||||
"id": s_id,
|
||||
"name": s.name or "",
|
||||
"visibility": s_vis,
|
||||
"owner": owner,
|
||||
"can_delete": can_delete,
|
||||
}
|
||||
if is_admin:
|
||||
d["patch_url"] = qurl("snippets.patch_visibility", snippet_id=s_id)
|
||||
if can_delete:
|
||||
d["delete_url"] = qurl("snippets.delete_snippet", snippet_id=s_id)
|
||||
snippets.append(d)
|
||||
return {
|
||||
"snippets": snippets,
|
||||
"is_admin": bool(is_admin),
|
||||
"csrf": csrf,
|
||||
}
|
||||
|
||||
async def menu_items_data(self, session, **kw):
|
||||
from quart import url_for as qurl
|
||||
from bp.menu_items.services.menu_items import get_all_menu_items
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
|
||||
menu_items = await get_all_menu_items(session)
|
||||
csrf = generate_csrf_token()
|
||||
items = []
|
||||
for mi in menu_items:
|
||||
i_id = mi.id
|
||||
label = mi.label or ""
|
||||
fi = getattr(mi, "feature_image", None)
|
||||
sort = mi.position or 0
|
||||
items.append({
|
||||
"id": i_id,
|
||||
"label": label,
|
||||
"url": mi.url or "",
|
||||
"sort_order": sort,
|
||||
"feature_image": fi,
|
||||
"edit_url": qurl("menu_items.edit_menu_item", item_id=i_id),
|
||||
"delete_url": qurl("menu_items.delete_menu_item_route", item_id=i_id),
|
||||
})
|
||||
return {
|
||||
"menu_items": items,
|
||||
"new_url": qurl("menu_items.new_menu_item"),
|
||||
"csrf": csrf,
|
||||
}
|
||||
|
||||
async def tag_groups_data(self, session, **kw):
|
||||
from quart import url_for as qurl
|
||||
from sqlalchemy import select
|
||||
from models.tag_group import TagGroup
|
||||
from bp.blog.admin.routes import _unassigned_tags
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
|
||||
groups_rows = list(
|
||||
(await session.execute(
|
||||
select(TagGroup).order_by(TagGroup.sort_order, TagGroup.name)
|
||||
)).scalars()
|
||||
)
|
||||
unassigned = await _unassigned_tags(session)
|
||||
|
||||
groups = []
|
||||
for g in groups_rows:
|
||||
groups.append({
|
||||
"id": g.id,
|
||||
"name": g.name or "",
|
||||
"slug": getattr(g, "slug", "") or "",
|
||||
"feature_image": getattr(g, "feature_image", None),
|
||||
"colour": getattr(g, "colour", None),
|
||||
"sort_order": getattr(g, "sort_order", 0) or 0,
|
||||
"edit_href": qurl("blog.tag_groups_admin.defpage_tag_group_edit", id=g.id),
|
||||
})
|
||||
|
||||
unassigned_tags = []
|
||||
for t in unassigned:
|
||||
unassigned_tags.append({
|
||||
"name": getattr(t, "name", "") if hasattr(t, "name") else t.get("name", ""),
|
||||
})
|
||||
|
||||
return {
|
||||
"groups": groups,
|
||||
"unassigned_tags": unassigned_tags,
|
||||
"create_url": qurl("blog.tag_groups_admin.create"),
|
||||
"csrf": generate_csrf_token(),
|
||||
}
|
||||
|
||||
async def tag_group_edit_data(self, session, *, id=None, **kw):
|
||||
from quart import abort, url_for as qurl
|
||||
from sqlalchemy import select
|
||||
from models.tag_group import TagGroup, TagGroupTag
|
||||
from models.ghost_content import Tag
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
|
||||
tg = await session.get(TagGroup, id)
|
||||
if not tg:
|
||||
abort(404)
|
||||
|
||||
assigned_rows = list(
|
||||
(await session.execute(
|
||||
select(TagGroupTag.tag_id).where(TagGroupTag.tag_group_id == id)
|
||||
)).scalars()
|
||||
)
|
||||
assigned_set = set(assigned_rows)
|
||||
|
||||
all_tags_rows = list(
|
||||
(await session.execute(
|
||||
select(Tag).where(
|
||||
Tag.deleted_at.is_(None),
|
||||
(Tag.visibility == "public") | (Tag.visibility.is_(None)),
|
||||
).order_by(Tag.name)
|
||||
)).scalars()
|
||||
)
|
||||
|
||||
all_tags = []
|
||||
for t in all_tags_rows:
|
||||
all_tags.append({
|
||||
"id": t.id,
|
||||
"name": getattr(t, "name", "") or "",
|
||||
"feature_image": getattr(t, "feature_image", None),
|
||||
"checked": t.id in assigned_set,
|
||||
})
|
||||
|
||||
return {
|
||||
"group": {
|
||||
"id": tg.id,
|
||||
"name": tg.name or "",
|
||||
"colour": getattr(tg, "colour", "") or "",
|
||||
"sort_order": getattr(tg, "sort_order", 0) or 0,
|
||||
"feature_image": getattr(tg, "feature_image", "") or "",
|
||||
},
|
||||
"all_tags": all_tags,
|
||||
"save_url": qurl("blog.tag_groups_admin.save", id=tg.id),
|
||||
"delete_url": qurl("blog.tag_groups_admin.delete_group", id=tg.id),
|
||||
"csrf": generate_csrf_token(),
|
||||
}
|
||||
|
||||
async def index_data(self, session, **kw):
|
||||
"""Blog index page data — posts or pages listing with filters."""
|
||||
from quart import g, request, url_for as qurl
|
||||
from bp.blog.services.posts_data import posts_data
|
||||
from bp.blog.services.pages_data import pages_data
|
||||
from bp.blog.filters.qs import decode
|
||||
from shared.utils import host_url
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
|
||||
q = decode()
|
||||
content_type = request.args.get("type", "posts")
|
||||
is_admin = bool((g.get("rights") or {}).get("admin"))
|
||||
user = getattr(g, "user", None)
|
||||
csrf = generate_csrf_token()
|
||||
|
||||
blog_url_base = host_url(qurl("blog.index")).rstrip("/index").rstrip("/")
|
||||
|
||||
if content_type == "pages":
|
||||
data = await pages_data(session, q.page, q.search)
|
||||
posts_list = data.get("pages", [])
|
||||
tag_groups_raw = []
|
||||
authors_raw = []
|
||||
draft_count = 0
|
||||
selected_tags = ()
|
||||
selected_authors = ()
|
||||
selected_groups = ()
|
||||
else:
|
||||
show_drafts = bool(q.drafts and user)
|
||||
drafts_user_id = None if (not show_drafts or is_admin) else user.id
|
||||
count_drafts_uid = None if (user and is_admin) else (user.id if user else False)
|
||||
data = await posts_data(
|
||||
session, q.page, q.search, q.sort, q.selected_tags,
|
||||
q.selected_authors, q.liked,
|
||||
drafts=show_drafts, drafts_user_id=drafts_user_id,
|
||||
count_drafts_for_user_id=count_drafts_uid,
|
||||
selected_groups=q.selected_groups,
|
||||
)
|
||||
posts_list = data.get("posts", [])
|
||||
tag_groups_raw = data.get("tag_groups", [])
|
||||
authors_raw = data.get("authors", [])
|
||||
draft_count = data.get("draft_count", 0)
|
||||
selected_tags = q.selected_tags
|
||||
selected_authors = q.selected_authors
|
||||
selected_groups = q.selected_groups
|
||||
|
||||
page_num = data.get("page", q.page)
|
||||
total_pages = data.get("total_pages", 1)
|
||||
card_widgets = data.get("card_widgets_html", {})
|
||||
|
||||
current_local_href = f"{blog_url_base}/index"
|
||||
if content_type == "pages":
|
||||
current_local_href += "?type=pages"
|
||||
hx_select = "#main-panel"
|
||||
|
||||
# Serialize posts for cards
|
||||
def _format_ts(dt):
|
||||
if not dt:
|
||||
return ""
|
||||
return dt.strftime("%-d %b %Y at %H:%M") if hasattr(dt, "strftime") else str(dt)
|
||||
|
||||
cards = []
|
||||
for p in posts_list:
|
||||
slug = p.get("slug", "")
|
||||
href = f"{blog_url_base}/{slug}/"
|
||||
status = p.get("status", "published")
|
||||
is_draft = status == "draft"
|
||||
ts = _format_ts(p.get("updated_at") if is_draft else p.get("published_at"))
|
||||
tags = []
|
||||
for t in (p.get("tags") or []):
|
||||
name = t.get("name") or getattr(t, "name", "")
|
||||
fi = t.get("feature_image") or getattr(t, "feature_image", None)
|
||||
tags.append({"name": name, "src": fi or "", "initial": name[:1] if name else ""})
|
||||
authors = []
|
||||
for a in (p.get("authors") or []):
|
||||
name = a.get("name") or getattr(a, "name", "")
|
||||
img = a.get("profile_image") or getattr(a, "profile_image", None)
|
||||
authors.append({"name": name, "image": img or ""})
|
||||
card = {
|
||||
"slug": slug, "href": href, "hx_select": hx_select,
|
||||
"title": p.get("title", ""), "feature_image": p.get("feature_image"),
|
||||
"excerpt": p.get("custom_excerpt") or p.get("excerpt", ""),
|
||||
"is_draft": is_draft,
|
||||
"publish_requested": p.get("publish_requested", False) if is_draft else False,
|
||||
"status_timestamp": ts,
|
||||
"tags": tags, "authors": authors,
|
||||
"has_like": bool(user),
|
||||
}
|
||||
if user:
|
||||
card["liked"] = p.get("is_liked", False)
|
||||
card["like_url"] = f"{blog_url_base}/{slug}/like/toggle/"
|
||||
card["csrf_token"] = csrf
|
||||
widget = card_widgets.get(str(p.get("id", "")), "")
|
||||
if widget:
|
||||
card["widget"] = widget
|
||||
# Page-specific fields
|
||||
features = p.get("features") or {}
|
||||
if content_type == "pages":
|
||||
card["has_calendar"] = features.get("calendar", False)
|
||||
card["has_market"] = features.get("market", False)
|
||||
card["pub_timestamp"] = ts
|
||||
cards.append(card)
|
||||
|
||||
# Serialize tag groups for filter
|
||||
tag_groups = []
|
||||
for grp in tag_groups_raw:
|
||||
g_slug = grp.get("slug", "") if isinstance(grp, dict) else getattr(grp, "slug", "")
|
||||
g_name = grp.get("name", "") if isinstance(grp, dict) else getattr(grp, "name", "")
|
||||
g_fi = grp.get("feature_image") if isinstance(grp, dict) else getattr(grp, "feature_image", None)
|
||||
g_colour = grp.get("colour") if isinstance(grp, dict) else getattr(grp, "colour", None)
|
||||
g_count = grp.get("post_count", 0) if isinstance(grp, dict) else getattr(grp, "post_count", 0)
|
||||
if g_count <= 0 and g_slug not in selected_groups:
|
||||
continue
|
||||
tag_groups.append({
|
||||
"slug": g_slug, "name": g_name, "feature_image": g_fi,
|
||||
"colour": g_colour, "post_count": g_count,
|
||||
"is_selected": g_slug in selected_groups,
|
||||
})
|
||||
|
||||
# Serialize authors for filter
|
||||
authors_list = []
|
||||
for a in authors_raw:
|
||||
a_slug = a.get("slug", "") if isinstance(a, dict) else getattr(a, "slug", "")
|
||||
a_name = a.get("name", "") if isinstance(a, dict) else getattr(a, "name", "")
|
||||
a_img = a.get("profile_image") if isinstance(a, dict) else getattr(a, "profile_image", None)
|
||||
a_count = a.get("published_post_count", 0) if isinstance(a, dict) else getattr(a, "published_post_count", 0)
|
||||
authors_list.append({
|
||||
"slug": a_slug, "name": a_name, "profile_image": a_img,
|
||||
"published_post_count": a_count,
|
||||
"is_selected": a_slug in selected_authors,
|
||||
})
|
||||
|
||||
# Filter summary names
|
||||
tg_summary_names = [grp["name"] for grp in tag_groups if grp["is_selected"]]
|
||||
au_summary_names = [a["name"] for a in authors_list if a["is_selected"]]
|
||||
|
||||
return {
|
||||
"content_type": content_type,
|
||||
"view": q.view,
|
||||
"cards": cards,
|
||||
"page": page_num,
|
||||
"total_pages": total_pages,
|
||||
"current_local_href": current_local_href,
|
||||
"hx_select": hx_select,
|
||||
"is_admin": is_admin,
|
||||
"has_user": bool(user),
|
||||
"draft_count": draft_count,
|
||||
"drafts": bool(q.drafts) if user else False,
|
||||
"new_post_href": f"{blog_url_base}/new/",
|
||||
"new_page_href": f"{blog_url_base}/new-page/",
|
||||
"tag_groups": tag_groups,
|
||||
"authors": authors_list,
|
||||
"is_any_group": len(selected_groups) == 0 and len(selected_tags) == 0,
|
||||
"is_any_author": len(selected_authors) == 0,
|
||||
"tg_summary": ", ".join(tg_summary_names) if tg_summary_names else "",
|
||||
"au_summary": ", ".join(au_summary_names) if au_summary_names else "",
|
||||
"blog_url_base": blog_url_base,
|
||||
"csrf": csrf,
|
||||
}
|
||||
|
||||
async def post_admin_data(self, session, *, slug=None, **kw):
|
||||
"""Post admin panel — just needs post loaded into context."""
|
||||
from quart import g
|
||||
from sqlalchemy import select
|
||||
from shared.models.page_config import PageConfig
|
||||
|
||||
# _ensure_post_data is called by before_request in defpage context
|
||||
post = (g.post_data or {}).get("post", {})
|
||||
features = {}
|
||||
sumup_configured = False
|
||||
if post.get("is_page"):
|
||||
pc = (await session.execute(
|
||||
select(PageConfig).where(
|
||||
PageConfig.container_type == "page",
|
||||
PageConfig.container_id == post["id"],
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if pc:
|
||||
features = pc.features or {}
|
||||
sumup_configured = bool(pc.sumup_api_key)
|
||||
return {
|
||||
"features": features,
|
||||
"sumup_configured": sumup_configured,
|
||||
}
|
||||
|
||||
def post_meta_data(self, post, base_title):
|
||||
"""Compute SEO meta tag values from post dict."""
|
||||
import re
|
||||
from quart import request as req
|
||||
|
||||
is_public = post.get("visibility") == "public"
|
||||
is_published = post.get("status") == "published"
|
||||
email_only = post.get("email_only", False)
|
||||
robots = "index,follow" if (is_public and is_published and not email_only) else "noindex,nofollow"
|
||||
|
||||
desc = (post.get("meta_description") or post.get("og_description") or
|
||||
post.get("twitter_description") or post.get("custom_excerpt") or
|
||||
post.get("excerpt") or "")
|
||||
if not desc and post.get("html"):
|
||||
desc = re.sub(r'<[^>]+>', '', post["html"])
|
||||
desc = desc.replace("\n", " ").replace("\r", " ").strip()[:160]
|
||||
|
||||
image = (post.get("og_image") or post.get("twitter_image") or post.get("feature_image") or "")
|
||||
canonical = post.get("canonical_url") or (req.url if req else "")
|
||||
|
||||
post_title = post.get("meta_title") or post.get("title") or ""
|
||||
page_title = f"{post_title} \u2014 {base_title}" if post_title else base_title
|
||||
og_title = post.get("og_title") or page_title
|
||||
tw_title = post.get("twitter_title") or page_title
|
||||
is_article = not post.get("is_page")
|
||||
|
||||
return {
|
||||
"robots": robots, "page_title": page_title, "desc": desc,
|
||||
"canonical": canonical,
|
||||
"og_type": "article" if is_article else "website",
|
||||
"og_title": og_title, "image": image,
|
||||
"twitter_card": "summary_large_image" if image else "summary",
|
||||
"twitter_title": tw_title,
|
||||
}
|
||||
|
||||
def post_detail_data(self, post, user, rights, csrf, blog_url_base):
|
||||
"""Serialize post detail view data for ~detail/post-detail-content defcomp."""
|
||||
slug = post.get("slug", "")
|
||||
is_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False)
|
||||
user_id = getattr(user, "id", None) if user else None
|
||||
|
||||
# Tags and authors
|
||||
tags = []
|
||||
for t in (post.get("tags") or []):
|
||||
name = t.get("name") or getattr(t, "name", "")
|
||||
fi = t.get("feature_image") or getattr(t, "feature_image", None)
|
||||
tags.append({"name": name, "src": fi or "", "initial": name[:1] if name else ""})
|
||||
authors = []
|
||||
for a in (post.get("authors") or []):
|
||||
name = a.get("name") or getattr(a, "name", "")
|
||||
img = a.get("profile_image") or getattr(a, "profile_image", None)
|
||||
authors.append({"name": name, "image": img or ""})
|
||||
|
||||
return {
|
||||
"slug": slug,
|
||||
"is_draft": post.get("status") == "draft",
|
||||
"publish_requested": post.get("publish_requested", False),
|
||||
"can_edit": is_admin or (user_id is not None and post.get("user_id") == user_id),
|
||||
"edit_href": f"{blog_url_base}/{slug}/admin/edit/",
|
||||
"is_page": bool(post.get("is_page")),
|
||||
"has_user": bool(user),
|
||||
"liked": post.get("is_liked", False),
|
||||
"like_url": f"{blog_url_base}/{slug}/like/toggle/",
|
||||
"csrf": csrf,
|
||||
"custom_excerpt": post.get("custom_excerpt") or "",
|
||||
"tags": tags,
|
||||
"authors": authors,
|
||||
"feature_image": post.get("feature_image"),
|
||||
"html_content": post.get("html", ""),
|
||||
"sx_content": _sx_content_expr(post.get("sx_content", "")),
|
||||
}
|
||||
|
||||
async def preview_data(self, session, *, slug=None, **kw):
|
||||
"""Build preview data with prettified/rendered content."""
|
||||
from quart import g
|
||||
from models.ghost_content import Post
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
post_id = g.post_data["post"]["id"]
|
||||
post = (await session.execute(
|
||||
sa_select(Post).where(Post.id == post_id)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
result = {}
|
||||
sx_content = getattr(post, "sx_content", None) or ""
|
||||
if sx_content:
|
||||
from shared.sx.prettify import sx_to_pretty_sx
|
||||
result["sx_pretty"] = sx_to_pretty_sx(sx_content)
|
||||
lexical_raw = getattr(post, "lexical", None) or ""
|
||||
if lexical_raw:
|
||||
from shared.sx.prettify import json_to_pretty_sx
|
||||
result["json_pretty"] = json_to_pretty_sx(lexical_raw)
|
||||
if sx_content:
|
||||
from shared.sx.parser import parse as sx_parse
|
||||
from shared.sx.html import render as sx_html_render
|
||||
from shared.sx.jinja_bridge import _COMPONENT_ENV
|
||||
try:
|
||||
parsed = sx_parse(sx_content)
|
||||
result["sx_rendered"] = sx_html_render(parsed, dict(_COMPONENT_ENV))
|
||||
except Exception:
|
||||
result["sx_rendered"] = "<em>Error rendering sx</em>"
|
||||
if lexical_raw:
|
||||
from bp.blog.ghost.lexical_renderer import render_lexical
|
||||
try:
|
||||
result["lex_rendered"] = render_lexical(lexical_raw)
|
||||
except Exception:
|
||||
result["lex_rendered"] = "<em>Error rendering lexical</em>"
|
||||
return result
|
||||
472
blog/sx/admin.sx
472
blog/sx/admin.sx
@@ -1,6 +1,6 @@
|
||||
;; Blog admin panel components
|
||||
|
||||
(defcomp ~blog-cache-panel (&key clear-url csrf)
|
||||
(defcomp ~admin/cache-panel (&key (clear-url :as string) (csrf :as string))
|
||||
(div :class "max-w-2xl mx-auto px-4 py-6 space-y-6"
|
||||
(div :class "flex flex-col md:flex-row gap-3 items-start"
|
||||
(form :sx-post clear-url :sx-trigger "submit" :sx-target "#cache-status" :sx-swap "innerHTML"
|
||||
@@ -8,21 +8,21 @@
|
||||
(button :class "border rounded px-4 py-2 bg-stone-800 text-white text-sm" :type "submit" "Clear cache"))
|
||||
(div :id "cache-status" :class "py-2"))))
|
||||
|
||||
(defcomp ~blog-snippets-panel (&key list)
|
||||
(defcomp ~admin/snippets-panel (&key list)
|
||||
(div :class "max-w-4xl mx-auto p-6"
|
||||
(div :class "mb-6 flex justify-between items-center"
|
||||
(h1 :class "text-3xl font-bold" "Snippets"))
|
||||
(div :id "snippets-list" list)))
|
||||
|
||||
(defcomp ~blog-snippet-visibility-select (&key patch-url hx-headers options cls)
|
||||
(defcomp ~admin/snippet-visibility-select (&key patch-url hx-headers options cls)
|
||||
(select :name "visibility" :sx-patch patch-url :sx-target "#snippets-list" :sx-swap "innerHTML"
|
||||
:sx-headers hx-headers :class "text-sm border border-stone-300 rounded px-2 py-1"
|
||||
options))
|
||||
|
||||
(defcomp ~blog-snippet-option (&key value selected label)
|
||||
(defcomp ~admin/snippet-option (&key (value :as string) (selected :as boolean) (label :as string))
|
||||
(option :value value :selected selected label))
|
||||
|
||||
(defcomp ~blog-snippet-row (&key name owner badge-cls visibility extra)
|
||||
(defcomp ~admin/snippet-row (&key (name :as string) (owner :as string) (badge-cls :as string) (visibility :as string) extra)
|
||||
(div :class "flex items-center gap-4 p-4 hover:bg-stone-50 transition"
|
||||
(div :class "flex-1 min-w-0"
|
||||
(div :class "font-medium truncate" name)
|
||||
@@ -30,10 +30,10 @@
|
||||
(span :class (str "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium " badge-cls) visibility)
|
||||
extra))
|
||||
|
||||
(defcomp ~blog-snippets-list (&key rows)
|
||||
(defcomp ~admin/snippets-list (&key rows)
|
||||
(div :class "bg-white rounded-lg shadow" (div :class "divide-y" rows)))
|
||||
|
||||
(defcomp ~blog-menu-items-panel (&key new-url list)
|
||||
(defcomp ~admin/menu-items-panel (&key new-url list)
|
||||
(div :class "max-w-4xl mx-auto p-6"
|
||||
(div :class "mb-6 flex justify-end items-center"
|
||||
(button :type "button" :sx-get new-url :sx-target "#menu-item-form" :sx-swap "innerHTML"
|
||||
@@ -42,7 +42,7 @@
|
||||
(div :id "menu-item-form" :class "mb-6")
|
||||
(div :id "menu-items-list" list)))
|
||||
|
||||
(defcomp ~blog-menu-item-row (&key img label slug sort-order edit-url delete-url confirm-text hx-headers)
|
||||
(defcomp ~admin/menu-item-row (&key img (label :as string) (slug :as string) (sort-order :as string) (edit-url :as string) (delete-url :as string) (confirm-text :as string) hx-headers)
|
||||
(div :class "flex items-center gap-4 p-4 hover:bg-stone-50 transition"
|
||||
(div :class "text-stone-400 cursor-move" (i :class "fa fa-grip-vertical"))
|
||||
img
|
||||
@@ -54,16 +54,16 @@
|
||||
(button :type "button" :sx-get edit-url :sx-target "#menu-item-form" :sx-swap "innerHTML"
|
||||
:class "px-3 py-1 text-sm bg-stone-200 hover:bg-stone-300 rounded"
|
||||
(i :class "fa fa-edit") " Edit")
|
||||
(~delete-btn :url delete-url :trigger-target "#menu-items-list"
|
||||
(~shared:misc/delete-btn :url delete-url :trigger-target "#menu-items-list"
|
||||
:title "Delete menu item?" :text confirm-text
|
||||
:sx-headers hx-headers))))
|
||||
|
||||
(defcomp ~blog-menu-items-list (&key rows)
|
||||
(defcomp ~admin/menu-items-list (&key rows)
|
||||
(div :class "bg-white rounded-lg shadow" (div :class "divide-y" rows)))
|
||||
|
||||
;; Tag groups admin
|
||||
|
||||
(defcomp ~blog-tag-groups-create-form (&key create-url csrf)
|
||||
(defcomp ~admin/tag-groups-create-form (&key create-url csrf)
|
||||
(form :method "post" :action create-url :class "border rounded p-4 bg-white space-y-3"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(h3 :class "text-sm font-semibold text-stone-700" "New Group")
|
||||
@@ -74,14 +74,14 @@
|
||||
(input :type "text" :name "feature_image" :placeholder "Image URL (optional)" :class "w-full border rounded px-3 py-2 text-sm")
|
||||
(button :type "submit" :class "border rounded px-4 py-2 bg-stone-800 text-white text-sm" "Create")))
|
||||
|
||||
(defcomp ~blog-tag-group-icon-image (&key src name)
|
||||
(defcomp ~admin/tag-group-icon-image (&key src name)
|
||||
(img :src src :alt name :class "h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0"))
|
||||
|
||||
(defcomp ~blog-tag-group-icon-color (&key style initial)
|
||||
(defcomp ~admin/tag-group-icon-color (&key style initial)
|
||||
(div :class "h-8 w-8 rounded-full text-xs font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0"
|
||||
:style style initial))
|
||||
|
||||
(defcomp ~blog-tag-group-li (&key icon edit-href name slug sort-order)
|
||||
(defcomp ~admin/tag-group-li (&key icon (edit-href :as string) (name :as string) (slug :as string) (sort-order :as number))
|
||||
(li :class "border rounded p-3 bg-white flex items-center gap-3"
|
||||
icon
|
||||
(div :class "flex-1"
|
||||
@@ -89,32 +89,32 @@
|
||||
(span :class "text-xs text-stone-500 ml-2" slug))
|
||||
(span :class "text-xs text-stone-500" (str "order: " sort-order))))
|
||||
|
||||
(defcomp ~blog-tag-groups-list (&key items)
|
||||
(defcomp ~admin/tag-groups-list (&key items)
|
||||
(ul :class "space-y-2" items))
|
||||
|
||||
(defcomp ~blog-unassigned-tag (&key name)
|
||||
(defcomp ~admin/unassigned-tag (&key name)
|
||||
(span :class "inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200 rounded" name))
|
||||
|
||||
(defcomp ~blog-unassigned-tags (&key heading spans)
|
||||
(defcomp ~admin/unassigned-tags (&key heading spans)
|
||||
(div :class "border-t pt-4"
|
||||
(h3 :class "text-sm font-semibold text-stone-700 mb-2" heading)
|
||||
(div :class "flex flex-wrap gap-2" spans)))
|
||||
|
||||
(defcomp ~blog-tag-groups-main (&key form groups unassigned)
|
||||
(defcomp ~admin/tag-groups-main (&key form groups unassigned)
|
||||
(div :class "max-w-2xl mx-auto px-4 py-6 space-y-8"
|
||||
form groups unassigned))
|
||||
|
||||
;; Tag group edit
|
||||
|
||||
(defcomp ~blog-tag-checkbox (&key tag-id checked img name)
|
||||
(defcomp ~admin/tag-checkbox (&key (tag-id :as string) (checked :as boolean) img (name :as string))
|
||||
(label :class "flex items-center gap-2 px-2 py-1 hover:bg-stone-50 rounded text-sm cursor-pointer"
|
||||
(input :type "checkbox" :name "tag_ids" :value tag-id :checked checked :class "rounded border-stone-300")
|
||||
img (span name)))
|
||||
|
||||
(defcomp ~blog-tag-checkbox-image (&key src)
|
||||
(defcomp ~admin/tag-checkbox-image (&key src)
|
||||
(img :src src :alt "" :class "h-4 w-4 rounded-full object-cover"))
|
||||
|
||||
(defcomp ~blog-tag-group-edit-form (&key save-url csrf name colour sort-order feature-image tags)
|
||||
(defcomp ~admin/tag-group-edit-form (&key (save-url :as string) (csrf :as string) (name :as string) (colour :as string?) (sort-order :as number) (feature-image :as string?) tags)
|
||||
(form :method "post" :action save-url :class "border rounded p-4 bg-white space-y-4"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(div :class "space-y-3"
|
||||
@@ -133,19 +133,93 @@
|
||||
(div :class "flex gap-3"
|
||||
(button :type "submit" :class "border rounded px-4 py-2 bg-stone-800 text-white text-sm" "Save"))))
|
||||
|
||||
(defcomp ~blog-tag-group-delete-form (&key delete-url csrf)
|
||||
(defcomp ~admin/tag-group-delete-form (&key (delete-url :as string) (csrf :as string))
|
||||
(form :method "post" :action delete-url :class "border-t pt-4"
|
||||
:onsubmit "return confirm('Delete this tag group? Tags will not be deleted.')"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(button :type "submit" :class "border rounded px-4 py-2 bg-red-600 text-white text-sm" "Delete Group")))
|
||||
|
||||
(defcomp ~blog-tag-group-edit-main (&key edit-form delete-form)
|
||||
(defcomp ~admin/tag-group-edit-main (&key edit-form delete-form)
|
||||
(div :class "max-w-2xl mx-auto px-4 py-6 space-y-6"
|
||||
edit-form delete-form))
|
||||
|
||||
;; Data-driven snippets list (replaces Python _snippets_sx loop)
|
||||
(defcomp ~admin/snippets-from-data (&key snippets user-id is-admin csrf badge-colours)
|
||||
(~admin/snippets-list
|
||||
:rows (<> (map (lambda (s)
|
||||
(let* ((s-id (get s "id"))
|
||||
(s-name (get s "name"))
|
||||
(s-uid (get s "user_id"))
|
||||
(s-vis (get s "visibility"))
|
||||
(owner (if (= s-uid user-id) "You" (str "User #" s-uid)))
|
||||
(badge-cls (or (get badge-colours s-vis) "bg-stone-200 text-stone-700"))
|
||||
(extra (<>
|
||||
(when is-admin
|
||||
(~admin/snippet-visibility-select
|
||||
:patch-url (get s "patch_url")
|
||||
:hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")
|
||||
:options (<>
|
||||
(~admin/snippet-option :value "private" :selected (= s-vis "private") :label "private")
|
||||
(~admin/snippet-option :value "shared" :selected (= s-vis "shared") :label "shared")
|
||||
(~admin/snippet-option :value "admin" :selected (= s-vis "admin") :label "admin"))
|
||||
:cls "text-sm border border-stone-300 rounded px-2 py-1"))
|
||||
(when (or (= s-uid user-id) is-admin)
|
||||
(~shared:misc/delete-btn :url (get s "delete_url") :trigger-target "#snippets-list"
|
||||
:title "Delete snippet?"
|
||||
:text (str "Delete \u201c" s-name "\u201d?")
|
||||
:sx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")
|
||||
:cls "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800 flex-shrink-0")))))
|
||||
(~admin/snippet-row :name s-name :owner owner :badge-cls badge-cls
|
||||
:visibility s-vis :extra extra)))
|
||||
(or snippets (list))))))
|
||||
|
||||
;; Data-driven menu items list (replaces Python _menu_items_list_sx loop)
|
||||
(defcomp ~admin/menu-items-from-data (&key items csrf)
|
||||
(~admin/menu-items-list
|
||||
:rows (<> (map (lambda (item)
|
||||
(let* ((img (~shared:misc/img-or-placeholder :src (get item "feature_image") :alt (get item "label")
|
||||
:size-cls "w-12 h-12 rounded-full object-cover flex-shrink-0")))
|
||||
(~admin/menu-item-row
|
||||
:img img :label (get item "label") :slug (get item "slug")
|
||||
:sort-order (get item "sort_order") :edit-url (get item "edit_url")
|
||||
:delete-url (get item "delete_url")
|
||||
:confirm-text (str "Remove " (get item "label") " from the menu?")
|
||||
:hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}"))))
|
||||
(or items (list))))))
|
||||
|
||||
;; Data-driven tag groups main (replaces Python _tag_groups_main_panel_sx loops)
|
||||
(defcomp ~admin/tag-groups-from-data (&key groups unassigned-tags csrf create-url)
|
||||
(~admin/tag-groups-main
|
||||
:form (~admin/tag-groups-create-form :create-url create-url :csrf csrf)
|
||||
:groups (if (empty? (or groups (list)))
|
||||
(~shared:misc/empty-state :message "No tag groups yet." :cls "text-stone-500 text-sm")
|
||||
(~admin/tag-groups-list
|
||||
:items (<> (map (lambda (g)
|
||||
(let* ((icon (if (get g "feature_image")
|
||||
(~admin/tag-group-icon-image :src (get g "feature_image") :name (get g "name"))
|
||||
(~admin/tag-group-icon-color :style (get g "style") :initial (get g "initial")))))
|
||||
(~admin/tag-group-li :icon icon :edit-href (get g "edit_href")
|
||||
:name (get g "name") :slug (get g "slug") :sort-order (get g "sort_order"))))
|
||||
groups))))
|
||||
:unassigned (when (not (empty? (or unassigned-tags (list))))
|
||||
(~admin/unassigned-tags
|
||||
:heading (str "Unassigned Tags (" (len unassigned-tags) ")")
|
||||
:spans (<> (map (lambda (t)
|
||||
(~admin/unassigned-tag :name (get t "name")))
|
||||
unassigned-tags))))))
|
||||
|
||||
;; Data-driven tag group edit (replaces Python _tag_groups_edit_main_panel_sx loop)
|
||||
(defcomp ~admin/tag-checkboxes-from-data (&key tags)
|
||||
(<> (map (lambda (t)
|
||||
(~admin/tag-checkbox
|
||||
:tag-id (get t "tag_id") :checked (get t "checked")
|
||||
:img (when (get t "feature_image") (~admin/tag-checkbox-image :src (get t "feature_image")))
|
||||
:name (get t "name")))
|
||||
(or tags (list)))))
|
||||
|
||||
;; Preview panel components
|
||||
|
||||
(defcomp ~blog-preview-panel (&key sections)
|
||||
(defcomp ~admin/preview-panel (&key sections)
|
||||
(div :class "max-w-4xl mx-auto px-4 py-6 space-y-4"
|
||||
(style "
|
||||
.sx-pretty, .json-pretty { font-family: monospace; font-size: 12px; line-height: 1.6; white-space: pre-wrap; }
|
||||
@@ -165,7 +239,355 @@
|
||||
")
|
||||
sections))
|
||||
|
||||
(defcomp ~blog-preview-section (&key title content)
|
||||
(defcomp ~admin/preview-section (&key title content)
|
||||
(details :class "border rounded bg-white"
|
||||
(summary :class "cursor-pointer px-4 py-3 font-medium text-sm bg-stone-100 hover:bg-stone-200 select-none" title)
|
||||
(div :class "p-4 overflow-x-auto text-xs" content)))
|
||||
|
||||
(defcomp ~admin/preview-rendered (&key html)
|
||||
(div :class "blog-content prose max-w-none" (raw! html)))
|
||||
|
||||
(defcomp ~admin/preview-empty ()
|
||||
(div :class "p-8 text-stone-500" "No content to preview."))
|
||||
|
||||
(defcomp ~admin/placeholder ()
|
||||
(div :class "pb-8"))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Data-driven content defcomps (called from defpages with service data)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
;; Snippets — receives serialized snippet dicts from service
|
||||
(defcomp ~admin/snippets-content (&key snippets is-admin csrf)
|
||||
(~admin/snippets-panel
|
||||
:list (if (empty? (or snippets (list)))
|
||||
(~shared:misc/empty-state :icon "fa fa-puzzle-piece"
|
||||
:message "No snippets yet. Create one from the blog editor.")
|
||||
(~admin/snippets-list
|
||||
:rows (map (lambda (s)
|
||||
(let* ((badge-colours (dict
|
||||
"private" "bg-stone-200 text-stone-700"
|
||||
"shared" "bg-blue-100 text-blue-700"
|
||||
"admin" "bg-amber-100 text-amber-700"))
|
||||
(vis (or (get s "visibility") "private"))
|
||||
(badge-cls (or (get badge-colours vis) "bg-stone-200 text-stone-700"))
|
||||
(name (get s "name"))
|
||||
(owner (get s "owner"))
|
||||
(can-delete (get s "can_delete")))
|
||||
(~admin/snippet-row
|
||||
:name name :owner owner :badge-cls badge-cls :visibility vis
|
||||
:extra (<>
|
||||
(when is-admin
|
||||
(~admin/snippet-visibility-select
|
||||
:patch-url (get s "patch_url")
|
||||
:hx-headers {:X-CSRFToken csrf}
|
||||
:options (<>
|
||||
(~admin/snippet-option :value "private" :selected (= vis "private") :label "private")
|
||||
(~admin/snippet-option :value "shared" :selected (= vis "shared") :label "shared")
|
||||
(~admin/snippet-option :value "admin" :selected (= vis "admin") :label "admin"))))
|
||||
(when can-delete
|
||||
(~shared:misc/delete-btn
|
||||
:url (get s "delete_url")
|
||||
:trigger-target "#snippets-list"
|
||||
:title "Delete snippet?"
|
||||
:text (str "Delete \u201c" name "\u201d?")
|
||||
:sx-headers {:X-CSRFToken csrf}
|
||||
:cls "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800 flex-shrink-0"))))))
|
||||
(or snippets (list)))))))
|
||||
|
||||
;; Menu Items — receives serialized menu item dicts from service
|
||||
(defcomp ~admin/menu-items-content (&key menu-items new-url csrf)
|
||||
(~admin/menu-items-panel
|
||||
:new-url new-url
|
||||
:list (if (empty? (or menu-items (list)))
|
||||
(~shared:misc/empty-state :icon "fa fa-inbox"
|
||||
:message "No menu items yet. Add one to get started!")
|
||||
(~admin/menu-items-list
|
||||
:rows (map (lambda (mi)
|
||||
(~admin/menu-item-row
|
||||
:img (~shared:misc/img-or-placeholder
|
||||
:src (get mi "feature_image") :alt (get mi "label")
|
||||
:size-cls "w-12 h-12 rounded-full object-cover flex-shrink-0")
|
||||
:label (get mi "label")
|
||||
:slug (get mi "url")
|
||||
:sort-order (str (or (get mi "sort_order") 0))
|
||||
:edit-url (get mi "edit_url")
|
||||
:delete-url (get mi "delete_url")
|
||||
:confirm-text (str "Remove " (get mi "label") " from the menu?")
|
||||
:hx-headers {:X-CSRFToken csrf}))
|
||||
(or menu-items (list)))))))
|
||||
|
||||
;; Tag Groups — receives serialized tag group data from service
|
||||
(defcomp ~admin/tag-groups-content (&key groups unassigned-tags create-url csrf)
|
||||
(~admin/tag-groups-main
|
||||
:form (~admin/tag-groups-create-form :create-url create-url :csrf csrf)
|
||||
:groups (if (empty? (or groups (list)))
|
||||
(~shared:misc/empty-state :icon "fa fa-tags" :message "No tag groups yet.")
|
||||
(~admin/tag-groups-list
|
||||
:items (map (lambda (g)
|
||||
(let* ((fi (get g "feature_image"))
|
||||
(colour (get g "colour"))
|
||||
(name (get g "name"))
|
||||
(initial (slice (or name "?") 0 1))
|
||||
(icon (if fi
|
||||
(~admin/tag-group-icon-image :src fi :name name)
|
||||
(~admin/tag-group-icon-color
|
||||
:style (if colour (str "background:" colour) "background:#e7e5e4")
|
||||
:initial initial))))
|
||||
(~admin/tag-group-li
|
||||
:icon icon
|
||||
:edit-href (get g "edit_href")
|
||||
:name name
|
||||
:slug (or (get g "slug") "")
|
||||
:sort-order (or (get g "sort_order") 0))))
|
||||
(or groups (list)))))
|
||||
:unassigned (when (not (empty? (or unassigned-tags (list))))
|
||||
(~admin/unassigned-tags
|
||||
:heading (str (len (or unassigned-tags (list))) " Unassigned Tags")
|
||||
:spans (map (lambda (t)
|
||||
(~admin/unassigned-tag :name (get t "name")))
|
||||
(or unassigned-tags (list)))))))
|
||||
|
||||
;; Tag Group Edit — receives serialized tag group + tags from service
|
||||
(defcomp ~admin/tag-group-edit-content (&key group all-tags save-url delete-url csrf)
|
||||
(~admin/tag-group-edit-main
|
||||
:edit-form (~admin/tag-group-edit-form
|
||||
:save-url save-url :csrf csrf
|
||||
:name (get group "name")
|
||||
:colour (get group "colour")
|
||||
:sort-order (get group "sort_order")
|
||||
:feature-image (get group "feature_image")
|
||||
:tags (map (lambda (t)
|
||||
(~admin/tag-checkbox
|
||||
:tag-id (get t "id")
|
||||
:checked (get t "checked")
|
||||
:img (when (get t "feature_image")
|
||||
(~admin/tag-checkbox-image :src (get t "feature_image")))
|
||||
:name (get t "name")))
|
||||
(or all-tags (list))))
|
||||
:delete-form (~admin/tag-group-delete-form :delete-url delete-url :csrf csrf)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Preview content composition — replaces _h_post_preview_content
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~admin/preview-content (&key sx-pretty json-pretty sx-rendered lex-rendered)
|
||||
(let* ((sections (list)))
|
||||
(if (and (not sx-pretty) (not json-pretty) (not sx-rendered) (not lex-rendered))
|
||||
(~admin/preview-empty)
|
||||
(~admin/preview-panel :sections
|
||||
(<>
|
||||
(when sx-pretty
|
||||
(~admin/preview-section :title "S-Expression Source" :content sx-pretty))
|
||||
(when json-pretty
|
||||
(~admin/preview-section :title "Lexical JSON" :content json-pretty))
|
||||
(when sx-rendered
|
||||
(~admin/preview-section :title "SX Rendered"
|
||||
:content (~admin/preview-rendered :html sx-rendered)))
|
||||
(when lex-rendered
|
||||
(~admin/preview-section :title "Lexical Rendered"
|
||||
:content (~admin/preview-rendered :html lex-rendered))))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Data introspection composition — replaces _h_post_data_content
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~admin/data-value-cell (&key value value-type)
|
||||
(if (= value-type "nil")
|
||||
(span :class "text-neutral-400" "\u2014")
|
||||
(pre :class "whitespace-pre-wrap break-words break-all text-xs"
|
||||
(if (or (= value-type "date") (= value-type "other"))
|
||||
(code value)
|
||||
value))))
|
||||
|
||||
(defcomp ~admin/data-scalar-table (&key columns)
|
||||
(div :class "w-full overflow-x-auto sm:overflow-visible"
|
||||
(table :class "w-full table-fixed text-sm border border-neutral-200 rounded-xl overflow-hidden"
|
||||
(thead :class "bg-neutral-50/70"
|
||||
(tr (th :class "px-3 py-2 text-left font-medium w-40 sm:w-56" "Field")
|
||||
(th :class "px-3 py-2 text-left font-medium" "Value")))
|
||||
(tbody
|
||||
(map (lambda (col)
|
||||
(tr :class "border-t border-neutral-200 align-top"
|
||||
(td :class "px-3 py-2 whitespace-nowrap text-neutral-600 align-top" (get col "key"))
|
||||
(td :class "px-3 py-2 align-top"
|
||||
(~admin/data-value-cell :value (get col "value") :value-type (get col "type")))))
|
||||
(or columns (list)))))))
|
||||
|
||||
(defcomp ~admin/data-relationship-item (&key index summary children)
|
||||
(tr :class "border-t border-neutral-200 align-top"
|
||||
(td :class "px-2 py-1 whitespace-nowrap align-top" (str index))
|
||||
(td :class "px-2 py-1 align-top"
|
||||
(pre :class "whitespace-pre-wrap break-words break-all text-xs"
|
||||
(code summary))
|
||||
(when children
|
||||
(div :class "mt-2 pl-3 border-l border-neutral-200"
|
||||
(~admin/data-model-content
|
||||
:columns (get children "columns")
|
||||
:relationships (get children "relationships")))))))
|
||||
|
||||
(defcomp ~admin/data-relationship (&key name cardinality class-name loaded value)
|
||||
(div :class "rounded-xl border border-neutral-200"
|
||||
(div :class "px-3 py-2 bg-neutral-50/70 text-sm font-medium"
|
||||
"Relationship: " (span :class "font-semibold" name)
|
||||
(span :class "ml-2 text-xs text-neutral-500"
|
||||
cardinality " \u2192 " class-name
|
||||
(when (not loaded) " \u2022 " (em "not loaded"))))
|
||||
(div :class "p-3 text-sm"
|
||||
(if (not value)
|
||||
(span :class "text-neutral-400" "\u2014")
|
||||
(if (get value "is_list")
|
||||
(<>
|
||||
(div :class "text-neutral-500 mb-2"
|
||||
(str (get value "count") " item" (if (= (get value "count") 1) "" "s")))
|
||||
(when (get value "items")
|
||||
(div :class "w-full overflow-x-auto sm:overflow-visible"
|
||||
(table :class "w-full table-fixed text-sm border border-neutral-200 rounded-lg overflow-hidden"
|
||||
(thead :class "bg-neutral-50/70"
|
||||
(tr (th :class "px-2 py-1 text-left w-10" "#")
|
||||
(th :class "px-2 py-1 text-left" "Summary")))
|
||||
(tbody
|
||||
(map (lambda (item)
|
||||
(~admin/data-relationship-item
|
||||
:index (get item "index")
|
||||
:summary (get item "summary")
|
||||
:children (get item "children")))
|
||||
(get value "items")))))))
|
||||
;; Single value
|
||||
(<>
|
||||
(pre :class "whitespace-pre-wrap break-words break-all text-xs mb-2"
|
||||
(code (get value "summary")))
|
||||
(when (get value "children")
|
||||
(div :class "pl-3 border-l border-neutral-200"
|
||||
(~admin/data-model-content
|
||||
:columns (get (get value "children") "columns")
|
||||
:relationships (get (get value "children") "relationships"))))))))))
|
||||
|
||||
(defcomp ~admin/data-model-content (&key columns relationships)
|
||||
(div :class "space-y-4"
|
||||
(~admin/data-scalar-table :columns columns)
|
||||
(when (not (empty? (or relationships (list))))
|
||||
(div :class "space-y-3"
|
||||
(map (lambda (rel)
|
||||
(~admin/data-relationship
|
||||
:name (get rel "name")
|
||||
:cardinality (get rel "cardinality")
|
||||
:class-name (get rel "class_name")
|
||||
:loaded (get rel "loaded")
|
||||
:value (get rel "value")))
|
||||
relationships)))))
|
||||
|
||||
(defcomp ~admin/data-table-content (&key tablename model-data)
|
||||
(if (not model-data)
|
||||
(div :class "px-4 py-8 text-stone-400" "No post data available.")
|
||||
(div :class "px-4 py-8"
|
||||
(div :class "mb-6 text-sm text-neutral-500"
|
||||
"Model: " (code "Post") " \u2022 Table: " (code tablename))
|
||||
(~admin/data-model-content
|
||||
:columns (get model-data "columns")
|
||||
:relationships (get model-data "relationships")))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Calendar month view for browsing/toggling entries (B1)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~admin/cal-entry-associated (&key name toggle-url csrf)
|
||||
(div :class "flex items-center gap-1 text-[10px] rounded px-1 py-0.5 bg-green-200 text-green-900"
|
||||
(span :class "truncate flex-1" name)
|
||||
(button :type "button" :class "flex-shrink-0 hover:text-red-600"
|
||||
:data-confirm "" :data-confirm-title "Remove entry?"
|
||||
:data-confirm-text (str "Remove " name " from this post?")
|
||||
:data-confirm-icon "warning" :data-confirm-confirm-text "Yes, remove it"
|
||||
:data-confirm-cancel-text "Cancel" :data-confirm-event "confirmed"
|
||||
:sx-post toggle-url :sx-trigger "confirmed"
|
||||
:sx-target "#associated-entries-list" :sx-swap "outerHTML"
|
||||
:sx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")
|
||||
:sx-on:afterSwap "document.body.dispatchEvent(new CustomEvent('entryToggled'))"
|
||||
(i :class "fa fa-times"))))
|
||||
|
||||
(defcomp ~admin/cal-entry-unassociated (&key name toggle-url csrf)
|
||||
(button :type "button"
|
||||
:class "w-full text-left text-[10px] rounded px-1 py-0.5 bg-stone-100 text-stone-700 hover:bg-stone-200"
|
||||
:data-confirm "" :data-confirm-title "Add entry?"
|
||||
:data-confirm-text (str "Add " name " to this post?")
|
||||
:data-confirm-icon "question" :data-confirm-confirm-text "Yes, add it"
|
||||
:data-confirm-cancel-text "Cancel" :data-confirm-event "confirmed"
|
||||
:sx-post toggle-url :sx-trigger "confirmed"
|
||||
:sx-target "#associated-entries-list" :sx-swap "outerHTML"
|
||||
:sx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")
|
||||
:sx-on:afterSwap "document.body.dispatchEvent(new CustomEvent('entryToggled'))"
|
||||
(span :class "truncate block" name)))
|
||||
|
||||
(defcomp ~admin/calendar-view (&key cal-id year month-name
|
||||
current-url prev-month-url prev-year-url
|
||||
next-month-url next-year-url
|
||||
weekday-names days csrf)
|
||||
(let* ((target (str "#calendar-view-" cal-id)))
|
||||
(div :id (str "calendar-view-" cal-id)
|
||||
:sx-get current-url :sx-trigger "entryToggled from:body" :sx-swap "outerHTML"
|
||||
(header :class "flex items-center justify-center mb-4"
|
||||
(nav :class "flex items-center gap-2 text-xl"
|
||||
(a :class "px-2 py-1 hover:bg-stone-100 rounded"
|
||||
:sx-get prev-year-url :sx-target target :sx-swap "outerHTML"
|
||||
(raw! "«"))
|
||||
(a :class "px-2 py-1 hover:bg-stone-100 rounded"
|
||||
:sx-get prev-month-url :sx-target target :sx-swap "outerHTML"
|
||||
(raw! "‹"))
|
||||
(div :class "px-3 font-medium" (str month-name " " year))
|
||||
(a :class "px-2 py-1 hover:bg-stone-100 rounded"
|
||||
:sx-get next-month-url :sx-target target :sx-swap "outerHTML"
|
||||
(raw! "›"))
|
||||
(a :class "px-2 py-1 hover:bg-stone-100 rounded"
|
||||
:sx-get next-year-url :sx-target target :sx-swap "outerHTML"
|
||||
(raw! "»"))))
|
||||
(div :class "rounded border bg-white"
|
||||
(div :class "hidden sm:grid grid-cols-7 text-center text-xs font-semibold text-stone-700 bg-stone-50 border-b"
|
||||
(map (lambda (wd) (div :class "py-2" wd)) (or weekday-names (list))))
|
||||
(div :class "grid grid-cols-1 sm:grid-cols-7 gap-px bg-stone-200"
|
||||
(map (lambda (day)
|
||||
(let* ((extra-cls (if (get day "in_month") "" " bg-stone-50 text-stone-400"))
|
||||
(entries (or (get day "entries") (list))))
|
||||
(div :class (str "min-h-20 bg-white px-2 py-2 text-xs" extra-cls)
|
||||
(div :class "font-medium mb-1" (str (get day "day")))
|
||||
(when (not (empty? entries))
|
||||
(div :class "space-y-0.5"
|
||||
(map (lambda (e)
|
||||
(if (get e "is_associated")
|
||||
(~admin/cal-entry-associated
|
||||
:name (get e "name") :toggle-url (get e "toggle_url") :csrf csrf)
|
||||
(~admin/cal-entry-unassociated
|
||||
:name (get e "name") :toggle-url (get e "toggle_url") :csrf csrf)))
|
||||
entries))))))
|
||||
(or days (list))))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Nav entries OOB — renders associated entry/calendar items in scroll wrapper (B2)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~admin/nav-entries-oob (&key entries calendars)
|
||||
(let* ((entry-list (or entries (list)))
|
||||
(cal-list (or calendars (list)))
|
||||
(has-items (or (not (empty? entry-list)) (not (empty? cal-list))))
|
||||
(nav-cls "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black [.hover-capable_&]:hover:bg-yellow-300 aria-selected:bg-stone-500 aria-selected:text-white [.hover-capable_&[aria-selected=true]:hover]:bg-orange-500 p-2")
|
||||
(scroll-hs "on load or scroll if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth remove .hidden from .entries-nav-arrow add .flex to .entries-nav-arrow else add .hidden to .entries-nav-arrow remove .flex from .entries-nav-arrow end"))
|
||||
(if (not has-items)
|
||||
(~shared:nav/blog-nav-entries-empty)
|
||||
(~shared:misc/scroll-nav-wrapper
|
||||
:wrapper-id "entries-calendars-nav-wrapper"
|
||||
:container-id "associated-items-container"
|
||||
:arrow-cls "entries-nav-arrow"
|
||||
:left-hs "on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200"
|
||||
:scroll-hs scroll-hs
|
||||
:right-hs "on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200"
|
||||
:items (<>
|
||||
(map (lambda (e)
|
||||
(~shared:navigation/calendar-entry-nav
|
||||
:href (get e "href") :nav-class nav-cls
|
||||
:name (get e "name") :date-str (get e "date_str")))
|
||||
entry-list)
|
||||
(map (lambda (c)
|
||||
(~shared:nav/blog-nav-calendar-item
|
||||
:href (get c "href") :nav-cls nav-cls
|
||||
:name (get c "name")))
|
||||
cal-list))
|
||||
:oob true))))
|
||||
|
||||
41
blog/sx/boundary.sx
Normal file
41
blog/sx/boundary.sx
Normal file
@@ -0,0 +1,41 @@
|
||||
;; Blog service — page helper declarations.
|
||||
|
||||
(define-page-helper "editor-data"
|
||||
:params (&key)
|
||||
:returns "dict"
|
||||
:service "blog")
|
||||
|
||||
(define-page-helper "editor-page-data"
|
||||
:params (&key)
|
||||
:returns "dict"
|
||||
:service "blog")
|
||||
|
||||
(define-page-helper "post-admin-data"
|
||||
:params (&key slug)
|
||||
:returns "dict"
|
||||
:service "blog")
|
||||
|
||||
(define-page-helper "post-data-data"
|
||||
:params (&key slug)
|
||||
:returns "dict"
|
||||
:service "blog")
|
||||
|
||||
(define-page-helper "post-preview-data"
|
||||
:params (&key slug)
|
||||
:returns "dict"
|
||||
:service "blog")
|
||||
|
||||
(define-page-helper "post-entries-data"
|
||||
:params (&key slug)
|
||||
:returns "dict"
|
||||
:service "blog")
|
||||
|
||||
(define-page-helper "post-settings-data"
|
||||
:params (&key slug)
|
||||
:returns "dict"
|
||||
:service "blog")
|
||||
|
||||
(define-page-helper "post-edit-data"
|
||||
:params (&key slug)
|
||||
:returns "dict"
|
||||
:service "blog")
|
||||
106
blog/sx/cards.sx
106
blog/sx/cards.sx
@@ -1,62 +1,61 @@
|
||||
;; Blog card components — pure data, no HTML injection
|
||||
|
||||
(defcomp ~blog-like-button (&key like-url hx-headers heart)
|
||||
(defcomp ~cards/like-button (&key like-url hx-headers heart)
|
||||
(div :class "absolute top-20 right-2 z-10 text-6xl md:text-4xl"
|
||||
(button :sx-post like-url :sx-swap "outerHTML"
|
||||
:sx-headers hx-headers :class "cursor-pointer" heart)))
|
||||
(~detail/like-toggle :like-url like-url :hx-headers hx-headers :heart heart)))
|
||||
|
||||
(defcomp ~blog-draft-status (&key publish-requested timestamp)
|
||||
(defcomp ~cards/draft-status (&key (publish-requested :as boolean) (timestamp :as string?))
|
||||
(<> (div :class "flex justify-center gap-2 mt-1"
|
||||
(span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-800" "Draft")
|
||||
(when publish-requested (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800" "Publish requested")))
|
||||
(when timestamp (p :class "text-sm text-stone-500" (str "Updated: " timestamp)))))
|
||||
|
||||
(defcomp ~blog-published-status (&key timestamp)
|
||||
(defcomp ~cards/published-status (&key (timestamp :as string))
|
||||
(p :class "text-sm text-stone-500" (str "Published: " timestamp)))
|
||||
|
||||
;; Tag components — accept data, not HTML
|
||||
(defcomp ~blog-tag-icon (&key src name initial)
|
||||
(defcomp ~cards/tag-icon (&key (src :as string?) (name :as string) (initial :as string))
|
||||
(if src
|
||||
(img :src src :alt name :class "h-4 w-4 rounded-full object-cover border border-stone-300 flex-shrink-0")
|
||||
(div :class "h-4 w-4 rounded-full text-[8px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0 bg-stone-200 text-stone-600" initial)))
|
||||
|
||||
(defcomp ~blog-tag-item (&key src name initial)
|
||||
(defcomp ~cards/tag-item (&key src name initial)
|
||||
(li (a :class "flex items-center gap-1"
|
||||
(~blog-tag-icon :src src :name name :initial initial)
|
||||
(~cards/tag-icon :src src :name name :initial initial)
|
||||
(span :class "inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-sm font-medium border border-stone-200" name))))
|
||||
|
||||
;; At-bar — tags + authors row for detail pages
|
||||
(defcomp ~blog-at-bar (&key tags authors)
|
||||
(defcomp ~cards/at-bar (&key tags authors)
|
||||
(when (or tags authors)
|
||||
(div :class "flex flex-row justify-center gap-3"
|
||||
(when tags
|
||||
(div :class "mt-4 flex items-center gap-2" (div "in")
|
||||
(ul :class "flex flex-wrap gap-2 text-sm"
|
||||
(map (lambda (t) (~blog-tag-item :src (get t "src") :name (get t "name") :initial (get t "initial"))) tags))))
|
||||
(map (lambda (t) (~cards/tag-item :src (get t "src") :name (get t "name") :initial (get t "initial"))) tags))))
|
||||
(div)
|
||||
(when authors
|
||||
(div :class "mt-4 flex items-center gap-2" (div "by")
|
||||
(ul :class "flex flex-wrap gap-2 text-sm"
|
||||
(map (lambda (a) (~blog-author-item :image (get a "image") :name (get a "name"))) authors)))))))
|
||||
(map (lambda (a) (~cards/author-item :image (get a "image") :name (get a "name"))) authors)))))))
|
||||
|
||||
;; Author components
|
||||
(defcomp ~blog-author-item (&key image name)
|
||||
(defcomp ~cards/author-item (&key image name)
|
||||
(li :class "flex items-center gap-1"
|
||||
(when image (img :src image :alt name :class "h-5 w-5 rounded-full object-cover"))
|
||||
(span :class "text-stone-700" name)))
|
||||
|
||||
;; Card — accepts pure data
|
||||
(defcomp ~blog-card (&key slug href hx-select title
|
||||
feature-image excerpt
|
||||
status is-draft publish-requested status-timestamp
|
||||
liked like-url csrf-token
|
||||
has-like
|
||||
tags authors widget)
|
||||
(defcomp ~cards/index (&key (slug :as string) (href :as string) (hx-select :as string?) (title :as string)
|
||||
(feature-image :as string?) (excerpt :as string?)
|
||||
status (is-draft :as boolean) (publish-requested :as boolean) (status-timestamp :as string?)
|
||||
(liked :as boolean) (like-url :as string?) (csrf-token :as string?)
|
||||
(has-like :as boolean)
|
||||
(tags :as list?) (authors :as list?) widget)
|
||||
(article :class "border-b pb-6 last:border-b-0 relative"
|
||||
(when has-like
|
||||
(~blog-like-button
|
||||
(~cards/like-button
|
||||
:like-url like-url
|
||||
:sx-headers (str "{\"X-CSRFToken\": \"" csrf-token "\"}")
|
||||
:hx-headers {:X-CSRFToken csrf-token}
|
||||
:heart (if liked "❤️" "🤍")))
|
||||
(a :href href :sx-get href :sx-target "#main-panel"
|
||||
:sx-select (or hx-select "#main-panel") :sx-swap "outerHTML" :sx-push-url "true"
|
||||
@@ -64,8 +63,8 @@
|
||||
(header :class "mb-2 text-center"
|
||||
(h2 :class "text-4xl font-bold text-stone-900" title)
|
||||
(if is-draft
|
||||
(~blog-draft-status :publish-requested publish-requested :timestamp status-timestamp)
|
||||
(when status-timestamp (~blog-published-status :timestamp status-timestamp))))
|
||||
(~cards/draft-status :publish-requested publish-requested :timestamp status-timestamp)
|
||||
(when status-timestamp (~cards/published-status :timestamp status-timestamp))))
|
||||
(when feature-image (div :class "mb-4" (img :src feature-image :alt "" :class "rounded-lg w-full object-cover")))
|
||||
(when excerpt (p :class "text-stone-700 text-lg leading-relaxed text-center" excerpt)))
|
||||
widget
|
||||
@@ -74,16 +73,16 @@
|
||||
(when tags
|
||||
(div :class "mt-4 flex items-center gap-2" (div "in")
|
||||
(ul :class "flex flex-wrap gap-2 text-sm"
|
||||
(map (lambda (t) (~blog-tag-item :src (get t "src") :name (get t "name") :initial (get t "initial"))) tags))))
|
||||
(map (lambda (t) (~cards/tag-item :src (get t "src") :name (get t "name") :initial (get t "initial"))) tags))))
|
||||
(div)
|
||||
(when authors
|
||||
(div :class "mt-4 flex items-center gap-2" (div "by")
|
||||
(ul :class "flex flex-wrap gap-2 text-sm"
|
||||
(map (lambda (a) (~blog-author-item :image (get a "image") :name (get a "name"))) authors))))))))
|
||||
(map (lambda (a) (~cards/author-item :image (get a "image") :name (get a "name"))) authors))))))))
|
||||
|
||||
(defcomp ~blog-card-tile (&key href hx-select feature-image title
|
||||
is-draft publish-requested status-timestamp
|
||||
excerpt tags authors)
|
||||
(defcomp ~cards/tile (&key (href :as string) (hx-select :as string?) (feature-image :as string?) (title :as string)
|
||||
(is-draft :as boolean) (publish-requested :as boolean) (status-timestamp :as string?)
|
||||
(excerpt :as string?) (tags :as list?) (authors :as list?))
|
||||
(article :class "relative"
|
||||
(a :href href :sx-get href :sx-target "#main-panel"
|
||||
:sx-select (or hx-select "#main-panel") :sx-swap "outerHTML" :sx-push-url "true"
|
||||
@@ -92,36 +91,73 @@
|
||||
(div :class "p-3 text-center"
|
||||
(h2 :class "text-lg font-bold text-stone-900" title)
|
||||
(if is-draft
|
||||
(~blog-draft-status :publish-requested publish-requested :timestamp status-timestamp)
|
||||
(when status-timestamp (~blog-published-status :timestamp status-timestamp)))
|
||||
(~cards/draft-status :publish-requested publish-requested :timestamp status-timestamp)
|
||||
(when status-timestamp (~cards/published-status :timestamp status-timestamp)))
|
||||
(when excerpt (p :class "text-stone-700 text-sm leading-relaxed line-clamp-3 mt-1" excerpt))))
|
||||
(when (or tags authors)
|
||||
(div :class "flex flex-row justify-center gap-3"
|
||||
(when tags
|
||||
(div :class "mt-4 flex items-center gap-2" (div "in")
|
||||
(ul :class "flex flex-wrap gap-2 text-sm"
|
||||
(map (lambda (t) (~blog-tag-item :src (get t "src") :name (get t "name") :initial (get t "initial"))) tags))))
|
||||
(map (lambda (t) (~cards/tag-item :src (get t "src") :name (get t "name") :initial (get t "initial"))) tags))))
|
||||
(div)
|
||||
(when authors
|
||||
(div :class "mt-4 flex items-center gap-2" (div "by")
|
||||
(ul :class "flex flex-wrap gap-2 text-sm"
|
||||
(map (lambda (a) (~blog-author-item :image (get a "image") :name (get a "name"))) authors))))))))
|
||||
(map (lambda (a) (~cards/author-item :image (get a "image") :name (get a "name"))) authors))))))))
|
||||
|
||||
(defcomp ~blog-page-badges (&key has-calendar has-market)
|
||||
;; Data-driven blog cards list (replaces Python _blog_cards_sx loop)
|
||||
(defcomp ~cards/from-data (&key (posts :as list?) (view :as string?) sentinel)
|
||||
(<>
|
||||
(map (lambda (p)
|
||||
(if (= view "tile")
|
||||
(~cards/tile
|
||||
:href (get p "href") :hx-select (get p "hx_select")
|
||||
:feature-image (get p "feature_image") :title (get p "title")
|
||||
:is-draft (get p "is_draft") :publish-requested (get p "publish_requested")
|
||||
:status-timestamp (get p "status_timestamp")
|
||||
:excerpt (get p "excerpt") :tags (get p "tags") :authors (get p "authors"))
|
||||
(~cards/index
|
||||
:slug (get p "slug") :href (get p "href") :hx-select (get p "hx_select")
|
||||
:title (get p "title") :feature-image (get p "feature_image")
|
||||
:excerpt (get p "excerpt") :is-draft (get p "is_draft")
|
||||
:publish-requested (get p "publish_requested")
|
||||
:status-timestamp (get p "status_timestamp")
|
||||
:has-like (get p "has_like") :liked (get p "liked")
|
||||
:like-url (get p "like_url") :csrf-token (get p "csrf_token")
|
||||
:tags (get p "tags") :authors (get p "authors")
|
||||
:widget (when (get p "widget") (~rich-text :html (get p "widget"))))))
|
||||
(or posts (list)))
|
||||
sentinel))
|
||||
|
||||
;; Data-driven page cards list (replaces Python _page_cards_sx loop)
|
||||
(defcomp ~cards/page-cards-from-data (&key (pages :as list?) sentinel)
|
||||
(<>
|
||||
(map (lambda (pg)
|
||||
(~cards/page-card
|
||||
:href (get pg "href") :hx-select (get pg "hx_select")
|
||||
:title (get pg "title")
|
||||
:has-calendar (get pg "has_calendar") :has-market (get pg "has_market")
|
||||
:pub-timestamp (get pg "pub_timestamp")
|
||||
:feature-image (get pg "feature_image") :excerpt (get pg "excerpt")))
|
||||
(or pages (list)))
|
||||
sentinel))
|
||||
|
||||
(defcomp ~cards/page-badges (&key has-calendar has-market)
|
||||
(div :class "flex justify-center gap-2 mt-2"
|
||||
(when has-calendar (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800"
|
||||
(i :class "fa fa-calendar mr-1") "Calendar"))
|
||||
(when has-market (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-800"
|
||||
(i :class "fa fa-shopping-bag mr-1") "Market"))))
|
||||
|
||||
(defcomp ~blog-page-card (&key href hx-select title has-calendar has-market pub-timestamp feature-image excerpt)
|
||||
(defcomp ~cards/page-card (&key (href :as string) (hx-select :as string?) (title :as string) (has-calendar :as boolean) (has-market :as boolean) (pub-timestamp :as string?) (feature-image :as string?) (excerpt :as string?))
|
||||
(article :class "border-b pb-6 last:border-b-0 relative"
|
||||
(a :href href :sx-get href :sx-target "#main-panel"
|
||||
:sx-select (or hx-select "#main-panel") :sx-swap "outerHTML" :sx-push-url "true"
|
||||
:class "block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"
|
||||
(header :class "mb-2 text-center"
|
||||
(h2 :class "text-4xl font-bold text-stone-900" title)
|
||||
(~blog-page-badges :has-calendar has-calendar :has-market has-market)
|
||||
(when pub-timestamp (~blog-published-status :timestamp pub-timestamp)))
|
||||
(~cards/page-badges :has-calendar has-calendar :has-market has-market)
|
||||
(when pub-timestamp (~cards/published-status :timestamp pub-timestamp)))
|
||||
(when feature-image (div :class "mb-4" (img :src feature-image :alt "" :class "rounded-lg w-full object-cover")))
|
||||
(when excerpt (p :class "text-stone-700 text-lg leading-relaxed text-center" excerpt)))))
|
||||
|
||||
@@ -1,31 +1,34 @@
|
||||
;; Blog post detail components
|
||||
|
||||
(defcomp ~blog-detail-edit-link (&key href hx-select)
|
||||
(defcomp ~detail/edit-link (&key (href :as string) (hx-select :as string))
|
||||
(a :href href :sx-get href :sx-target "#main-panel"
|
||||
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
|
||||
:class "inline-block px-3 py-1 rounded-full text-sm font-semibold bg-stone-700 text-white hover:bg-stone-800 transition-colors"
|
||||
(i :class "fa fa-pencil mr-1") " Edit"))
|
||||
|
||||
(defcomp ~blog-detail-draft (&key publish-requested edit)
|
||||
(defcomp ~detail/draft (&key publish-requested edit)
|
||||
(div :class "flex items-center justify-center gap-2 mb-3"
|
||||
(span :class "inline-block px-3 py-1 rounded-full text-sm font-semibold bg-amber-100 text-amber-800" "Draft")
|
||||
(when publish-requested (span :class "inline-block px-3 py-1 rounded-full text-sm font-semibold bg-blue-100 text-blue-800" "Publish requested"))
|
||||
edit))
|
||||
|
||||
(defcomp ~blog-detail-like (&key like-url hx-headers heart)
|
||||
(div :class "absolute top-2 right-2 z-10 text-8xl md:text-6xl"
|
||||
(button :sx-post like-url :sx-swap "outerHTML"
|
||||
:sx-headers hx-headers :class "cursor-pointer" heart)))
|
||||
(defcomp ~detail/like-toggle (&key like-url hx-headers heart)
|
||||
(button :sx-post like-url :sx-swap "outerHTML"
|
||||
:sx-headers hx-headers :class "cursor-pointer" heart))
|
||||
|
||||
(defcomp ~blog-detail-excerpt (&key excerpt)
|
||||
(defcomp ~detail/like (&key like-url hx-headers heart)
|
||||
(div :class "absolute top-2 right-2 z-10 text-8xl md:text-6xl"
|
||||
(~detail/like-toggle :like-url like-url :hx-headers hx-headers :heart heart)))
|
||||
|
||||
(defcomp ~detail/excerpt (&key (excerpt :as string))
|
||||
(div :class "w-full text-center italic text-3xl p-2" excerpt))
|
||||
|
||||
(defcomp ~blog-detail-chrome (&key like excerpt at-bar)
|
||||
(defcomp ~detail/chrome (&key like excerpt at-bar)
|
||||
(<> like
|
||||
excerpt
|
||||
(div :class "hidden md:block" at-bar)))
|
||||
|
||||
(defcomp ~blog-detail-main (&key draft chrome feature-image html-content sx-content)
|
||||
(defcomp ~detail/main (&key draft chrome feature-image html-content sx-content)
|
||||
(<> (article :class "relative"
|
||||
draft
|
||||
chrome
|
||||
@@ -36,7 +39,38 @@
|
||||
(when html-content (div :class "blog-content p-2" (~rich-text :html html-content)))))
|
||||
(div :class "pb-8")))
|
||||
|
||||
(defcomp ~blog-meta (&key robots page-title desc canonical og-type og-title image twitter-card twitter-title)
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Data-driven composition — replaces _post_main_panel_sx
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~detail/post-detail-content (&key (slug :as string) (is-draft :as boolean) (publish-requested :as boolean) (can-edit :as boolean) (edit-href :as string?)
|
||||
(is-page :as boolean) (has-user :as boolean) (liked :as boolean) (like-url :as string?) (csrf :as string?)
|
||||
(custom-excerpt :as string?) (tags :as list?) (authors :as list?)
|
||||
(feature-image :as string?) (html-content :as string?) (sx-content :as string?))
|
||||
(let* ((hx-select "#main-panel")
|
||||
(draft-sx (when is-draft
|
||||
(~detail/draft
|
||||
:publish-requested publish-requested
|
||||
:edit (when can-edit
|
||||
(~detail/edit-link :href edit-href :hx-select hx-select)))))
|
||||
(chrome-sx (when (not is-page)
|
||||
(~detail/chrome
|
||||
:like (when has-user
|
||||
(~detail/like
|
||||
:like-url like-url
|
||||
:hx-headers {:X-CSRFToken csrf}
|
||||
:heart (if liked "❤️" "🤍")))
|
||||
:excerpt (when (not (= custom-excerpt ""))
|
||||
(~detail/excerpt :excerpt custom-excerpt))
|
||||
:at-bar (~cards/at-bar :tags tags :authors authors)))))
|
||||
(~detail/main
|
||||
:draft draft-sx
|
||||
:chrome chrome-sx
|
||||
:feature-image feature-image
|
||||
:html-content html-content
|
||||
:sx-content sx-content)))
|
||||
|
||||
(defcomp ~detail/meta (&key (robots :as string) (page-title :as string) (desc :as string) (canonical :as string?) (og-type :as string) (og-title :as string) (image :as string?) (twitter-card :as string) (twitter-title :as string))
|
||||
(<>
|
||||
(meta :name "robots" :content robots)
|
||||
(title page-title)
|
||||
@@ -52,7 +86,7 @@
|
||||
(meta :name "twitter:description" :content desc)
|
||||
(when image (meta :name "twitter:image" :content image))))
|
||||
|
||||
(defcomp ~blog-home-main (&key html-content sx-content)
|
||||
(defcomp ~detail/home-main (&key html-content sx-content)
|
||||
(article :class "relative"
|
||||
(if sx-content
|
||||
(div :class "blog-content p-2" sx-content)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
;; Blog editor components
|
||||
|
||||
(defcomp ~blog-editor-error (&key error)
|
||||
(defcomp ~editor/error (&key error)
|
||||
(div :class "max-w-[768px] mx-auto mt-[16px] rounded-[8px] border border-red-300 bg-red-50 px-[16px] py-[12px] text-[14px] text-red-700"
|
||||
(strong "Save failed:") " " error))
|
||||
|
||||
(defcomp ~blog-editor-form (&key csrf title-placeholder create-label)
|
||||
(defcomp ~editor/form (&key (csrf :as string) (title-placeholder :as string) (create-label :as string))
|
||||
(form :id "post-new-form" :method "post" :class "max-w-[768px] mx-auto pb-[48px]"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(input :type "hidden" :id "lexical-json-input" :name "lexical" :value "")
|
||||
@@ -55,20 +55,118 @@
|
||||
(button :type "submit"
|
||||
:class "px-[20px] py-[6px] bg-stone-700 text-white text-[14px] rounded-[8px] hover:bg-stone-800 transition-colors cursor-pointer" create-label))))
|
||||
|
||||
(defcomp ~blog-editor-styles (&key css-href)
|
||||
;; Edit form — pre-populated version for /<slug>/admin/edit/
|
||||
(defcomp ~editor/edit-form (&key (csrf :as string) (updated-at :as string) (title-val :as string?) (excerpt-val :as string?)
|
||||
(feature-image :as string?) (feature-image-caption :as string?)
|
||||
(sx-content-val :as string?) (lexical-json :as string?)
|
||||
(has-sx :as boolean) (title-placeholder :as string)
|
||||
(status :as string) (already-emailed :as boolean)
|
||||
newsletter-options footer-extra)
|
||||
(let* ((sel-cls "text-[14px] rounded-[4px] border border-stone-200 px-[8px] py-[6px] bg-white text-stone-600")
|
||||
(active "px-[12px] py-[6px] text-[13px] font-medium text-stone-700 border-b-2 border-stone-700 cursor-pointer bg-transparent")
|
||||
(inactive "px-[12px] py-[6px] text-[13px] font-medium text-stone-400 border-b-2 border-transparent cursor-pointer bg-transparent hover:text-stone-600"))
|
||||
(form :id "post-edit-form" :method "post" :class "max-w-[768px] mx-auto pb-[48px]"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(input :type "hidden" :name "updated_at" :value updated-at)
|
||||
(input :type "hidden" :id "lexical-json-input" :name "lexical" :value "")
|
||||
(input :type "hidden" :id "sx-content-input" :name "sx_content" :value (or sx-content-val ""))
|
||||
(input :type "hidden" :id "feature-image-input" :name "feature_image" :value (or feature-image ""))
|
||||
(input :type "hidden" :id "feature-image-caption-input" :name "feature_image_caption" :value (or feature-image-caption ""))
|
||||
(div :id "feature-image-container" :class "relative mt-[16px] mb-[24px] group"
|
||||
(div :id "feature-image-empty" :class (if feature-image "hidden" "")
|
||||
(button :type "button" :id "feature-image-add-btn"
|
||||
:class "text-[14px] text-stone-400 hover:text-stone-600 transition-colors cursor-pointer"
|
||||
"+ Add feature image"))
|
||||
(div :id "feature-image-filled" :class (str "relative " (if feature-image "" "hidden"))
|
||||
(img :id "feature-image-preview" :src (or feature-image "") :alt ""
|
||||
:class "w-full max-h-[448px] object-cover rounded-[8px] cursor-pointer")
|
||||
(button :type "button" :id "feature-image-delete-btn"
|
||||
:class "absolute top-[8px] right-[8px] w-[32px] h-[32px] rounded-full bg-black/50 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-black/70 cursor-pointer text-[14px]"
|
||||
:title "Remove feature image"
|
||||
(i :class "fa-solid fa-trash-can"))
|
||||
(input :type "text" :id "feature-image-caption" :value (or feature-image-caption "")
|
||||
:placeholder "Add a caption..."
|
||||
:class "mt-[8px] w-full text-[14px] text-stone-500 bg-transparent border-none outline-none placeholder:text-stone-300 focus:text-stone-700"))
|
||||
(div :id "feature-image-uploading"
|
||||
:class "hidden flex items-center gap-[8px] mt-[8px] text-[14px] text-stone-400"
|
||||
(i :class "fa-solid fa-spinner fa-spin") " Uploading...")
|
||||
(input :type "file" :id "feature-image-file"
|
||||
:accept "image/jpeg,image/png,image/gif,image/webp,image/svg+xml" :class "hidden"))
|
||||
(input :type "text" :name "title" :value (or title-val "") :placeholder title-placeholder
|
||||
:class "w-full text-[36px] font-bold bg-transparent border-none outline-none placeholder:text-stone-300 mb-[8px] leading-tight")
|
||||
(textarea :name "custom_excerpt" :rows "1" :placeholder "Add an excerpt..."
|
||||
:class "w-full text-[18px] text-stone-500 bg-transparent border-none outline-none placeholder:text-stone-300 resize-none mb-[24px] leading-relaxed"
|
||||
(or excerpt-val ""))
|
||||
;; Editor tabs
|
||||
(div :class "flex gap-[4px] mb-[8px] border-b border-stone-200"
|
||||
(button :type "button" :id "editor-tab-sx"
|
||||
:class (if has-sx active inactive)
|
||||
:onclick "document.getElementById('sx-editor').style.display='block';document.getElementById('lexical-editor').style.display='none';this.className='px-[12px] py-[6px] text-[13px] font-medium text-stone-700 border-b-2 border-stone-700 cursor-pointer bg-transparent';document.getElementById('editor-tab-koenig').className='px-[12px] py-[6px] text-[13px] font-medium text-stone-400 border-b-2 border-transparent cursor-pointer bg-transparent hover:text-stone-600'"
|
||||
"SX Editor")
|
||||
(button :type "button" :id "editor-tab-koenig"
|
||||
:class (if has-sx inactive active)
|
||||
:onclick "document.getElementById('lexical-editor').style.display='block';document.getElementById('sx-editor').style.display='none';this.className='px-[12px] py-[6px] text-[13px] font-medium text-stone-700 border-b-2 border-stone-700 cursor-pointer bg-transparent';document.getElementById('editor-tab-sx').className='px-[12px] py-[6px] text-[13px] font-medium text-stone-400 border-b-2 border-transparent cursor-pointer bg-transparent hover:text-stone-600'"
|
||||
"Koenig (Legacy)"))
|
||||
(div :id "sx-editor" :class "relative w-full bg-transparent"
|
||||
:style (if has-sx "" "display:none"))
|
||||
(div :id "lexical-editor" :class "relative w-full bg-transparent"
|
||||
:style (if has-sx "display:none" ""))
|
||||
;; Initial lexical JSON
|
||||
(script :id "lexical-initial-data" :type "application/json" lexical-json)
|
||||
;; Footer: status + publish mode + newsletter + save + badges
|
||||
(div :class "flex flex-wrap items-center gap-[16px] mt-[32px] pt-[16px] border-t border-stone-200"
|
||||
(select :id "status-select" :name "status" :class sel-cls
|
||||
(option :value "draft" :selected (= status "draft") "Draft")
|
||||
(option :value "published" :selected (= status "published") "Published"))
|
||||
(select :id "publish-mode-select" :name "publish_mode"
|
||||
:class (str sel-cls (if (= status "published") "" " hidden")
|
||||
(if already-emailed " opacity-50 pointer-events-none" ""))
|
||||
:disabled (if already-emailed true nil)
|
||||
(option :value "web" :selected true "Web only")
|
||||
(option :value "email" "Email only")
|
||||
(option :value "both" "Web + Email"))
|
||||
(select :id "newsletter-select" :name "newsletter_slug"
|
||||
:class (str sel-cls " hidden")
|
||||
:disabled (if already-emailed true nil)
|
||||
newsletter-options)
|
||||
(button :type "submit"
|
||||
:class "px-[20px] py-[6px] bg-stone-700 text-white text-[14px] rounded-[8px] hover:bg-stone-800 transition-colors cursor-pointer"
|
||||
"Save")
|
||||
(when footer-extra footer-extra)))))
|
||||
|
||||
;; Publish-mode show/hide script for edit form
|
||||
(defcomp ~editor/publish-js (&key already-emailed)
|
||||
(script
|
||||
"(function() {"
|
||||
" var statusSel = document.getElementById('status-select');"
|
||||
" var modeSel = document.getElementById('publish-mode-select');"
|
||||
" var nlSel = document.getElementById('newsletter-select');"
|
||||
(str " var alreadyEmailed = " (if already-emailed "true" "false") ";")
|
||||
" function sync() {"
|
||||
" var isPublished = statusSel.value === 'published';"
|
||||
" if (isPublished && !alreadyEmailed) { modeSel.classList.remove('hidden'); } else { modeSel.classList.add('hidden'); }"
|
||||
" var needsEmail = isPublished && !alreadyEmailed && (modeSel.value === 'email' || modeSel.value === 'both');"
|
||||
" if (needsEmail) { nlSel.classList.remove('hidden'); } else { nlSel.classList.add('hidden'); }"
|
||||
" }"
|
||||
" statusSel.addEventListener('change', sync);"
|
||||
" modeSel.addEventListener('change', sync);"
|
||||
" sync();"
|
||||
"})();"))
|
||||
|
||||
(defcomp ~editor/styles (&key (css-href :as string))
|
||||
(<> (link :rel "stylesheet" :href css-href)
|
||||
(style
|
||||
"#lexical-editor { display: flow-root; }"
|
||||
"#lexical-editor [data-kg-card=\"html\"] * { float: none !important; }"
|
||||
"#lexical-editor [data-kg-card=\"html\"] table { width: 100% !important; }")))
|
||||
|
||||
(defcomp ~blog-editor-scripts (&key js-src sx-editor-js-src init-js)
|
||||
(defcomp ~editor/scripts (&key (js-src :as string) (sx-editor-js-src :as string?) (init-js :as string))
|
||||
(<> (script :src js-src)
|
||||
(when sx-editor-js-src (script :src sx-editor-js-src))
|
||||
(script init-js)))
|
||||
|
||||
;; SX editor styles — comprehensive CSS for the Koenig-style block editor
|
||||
(defcomp ~sx-editor-styles ()
|
||||
(defcomp ~editor/sx-editor-styles ()
|
||||
(style
|
||||
;; Editor container
|
||||
".sx-editor { position: relative; font-size: 18px; line-height: 1.6; font-family: Georgia, 'Times New Roman', serif; color: #1c1917; }"
|
||||
@@ -205,3 +303,48 @@
|
||||
|
||||
;; Drag over editor
|
||||
".sx-drag-over { outline: 2px dashed #3b82f6; outline-offset: -2px; border-radius: 4px; }"))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Editor panel composition — replaces render_editor_panel (new post/page)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~editor/content (&key csrf title-placeholder create-label
|
||||
css-href js-src sx-editor-js-src init-js
|
||||
save-error)
|
||||
(~layouts/editor-panel :parts
|
||||
(<>
|
||||
(when save-error (~editor/error :error save-error))
|
||||
(~editor/form :csrf csrf :title-placeholder title-placeholder
|
||||
:create-label create-label)
|
||||
(~editor/styles :css-href css-href)
|
||||
(~editor/sx-editor-styles)
|
||||
(~editor/scripts :js-src js-src :sx-editor-js-src sx-editor-js-src
|
||||
:init-js init-js))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Edit content composition — replaces _h_post_edit_content (existing post)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~editor/edit-content (&key csrf updated-at title-val excerpt-val
|
||||
feature-image feature-image-caption
|
||||
sx-content-val lexical-json has-sx
|
||||
title-placeholder status already-emailed
|
||||
newsletter-options footer-extra
|
||||
css-href js-src sx-editor-js-src init-js
|
||||
save-error)
|
||||
(~layouts/editor-panel :parts
|
||||
(<>
|
||||
(when save-error (~editor/error :error save-error))
|
||||
(~editor/edit-form
|
||||
:csrf csrf :updated-at updated-at
|
||||
:title-val title-val :excerpt-val excerpt-val
|
||||
:feature-image feature-image :feature-image-caption feature-image-caption
|
||||
:sx-content-val sx-content-val :lexical-json lexical-json
|
||||
:has-sx has-sx :title-placeholder title-placeholder
|
||||
:status status :already-emailed already-emailed
|
||||
:newsletter-options newsletter-options :footer-extra footer-extra)
|
||||
(~editor/publish-js :already-emailed already-emailed)
|
||||
(~editor/styles :css-href css-href)
|
||||
(~editor/sx-editor-styles)
|
||||
(~editor/scripts :js-src js-src :sx-editor-js-src sx-editor-js-src
|
||||
:init-js init-js))))
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
;; Blog filter components
|
||||
|
||||
(defcomp ~blog-action-button (&key href hx-select btn-class title icon-class label)
|
||||
(defcomp ~filters/action-button (&key (href :as string) (hx-select :as string) (btn-class :as string) (title :as string) (icon-class :as string) (label :as string))
|
||||
(a :href href :sx-get href :sx-target "#main-panel"
|
||||
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
|
||||
:class btn-class :title title (i :class icon-class) label))
|
||||
|
||||
(defcomp ~blog-drafts-button (&key href hx-select btn-class title label draft-count)
|
||||
(defcomp ~filters/drafts-button (&key (href :as string) (hx-select :as string) (btn-class :as string) (title :as string) (label :as string) (draft-count :as number))
|
||||
(a :href href :sx-get href :sx-target "#main-panel"
|
||||
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
|
||||
:class btn-class :title title (i :class "fa fa-file-text-o mr-1") " Drafts "
|
||||
(span :class "inline-block bg-stone-500 text-white px-1.5 py-0.5 text-xs font-medium rounded ml-1" draft-count)))
|
||||
|
||||
(defcomp ~blog-drafts-button-amber (&key href hx-select btn-class title label draft-count)
|
||||
(defcomp ~filters/drafts-button-amber (&key href hx-select btn-class title label draft-count)
|
||||
(a :href href :sx-get href :sx-target "#main-panel"
|
||||
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
|
||||
:class btn-class :title title (i :class "fa fa-file-text-o mr-1") " Drafts "
|
||||
(span :class "inline-block bg-amber-500 text-white px-1.5 py-0.5 text-xs font-medium rounded ml-1" draft-count)))
|
||||
|
||||
(defcomp ~blog-action-buttons-wrapper (&key inner)
|
||||
(defcomp ~filters/action-buttons-wrapper (&key inner)
|
||||
(div :class "flex flex-wrap gap-2 px-4 py-3" inner))
|
||||
|
||||
(defcomp ~blog-filter-any-topic (&key cls hx-select)
|
||||
(defcomp ~filters/any-topic (&key cls hx-select)
|
||||
(li (a :class (str "px-3 py-1 rounded border " cls)
|
||||
:sx-get "?page=1" :sx-target "#main-panel" :sx-select hx-select
|
||||
:sx-swap "outerHTML" :sx-push-url "true" "Any Topic")))
|
||||
|
||||
(defcomp ~blog-filter-group-icon-image (&key src name)
|
||||
(defcomp ~filters/group-icon-image (&key src name)
|
||||
(img :src src :alt name :class "h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"))
|
||||
|
||||
(defcomp ~blog-filter-group-icon-color (&key style initial)
|
||||
(defcomp ~filters/group-icon-color (&key style initial)
|
||||
(div :class "h-6 w-6 rounded-full text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0" :style style initial))
|
||||
|
||||
(defcomp ~blog-filter-group-li (&key cls hx-get hx-select icon name count)
|
||||
(defcomp ~filters/group-li (&key cls hx-get hx-select icon name count)
|
||||
(li (a :class (str "flex items-center gap-2 px-3 py-1 rounded border " cls)
|
||||
:sx-get hx-get :sx-target "#main-panel" :sx-select hx-select
|
||||
:sx-swap "outerHTML" :sx-push-url "true"
|
||||
@@ -40,19 +40,19 @@
|
||||
(span :class "flex-1")
|
||||
(span :class "inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200" count))))
|
||||
|
||||
(defcomp ~blog-filter-nav (&key items)
|
||||
(defcomp ~filters/nav (&key items)
|
||||
(nav :class "max-w-3xl mx-auto px-4 pb-4 flex flex-wrap gap-2 text-sm"
|
||||
(ul :class "divide-y flex flex-col gap-3" items)))
|
||||
|
||||
(defcomp ~blog-filter-any-author (&key cls hx-select)
|
||||
(defcomp ~filters/any-author (&key cls hx-select)
|
||||
(li (a :class (str "px-3 py-1 rounded " cls)
|
||||
:sx-get "?page=1" :sx-target "#main-panel" :sx-select hx-select
|
||||
:sx-swap "outerHTML" :sx-push-url "true" "Any author")))
|
||||
|
||||
(defcomp ~blog-filter-author-icon (&key src name)
|
||||
(defcomp ~filters/author-icon (&key src name)
|
||||
(img :src src :alt name :class "h-5 w-5 rounded-full object-cover"))
|
||||
|
||||
(defcomp ~blog-filter-author-li (&key cls hx-get hx-select icon name count)
|
||||
(defcomp ~filters/author-li (&key cls hx-get hx-select icon name count)
|
||||
(li (a :class (str "flex items-center gap-2 px-3 py-1 rounded " cls)
|
||||
:sx-get hx-get :sx-target "#main-panel" :sx-select hx-select
|
||||
:sx-swap "outerHTML" :sx-push-url "true"
|
||||
@@ -61,5 +61,41 @@
|
||||
(span :class "flex-1")
|
||||
(span :class "inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200" count))))
|
||||
|
||||
(defcomp ~blog-filter-summary (&key text)
|
||||
(defcomp ~filters/summary (&key (text :as string))
|
||||
(span :class "text-sm text-stone-600" text))
|
||||
|
||||
;; Data-driven tag groups filter (replaces Python _tag_groups_filter_sx loop)
|
||||
(defcomp ~filters/tag-groups-filter-from-data (&key groups selected-groups hx-select)
|
||||
(let* ((is-any (empty? (or selected-groups (list))))
|
||||
(any-cls (if is-any "bg-stone-900 text-white border-stone-900" "bg-white text-stone-600 border-stone-300 hover:bg-stone-50")))
|
||||
(~filters/nav
|
||||
:items (<>
|
||||
(~filters/any-topic :cls any-cls :hx-select hx-select)
|
||||
(map (lambda (g)
|
||||
(let* ((slug (get g "slug"))
|
||||
(name (get g "name"))
|
||||
(is-on (contains? selected-groups slug))
|
||||
(cls (if is-on "bg-stone-900 text-white border-stone-900" "bg-white text-stone-600 border-stone-300 hover:bg-stone-50"))
|
||||
(icon (if (get g "feature_image")
|
||||
(~filters/group-icon-image :src (get g "feature_image") :name name)
|
||||
(~filters/group-icon-color :style (get g "style") :initial (get g "initial")))))
|
||||
(~filters/group-li :cls cls :hx-get (str "?group=" slug "&page=1") :hx-select hx-select
|
||||
:icon icon :name name :count (get g "count"))))
|
||||
(or groups (list)))))))
|
||||
|
||||
;; Data-driven authors filter (replaces Python _authors_filter_sx loop)
|
||||
(defcomp ~filters/authors-filter-from-data (&key authors selected-authors hx-select)
|
||||
(let* ((is-any (empty? (or selected-authors (list))))
|
||||
(any-cls (if is-any "bg-stone-900 text-white border-stone-900" "bg-white text-stone-600 border-stone-300 hover:bg-stone-50")))
|
||||
(~filters/nav
|
||||
:items (<>
|
||||
(~filters/any-author :cls any-cls :hx-select hx-select)
|
||||
(map (lambda (a)
|
||||
(let* ((slug (get a "slug"))
|
||||
(is-on (contains? selected-authors slug))
|
||||
(cls (if is-on "bg-stone-900 text-white border-stone-900" "bg-white text-stone-600 border-stone-300 hover:bg-stone-50"))
|
||||
(icon (when (get a "profile_image")
|
||||
(~filters/author-icon :src (get a "profile_image") :name (get a "name")))))
|
||||
(~filters/author-li :cls cls :hx-get (str "?author=" slug "&page=1") :hx-select hx-select
|
||||
:icon icon :name (get a "name") :count (get a "count"))))
|
||||
(or authors (list)))))))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
;; Blog link-card fragment handler
|
||||
;; returns: sx
|
||||
;;
|
||||
;; Renders link-card(s) for blog posts by slug.
|
||||
;; Supports single mode (?slug=x) and batch mode (?keys=x,y,z).
|
||||
@@ -10,7 +11,7 @@
|
||||
(let ((post (query "blog" "post-by-slug" :slug (trim s))))
|
||||
(when post
|
||||
(<> (str "<!-- fragment:" (trim s) " -->")
|
||||
(~link-card
|
||||
(~shared:fragments/link-card
|
||||
:link (app-url "blog" (str "/" (get post "slug") "/"))
|
||||
:title (get post "title")
|
||||
:image (get post "feature_image")
|
||||
@@ -21,7 +22,7 @@
|
||||
(when slug
|
||||
(let ((post (query "blog" "post-by-slug" :slug slug)))
|
||||
(when post
|
||||
(~link-card
|
||||
(~shared:fragments/link-card
|
||||
:link (app-url "blog" (str "/" (get post "slug") "/"))
|
||||
:title (get post "title")
|
||||
:image (get post "feature_image")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
;; Blog nav-tree fragment handler
|
||||
;; returns: sx
|
||||
;;
|
||||
;; Renders the full scrollable navigation menu bar with app icons.
|
||||
;; Uses nav-tree I/O primitive to fetch menu nodes from the blog DB.
|
||||
@@ -29,25 +30,25 @@
|
||||
(app-url "blog" (str "/" item-slug "/"))))
|
||||
(selected (or (= item-slug (or first-seg ""))
|
||||
(= item-slug app))))
|
||||
(~blog-nav-item-link
|
||||
(~shared:nav/blog-nav-item-link
|
||||
:href href
|
||||
:hx-get href
|
||||
:selected (if selected "true" "false")
|
||||
:nav-cls nav-cls
|
||||
:img (~img-or-placeholder
|
||||
:img (~shared:misc/img-or-placeholder
|
||||
:src (get item "feature_image")
|
||||
:alt (or (get item "label") item-slug)
|
||||
:size-cls "w-8 h-8 rounded-full object-cover flex-shrink-0")
|
||||
:label (or (get item "label") item-slug)))) items)
|
||||
|
||||
;; Hardcoded artdag link
|
||||
(~blog-nav-item-link
|
||||
(~shared:nav/blog-nav-item-link
|
||||
:href (app-url "artdag" "/")
|
||||
:hx-get (app-url "artdag" "/")
|
||||
:selected (if (or (= "artdag" (or first-seg ""))
|
||||
(= "artdag" app)) "true" "false")
|
||||
:nav-cls nav-cls
|
||||
:img (~img-or-placeholder
|
||||
:img (~shared:misc/img-or-placeholder
|
||||
:src nil :alt "art-dag"
|
||||
:size-cls "w-8 h-8 rounded-full object-cover flex-shrink-0")
|
||||
:label "art-dag")))
|
||||
@@ -68,8 +69,8 @@
|
||||
(right-hs (str "on click set #" cid ".scrollLeft to #" cid ".scrollLeft + 200")))
|
||||
|
||||
(if (empty? items)
|
||||
(~blog-nav-empty :wrapper-id "menu-items-nav-wrapper")
|
||||
(~scroll-nav-wrapper
|
||||
(~shared:nav/blog-nav-empty :wrapper-id "menu-items-nav-wrapper")
|
||||
(~shared:misc/scroll-nav-wrapper
|
||||
:wrapper-id "menu-items-nav-wrapper"
|
||||
:container-id cid
|
||||
:arrow-cls arrow-cls
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
;; Blog header components
|
||||
|
||||
(defcomp ~blog-container-nav (&key container-nav)
|
||||
(defcomp ~header/container-nav (&key container-nav)
|
||||
(div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
|
||||
:id "entries-calendars-nav-wrapper" container-nav))
|
||||
|
||||
(defcomp ~blog-admin-label ()
|
||||
(defcomp ~header/admin-label ()
|
||||
(<> (i :class "fa fa-shield-halved" :aria-hidden "true") " admin"))
|
||||
|
||||
(defcomp ~blog-admin-nav-item (&key href nav-btn-class label is-selected select-colours)
|
||||
(defcomp ~header/admin-nav-item (&key href nav-btn-class label is-selected select-colours)
|
||||
(div :class "relative nav-group"
|
||||
(a :href href
|
||||
:aria-selected (when is-selected "true")
|
||||
:class (str (or nav-btn-class "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3") " " (or select-colours ""))
|
||||
label)))
|
||||
|
||||
(defcomp ~blog-sub-settings-label (&key icon label)
|
||||
(defcomp ~header/sub-settings-label (&key icon label)
|
||||
(<> (i :class icon :aria-hidden "true") " " label))
|
||||
|
||||
(defcomp ~blog-sub-admin-label (&key icon label)
|
||||
(defcomp ~header/sub-admin-label (&key icon label)
|
||||
(<> (i :class icon :aria-hidden "true") (div label)))
|
||||
|
||||
231
blog/sx/index.sx
231
blog/sx/index.sx
@@ -1,9 +1,9 @@
|
||||
;; Blog index components
|
||||
|
||||
(defcomp ~blog-no-pages ()
|
||||
(defcomp ~index/no-pages ()
|
||||
(div :class "col-span-full mt-8 text-center text-stone-500" "No pages found."))
|
||||
|
||||
(defcomp ~blog-content-type-tabs (&key posts-href pages-href hx-select posts-cls pages-cls)
|
||||
(defcomp ~index/content-type-tabs (&key posts-href pages-href hx-select posts-cls pages-cls)
|
||||
(div :class "flex justify-center gap-1 px-3 pt-3"
|
||||
(a :href posts-href :sx-get posts-href :sx-target "#main-panel"
|
||||
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
|
||||
@@ -12,21 +12,242 @@
|
||||
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
|
||||
:class (str "px-4 py-1.5 rounded-t text-sm font-medium transition-colors " pages-cls) "Pages")))
|
||||
|
||||
(defcomp ~blog-main-panel-pages (&key tabs cards)
|
||||
(defcomp ~index/main-panel-pages (&key tabs cards)
|
||||
(<> tabs
|
||||
(div :class "max-w-full px-3 py-3 space-y-3" cards)
|
||||
(div :class "pb-8")))
|
||||
|
||||
(defcomp ~blog-main-panel-posts (&key tabs toggle grid-cls cards)
|
||||
(defcomp ~index/main-panel-posts (&key tabs toggle grid-cls cards)
|
||||
(<> tabs
|
||||
toggle
|
||||
(div :class grid-cls cards)
|
||||
(div :class "pb-8")))
|
||||
|
||||
(defcomp ~blog-aside (&key search action-buttons tag-groups-filter authors-filter)
|
||||
(defcomp ~index/aside (&key search action-buttons tag-groups-filter authors-filter)
|
||||
(<> search
|
||||
action-buttons
|
||||
(div :id "category-summary-desktop" :hxx-swap-oob "outerHTML"
|
||||
tag-groups-filter
|
||||
authors-filter)
|
||||
(div :id "filter-summary-desktop" :hxx-swap-oob "outerHTML")))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Data-driven composition defcomps — replace Python sx_components functions
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
;; Helper: CSS class for filter item based on selection state
|
||||
(defcomp ~index/filter-cls (&key is-on)
|
||||
;; Returns nothing — use inline (if is-on ...) instead
|
||||
nil)
|
||||
|
||||
;; Blog index main content — replaces _blog_main_panel_sx
|
||||
(defcomp ~index/main-content (&key content-type view cards page total-pages
|
||||
current-local-href hx-select blog-url-base)
|
||||
(let* ((posts-href (str blog-url-base "/index"))
|
||||
(pages-href (str posts-href "?type=pages"))
|
||||
(posts-cls (if (not (= content-type "pages"))
|
||||
"bg-stone-700 text-white" "bg-stone-100 text-stone-600 hover:bg-stone-200"))
|
||||
(pages-cls (if (= content-type "pages")
|
||||
"bg-stone-700 text-white" "bg-stone-100 text-stone-600 hover:bg-stone-200")))
|
||||
(if (= content-type "pages")
|
||||
;; Pages listing
|
||||
(~index/main-panel-pages
|
||||
:tabs (~index/content-type-tabs
|
||||
:posts-href posts-href :pages-href pages-href
|
||||
:hx-select hx-select :posts-cls posts-cls :pages-cls pages-cls)
|
||||
:cards (<>
|
||||
(map (lambda (card)
|
||||
(~cards/page-card
|
||||
:href (get card "href") :hx-select hx-select
|
||||
:title (get card "title")
|
||||
:has-calendar (get card "has_calendar")
|
||||
:has-market (get card "has_market")
|
||||
:pub-timestamp (get card "pub_timestamp")
|
||||
:feature-image (get card "feature_image")
|
||||
:excerpt (get card "excerpt")))
|
||||
(or cards (list)))
|
||||
(if (< page total-pages)
|
||||
(~shared:misc/sentinel-simple
|
||||
:id (str "sentinel-" page "-d")
|
||||
:next-url (str current-local-href
|
||||
(if (contains? current-local-href "?") "&" "?")
|
||||
"page=" (+ page 1)))
|
||||
(if (not (empty? (or cards (list))))
|
||||
(~shared:misc/end-of-results)
|
||||
(~index/no-pages)))))
|
||||
;; Posts listing
|
||||
(let* ((grid-cls (if (= view "tile")
|
||||
"max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4"
|
||||
"max-w-full px-3 py-3 space-y-3"))
|
||||
(list-href current-local-href)
|
||||
(tile-href (str current-local-href
|
||||
(if (contains? current-local-href "?") "&" "?") "view=tile"))
|
||||
(list-cls (if (not (= view "tile"))
|
||||
"bg-stone-200 text-stone-800"
|
||||
"text-stone-400 hover:text-stone-600"))
|
||||
(tile-cls (if (= view "tile")
|
||||
"bg-stone-200 text-stone-800"
|
||||
"text-stone-400 hover:text-stone-600")))
|
||||
(~index/main-panel-posts
|
||||
:tabs (~index/content-type-tabs
|
||||
:posts-href posts-href :pages-href pages-href
|
||||
:hx-select hx-select :posts-cls posts-cls :pages-cls pages-cls)
|
||||
:toggle (~shared:misc/view-toggle
|
||||
:list-href list-href :tile-href tile-href :hx-select hx-select
|
||||
:list-cls list-cls :tile-cls tile-cls :storage-key "blog_view"
|
||||
:list-svg (~shared:misc/list-svg) :tile-svg (~shared:misc/tile-svg))
|
||||
:grid-cls grid-cls
|
||||
:cards (<>
|
||||
(map (lambda (card)
|
||||
(if (= view "tile")
|
||||
(~cards/tile
|
||||
:href (get card "href") :hx-select hx-select
|
||||
:feature-image (get card "feature_image")
|
||||
:title (get card "title") :is-draft (get card "is_draft")
|
||||
:publish-requested (get card "publish_requested")
|
||||
:status-timestamp (get card "status_timestamp")
|
||||
:excerpt (get card "excerpt")
|
||||
:tags (get card "tags") :authors (get card "authors"))
|
||||
(~cards/index
|
||||
:slug (get card "slug") :href (get card "href") :hx-select hx-select
|
||||
:title (get card "title") :feature-image (get card "feature_image")
|
||||
:excerpt (get card "excerpt") :is-draft (get card "is_draft")
|
||||
:publish-requested (get card "publish_requested")
|
||||
:status-timestamp (get card "status_timestamp")
|
||||
:has-like (get card "has_like") :liked (get card "liked")
|
||||
:like-url (get card "like_url") :csrf-token (get card "csrf_token")
|
||||
:tags (get card "tags") :authors (get card "authors")
|
||||
:widget (get card "widget"))))
|
||||
(or cards (list)))
|
||||
(~index/sentinel
|
||||
:page page :total-pages total-pages
|
||||
:current-local-href current-local-href)))))))
|
||||
|
||||
;; Sentinel for blog index infinite scroll
|
||||
(defcomp ~index/sentinel (&key page total-pages current-local-href)
|
||||
(when (< page total-pages)
|
||||
(let* ((next-url (str current-local-href "?page=" (+ page 1))))
|
||||
(~shared:misc/sentinel-desktop
|
||||
:id (str "sentinel-" page "-d")
|
||||
:next-url next-url
|
||||
:hyperscript "init if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end on htmx:beforeRequest(event) add .hidden to .js-neterr in me remove .hidden from .js-loading in me remove .opacity-100 from me add .opacity-0 to me set trig to null if event.detail and event.detail.triggeringEvent then set trig to event.detail.triggeringEvent end if trig and trig.type is 'intersect' set scroller to the closest .js-grid-viewport if scroller is null then halt end if scroller.scrollTop < 20 then halt end end def backoff() set ms to me.dataset.retryMs if ms > 30000 then set ms to 30000 end add .hidden to .js-loading in me remove .hidden from .js-neterr in me remove .opacity-0 from me add .opacity-100 to me wait ms ms trigger sentinel:retry set ms to ms * 2 if ms > 30000 then set ms to 30000 end set me.dataset.retryMs to ms end on htmx:sendError call backoff() on htmx:responseError call backoff() on htmx:timeout call backoff()"))))
|
||||
|
||||
;; Blog index action buttons — replaces _action_buttons_sx
|
||||
(defcomp ~index/actions (&key is-admin has-user hx-select draft-count drafts
|
||||
new-post-href new-page-href current-local-href)
|
||||
(~filters/action-buttons-wrapper
|
||||
:inner (<>
|
||||
(when is-admin
|
||||
(<>
|
||||
(~filters/action-button
|
||||
:href new-post-href :hx-select hx-select
|
||||
:btn-class "px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"
|
||||
:title "New Post" :icon-class "fa fa-plus mr-1" :label " New Post")
|
||||
(~filters/action-button
|
||||
:href new-page-href :hx-select hx-select
|
||||
:btn-class "px-3 py-1 rounded bg-blue-600 text-white text-sm hover:bg-blue-700 transition-colors"
|
||||
:title "New Page" :icon-class "fa fa-plus mr-1" :label " New Page")))
|
||||
(when (and has-user (or draft-count drafts))
|
||||
(if drafts
|
||||
(~filters/drafts-button
|
||||
:href current-local-href :hx-select hx-select
|
||||
:btn-class "px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"
|
||||
:title "Hide Drafts" :label " Drafts " :draft-count (str draft-count))
|
||||
(let* ((on-href (str current-local-href
|
||||
(if (contains? current-local-href "?") "&" "?") "drafts=1")))
|
||||
(~filters/drafts-button-amber
|
||||
:href on-href :hx-select hx-select
|
||||
:btn-class "px-3 py-1 rounded bg-amber-600 text-white text-sm hover:bg-amber-700 transition-colors"
|
||||
:title "Show Drafts" :label " Drafts " :draft-count (str draft-count))))))))
|
||||
|
||||
;; Tag groups filter — replaces _tag_groups_filter_sx
|
||||
(defcomp ~index/tag-groups-filter (&key tag-groups is-any-group hx-select)
|
||||
(~filters/nav
|
||||
:items (<>
|
||||
(~filters/any-topic
|
||||
:cls (if is-any-group
|
||||
"bg-stone-900 text-white border-stone-900"
|
||||
"bg-white text-stone-600 border-stone-300 hover:bg-stone-50")
|
||||
:hx-select hx-select)
|
||||
(map (lambda (grp)
|
||||
(let* ((is-on (get grp "is_selected"))
|
||||
(cls (if is-on
|
||||
"bg-stone-900 text-white border-stone-900"
|
||||
"bg-white text-stone-600 border-stone-300 hover:bg-stone-50"))
|
||||
(fi (get grp "feature_image"))
|
||||
(colour (get grp "colour"))
|
||||
(name (get grp "name"))
|
||||
(icon (if fi
|
||||
(~filters/group-icon-image :src fi :name name)
|
||||
(~filters/group-icon-color
|
||||
:style (if colour
|
||||
(str "background-color: " colour "; color: white;")
|
||||
"background-color: #e7e5e4; color: #57534e;")
|
||||
:initial (slice (or name "?") 0 1)))))
|
||||
(~filters/group-li
|
||||
:cls cls :hx-get (str "?group=" (get grp "slug") "&page=1")
|
||||
:hx-select hx-select :icon icon
|
||||
:name name :count (str (get grp "post_count")))))
|
||||
(or tag-groups (list))))))
|
||||
|
||||
;; Authors filter — replaces _authors_filter_sx
|
||||
(defcomp ~index/authors-filter (&key authors is-any-author hx-select)
|
||||
(~filters/nav
|
||||
:items (<>
|
||||
(~filters/any-author
|
||||
:cls (if is-any-author
|
||||
"bg-stone-900 text-white border-stone-900"
|
||||
"bg-white text-stone-600 border-stone-300 hover:bg-stone-50")
|
||||
:hx-select hx-select)
|
||||
(map (lambda (a)
|
||||
(let* ((is-on (get a "is_selected"))
|
||||
(cls (if is-on
|
||||
"bg-stone-900 text-white border-stone-900"
|
||||
"bg-white text-stone-600 border-stone-300 hover:bg-stone-50"))
|
||||
(img (get a "profile_image")))
|
||||
(~filters/author-li
|
||||
:cls cls :hx-get (str "?author=" (get a "slug") "&page=1")
|
||||
:hx-select hx-select
|
||||
:icon (when img (~filters/author-icon :src img :name (get a "name")))
|
||||
:name (get a "name")
|
||||
:count (str (get a "published_post_count")))))
|
||||
(or authors (list))))))
|
||||
|
||||
;; Blog index aside — replaces _blog_aside_sx
|
||||
(defcomp ~index/aside-content (&key is-admin has-user hx-select draft-count drafts
|
||||
new-post-href new-page-href current-local-href
|
||||
tag-groups authors is-any-group is-any-author)
|
||||
(~index/aside
|
||||
:search (~shared:controls/search-desktop)
|
||||
:action-buttons (~index/actions
|
||||
:is-admin is-admin :has-user has-user :hx-select hx-select
|
||||
:draft-count draft-count :drafts drafts
|
||||
:new-post-href new-post-href :new-page-href new-page-href
|
||||
:current-local-href current-local-href)
|
||||
:tag-groups-filter (~index/tag-groups-filter
|
||||
:tag-groups tag-groups :is-any-group is-any-group :hx-select hx-select)
|
||||
:authors-filter (~index/authors-filter
|
||||
:authors authors :is-any-author is-any-author :hx-select hx-select)))
|
||||
|
||||
;; Blog index mobile filter — replaces _blog_filter_sx
|
||||
(defcomp ~index/filter-content (&key is-admin has-user hx-select draft-count drafts
|
||||
new-post-href new-page-href current-local-href
|
||||
tag-groups authors is-any-group is-any-author
|
||||
tg-summary au-summary)
|
||||
(~shared:controls/mobile-filter
|
||||
:filter-summary (<>
|
||||
(~shared:controls/search-mobile)
|
||||
(when (not (= tg-summary ""))
|
||||
(~filters/summary :text tg-summary))
|
||||
(when (not (= au-summary ""))
|
||||
(~filters/summary :text au-summary)))
|
||||
:action-buttons (~index/actions
|
||||
:is-admin is-admin :has-user has-user :hx-select hx-select
|
||||
:draft-count draft-count :drafts drafts
|
||||
:new-post-href new-post-href :new-page-href new-page-href
|
||||
:current-local-href current-local-href)
|
||||
:filter-details (<>
|
||||
(~index/tag-groups-filter
|
||||
:tag-groups tag-groups :is-any-group is-any-group :hx-select hx-select)
|
||||
(~index/authors-filter
|
||||
:authors authors :is-any-author is-any-author :hx-select hx-select))))
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Image card
|
||||
;; ---------------------------------------------------------------------------
|
||||
(defcomp ~kg-image (&key src alt caption width href)
|
||||
(defcomp ~kg_cards/kg-image (&key (src :as string) (alt :as string?) (caption :as string?) (width :as string?) (href :as string?))
|
||||
(figure :class (str "kg-card kg-image-card"
|
||||
(if (= width "wide") " kg-width-wide"
|
||||
(if (= width "full") " kg-width-full" "")))
|
||||
@@ -19,7 +19,7 @@
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Gallery card
|
||||
;; ---------------------------------------------------------------------------
|
||||
(defcomp ~kg-gallery (&key images caption)
|
||||
(defcomp ~kg_cards/kg-gallery (&key (images :as list) (caption :as string?))
|
||||
(figure :class "kg-card kg-gallery-card kg-width-wide"
|
||||
(div :class "kg-gallery-container"
|
||||
(map (lambda (row)
|
||||
@@ -36,19 +36,19 @@
|
||||
;; HTML card — wraps user-pasted HTML so the editor can identify the block.
|
||||
;; Content is native sx children (no longer an opaque HTML string).
|
||||
;; ---------------------------------------------------------------------------
|
||||
(defcomp ~kg-html (&rest children)
|
||||
(defcomp ~kg_cards/kg-html (&rest children)
|
||||
(div :class "kg-card kg-html-card" children))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Markdown card — rendered markdown content, editor can identify the block.
|
||||
;; ---------------------------------------------------------------------------
|
||||
(defcomp ~kg-md (&rest children)
|
||||
(defcomp ~kg_cards/kg-md (&rest children)
|
||||
(div :class "kg-card kg-md-card" children))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Embed card
|
||||
;; ---------------------------------------------------------------------------
|
||||
(defcomp ~kg-embed (&key html caption)
|
||||
(defcomp ~kg_cards/kg-embed (&key (html :as string) (caption :as string?))
|
||||
(figure :class "kg-card kg-embed-card"
|
||||
(~rich-text :html html)
|
||||
(when caption (figcaption caption))))
|
||||
@@ -56,7 +56,7 @@
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Bookmark card
|
||||
;; ---------------------------------------------------------------------------
|
||||
(defcomp ~kg-bookmark (&key url title description icon author publisher thumbnail caption)
|
||||
(defcomp ~kg_cards/kg-bookmark (&key (url :as string) (title :as string?) (description :as string?) (icon :as string?) (author :as string?) (publisher :as string?) (thumbnail :as string?) (caption :as string?))
|
||||
(figure :class "kg-card kg-bookmark-card"
|
||||
(a :class "kg-bookmark-container" :href url
|
||||
(div :class "kg-bookmark-content"
|
||||
@@ -75,7 +75,7 @@
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Callout card
|
||||
;; ---------------------------------------------------------------------------
|
||||
(defcomp ~kg-callout (&key color emoji content)
|
||||
(defcomp ~kg_cards/kg-callout (&key (color :as string?) (emoji :as string?) (content :as string?))
|
||||
(div :class (str "kg-card kg-callout-card kg-callout-card-" (or color "grey"))
|
||||
(when emoji (div :class "kg-callout-emoji" emoji))
|
||||
(div :class "kg-callout-text" (or content ""))))
|
||||
@@ -83,14 +83,14 @@
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Button card
|
||||
;; ---------------------------------------------------------------------------
|
||||
(defcomp ~kg-button (&key url text alignment)
|
||||
(defcomp ~kg_cards/kg-button (&key (url :as string) (text :as string?) (alignment :as string?))
|
||||
(div :class (str "kg-card kg-button-card kg-align-" (or alignment "center"))
|
||||
(a :href url :class "kg-btn kg-btn-accent" (or text ""))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Toggle card (accordion)
|
||||
;; ---------------------------------------------------------------------------
|
||||
(defcomp ~kg-toggle (&key heading content)
|
||||
(defcomp ~kg_cards/kg-toggle (&key (heading :as string?) (content :as string?))
|
||||
(div :class "kg-card kg-toggle-card" :data-kg-toggle-state "close"
|
||||
(div :class "kg-toggle-heading"
|
||||
(h4 :class "kg-toggle-heading-text" (or heading ""))
|
||||
@@ -101,7 +101,7 @@
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Audio card
|
||||
;; ---------------------------------------------------------------------------
|
||||
(defcomp ~kg-audio (&key src title duration thumbnail)
|
||||
(defcomp ~kg_cards/kg-audio (&key (src :as string) (title :as string?) (duration :as string?) (thumbnail :as string?))
|
||||
(div :class "kg-card kg-audio-card"
|
||||
(if thumbnail
|
||||
(img :src thumbnail :alt "audio-thumbnail" :class "kg-audio-thumbnail")
|
||||
@@ -124,7 +124,7 @@
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Video card
|
||||
;; ---------------------------------------------------------------------------
|
||||
(defcomp ~kg-video (&key src caption width thumbnail loop)
|
||||
(defcomp ~kg_cards/kg-video (&key (src :as string) (caption :as string?) (width :as string?) (thumbnail :as string?) (loop :as boolean?))
|
||||
(figure :class (str "kg-card kg-video-card"
|
||||
(if (= width "wide") " kg-width-wide"
|
||||
(if (= width "full") " kg-width-full" "")))
|
||||
@@ -136,7 +136,7 @@
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; File card
|
||||
;; ---------------------------------------------------------------------------
|
||||
(defcomp ~kg-file (&key src filename title filesize caption)
|
||||
(defcomp ~kg_cards/kg-file (&key (src :as string) (filename :as string?) (title :as string?) (filesize :as string?) (caption :as string?))
|
||||
(div :class "kg-card kg-file-card"
|
||||
(a :class "kg-file-card-container" :href src :download (or filename "")
|
||||
(div :class "kg-file-card-contents"
|
||||
@@ -149,5 +149,5 @@
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Paywall marker
|
||||
;; ---------------------------------------------------------------------------
|
||||
(defcomp ~kg-paywall ()
|
||||
(defcomp ~kg_cards/kg-paywall ()
|
||||
(~rich-text :html "<!--members-only-->"))
|
||||
|
||||
185
blog/sx/layouts.sx
Normal file
185
blog/sx/layouts.sx
Normal file
@@ -0,0 +1,185 @@
|
||||
;; Blog layout defcomps — fully self-contained via IO primitives.
|
||||
;; Registered via register_sx_layout in __init__.py.
|
||||
|
||||
;; --- Blog header (invisible row for blog-header-child swap target) ---
|
||||
|
||||
(defcomp ~layouts/header (&key oob)
|
||||
(~shared:layout/menu-row-sx :id "blog-row" :level 1
|
||||
:link-label-content (div)
|
||||
:child-id "blog-header-child" :oob oob))
|
||||
|
||||
;; --- Auto-fetching settings header macro ---
|
||||
|
||||
(defmacro ~blog-settings-header-auto (oob)
|
||||
(quasiquote
|
||||
(~shared:layout/menu-row-sx :id "root-settings-row" :level 1
|
||||
:link-href (url-for "settings.defpage_settings_home")
|
||||
:link-label-content (~header/admin-label)
|
||||
:nav (~layouts/settings-nav)
|
||||
:child-id "root-settings-header-child"
|
||||
:oob (unquote oob))))
|
||||
|
||||
;; --- Auto-fetching sub-settings header macro ---
|
||||
|
||||
(defmacro ~blog-sub-settings-header-auto (row-id child-id endpoint icon label oob)
|
||||
(quasiquote
|
||||
(~shared:layout/menu-row-sx :id (unquote row-id) :level 2
|
||||
:link-href (url-for (unquote endpoint))
|
||||
:link-label-content (~header/sub-settings-label
|
||||
:icon (str "fa fa-" (unquote icon))
|
||||
:label (unquote label))
|
||||
:child-id (unquote child-id)
|
||||
:oob (unquote oob))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Blog layout (root + blog header)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~layouts/full ()
|
||||
(<> (~root-header-auto)
|
||||
(~layouts/header)))
|
||||
|
||||
(defcomp ~layouts/oob ()
|
||||
(<> (~layouts/header :oob true)
|
||||
(~shared:layout/clear-oob-div :id "blog-header-child")
|
||||
(~root-header-auto true)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Settings layout (root + settings header)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~layouts/settings-layout-full ()
|
||||
(<> (~root-header-auto)
|
||||
(~blog-settings-header-auto)))
|
||||
|
||||
(defcomp ~layouts/settings-layout-oob ()
|
||||
(<> (~blog-settings-header-auto true)
|
||||
(~shared:layout/clear-oob-div :id "root-settings-header-child")
|
||||
(~root-header-auto true)))
|
||||
|
||||
(defcomp ~layouts/settings-layout-mobile ()
|
||||
(~layouts/settings-nav))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Cache layout (root + settings + cache sub-header)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~layouts/cache-layout-full ()
|
||||
(<> (~root-header-auto)
|
||||
(~blog-settings-header-auto)
|
||||
(~blog-sub-settings-header-auto
|
||||
"cache-row" "cache-header-child"
|
||||
"settings.defpage_cache_page" "refresh" "Cache")))
|
||||
|
||||
(defcomp ~layouts/cache-layout-oob ()
|
||||
(<> (~blog-sub-settings-header-auto
|
||||
"cache-row" "cache-header-child"
|
||||
"settings.defpage_cache_page" "refresh" "Cache" true)
|
||||
(~shared:layout/clear-oob-div :id "cache-header-child")
|
||||
(~blog-settings-header-auto true)
|
||||
(~root-header-auto true)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Snippets layout (root + settings + snippets sub-header)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~layouts/snippets-layout-full ()
|
||||
(<> (~root-header-auto)
|
||||
(~blog-settings-header-auto)
|
||||
(~blog-sub-settings-header-auto
|
||||
"snippets-row" "snippets-header-child"
|
||||
"snippets.defpage_snippets_page" "puzzle-piece" "Snippets")))
|
||||
|
||||
(defcomp ~layouts/snippets-layout-oob ()
|
||||
(<> (~blog-sub-settings-header-auto
|
||||
"snippets-row" "snippets-header-child"
|
||||
"snippets.defpage_snippets_page" "puzzle-piece" "Snippets" true)
|
||||
(~shared:layout/clear-oob-div :id "snippets-header-child")
|
||||
(~blog-settings-header-auto true)
|
||||
(~root-header-auto true)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Menu Items layout (root + settings + menu-items sub-header)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~layouts/menu-items-layout-full ()
|
||||
(<> (~root-header-auto)
|
||||
(~blog-settings-header-auto)
|
||||
(~blog-sub-settings-header-auto
|
||||
"menu_items-row" "menu_items-header-child"
|
||||
"menu_items.defpage_menu_items_page" "bars" "Menu Items")))
|
||||
|
||||
(defcomp ~layouts/menu-items-layout-oob ()
|
||||
(<> (~blog-sub-settings-header-auto
|
||||
"menu_items-row" "menu_items-header-child"
|
||||
"menu_items.defpage_menu_items_page" "bars" "Menu Items" true)
|
||||
(~shared:layout/clear-oob-div :id "menu_items-header-child")
|
||||
(~blog-settings-header-auto true)
|
||||
(~root-header-auto true)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Tag Groups layout (root + settings + tag-groups sub-header)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~layouts/tag-groups-layout-full ()
|
||||
(<> (~root-header-auto)
|
||||
(~blog-settings-header-auto)
|
||||
(~blog-sub-settings-header-auto
|
||||
"tag-groups-row" "tag-groups-header-child"
|
||||
"blog.tag_groups_admin.defpage_tag_groups_page" "tags" "Tag Groups")))
|
||||
|
||||
(defcomp ~layouts/tag-groups-layout-oob ()
|
||||
(<> (~blog-sub-settings-header-auto
|
||||
"tag-groups-row" "tag-groups-header-child"
|
||||
"blog.tag_groups_admin.defpage_tag_groups_page" "tags" "Tag Groups" true)
|
||||
(~shared:layout/clear-oob-div :id "tag-groups-header-child")
|
||||
(~blog-settings-header-auto true)
|
||||
(~root-header-auto true)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Tag Group Edit layout (root + settings + tag-groups sub-header with id)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~layouts/tag-group-edit-layout-full ()
|
||||
(<> (~root-header-auto)
|
||||
(~blog-settings-header-auto)
|
||||
(~shared:layout/menu-row-sx :id "tag-groups-row" :level 2
|
||||
:link-href (url-for "blog.tag_groups_admin.defpage_tag_group_edit"
|
||||
:id (request-view-args "id"))
|
||||
:link-label-content (~header/sub-settings-label
|
||||
:icon "fa fa-tags" :label "Tag Groups")
|
||||
:child-id "tag-groups-header-child")))
|
||||
|
||||
(defcomp ~layouts/tag-group-edit-layout-oob ()
|
||||
(<> (~shared:layout/menu-row-sx :id "tag-groups-row" :level 2
|
||||
:link-href (url-for "blog.tag_groups_admin.defpage_tag_group_edit"
|
||||
:id (request-view-args "id"))
|
||||
:link-label-content (~header/sub-settings-label
|
||||
:icon "fa fa-tags" :label "Tag Groups")
|
||||
:child-id "tag-groups-header-child"
|
||||
:oob true)
|
||||
(~shared:layout/clear-oob-div :id "tag-groups-header-child")
|
||||
(~blog-settings-header-auto true)
|
||||
(~root-header-auto true)))
|
||||
|
||||
;; --- Settings nav links — uses IO primitives ---
|
||||
|
||||
(defcomp ~layouts/settings-nav ()
|
||||
(let* ((sc (select-colours))
|
||||
(links (list
|
||||
(dict :endpoint "menu_items.defpage_menu_items_page" :icon "fa fa-bars" :label "Menu Items")
|
||||
(dict :endpoint "snippets.defpage_snippets_page" :icon "fa fa-puzzle-piece" :label "Snippets")
|
||||
(dict :endpoint "blog.tag_groups_admin.defpage_tag_groups_page" :icon "fa fa-tags" :label "Tag Groups")
|
||||
(dict :endpoint "settings.defpage_cache_page" :icon "fa fa-refresh" :label "Cache"))))
|
||||
(<> (map (lambda (lnk)
|
||||
(~shared:layout/nav-link
|
||||
:href (url-for (get lnk "endpoint"))
|
||||
:icon (get lnk "icon")
|
||||
:label (get lnk "label")
|
||||
:select-colours (or sc "")))
|
||||
links))))
|
||||
|
||||
;; --- Editor panel wrapper ---
|
||||
|
||||
(defcomp ~layouts/editor-panel (&key parts)
|
||||
(<> parts))
|
||||
@@ -1,6 +1,6 @@
|
||||
;; Menu item form and page search components
|
||||
|
||||
(defcomp ~page-search-item (&key id title slug feature-image)
|
||||
(defcomp ~menu_items/page-search-item (&key id title slug feature-image)
|
||||
(div :class "flex items-center gap-3 p-3 hover:bg-stone-50 cursor-pointer border-b last:border-b-0"
|
||||
:data-page-id id :data-page-title title :data-page-slug slug
|
||||
:data-page-image (or feature-image "")
|
||||
@@ -11,16 +11,50 @@
|
||||
(div :class "font-medium truncate" title)
|
||||
(div :class "text-xs text-stone-500 truncate" slug))))
|
||||
|
||||
(defcomp ~page-search-results (&key items sentinel)
|
||||
(defcomp ~menu_items/page-search-results (&key items sentinel)
|
||||
(div :class "border border-stone-200 rounded-md max-h-64 overflow-y-auto"
|
||||
items sentinel))
|
||||
|
||||
(defcomp ~page-search-sentinel (&key url query next-page)
|
||||
(defcomp ~menu_items/page-search-sentinel (&key url query next-page)
|
||||
(div :sx-get url :sx-trigger "intersect once" :sx-swap "outerHTML"
|
||||
:sx-vals (str "{\"q\": \"" query "\", \"page\": " next-page "}")
|
||||
:class "p-3 text-center text-sm text-stone-400"
|
||||
(i :class "fa fa-spinner fa-spin") " Loading more..."))
|
||||
|
||||
(defcomp ~page-search-empty (&key query)
|
||||
(defcomp ~menu_items/page-search-empty (&key query)
|
||||
(div :class "p-3 text-center text-stone-400 border border-stone-200 rounded-md"
|
||||
(str "No pages found matching \"" query "\"")))
|
||||
|
||||
;; Data-driven page search results (replaces Python render_page_search_results loop)
|
||||
(defcomp ~menu_items/page-search-results-from-data (&key pages query has-more search-url next-page)
|
||||
(if (and (not pages) query)
|
||||
(~menu_items/page-search-empty :query query)
|
||||
(when pages
|
||||
(~menu_items/page-search-results
|
||||
:items (<> (map (lambda (p)
|
||||
(~menu_items/page-search-item
|
||||
:id (get p "id") :title (get p "title")
|
||||
:slug (get p "slug") :feature-image (get p "feature_image")))
|
||||
pages))
|
||||
:sentinel (when has-more
|
||||
(~menu_items/page-search-sentinel :url search-url :query query :next-page next-page))))))
|
||||
|
||||
;; Data-driven menu nav items (replaces Python render_menu_items_nav_oob loop)
|
||||
(defcomp ~menu_items/menu-nav-from-data (&key items nav-cls container-id arrow-cls scroll-hs)
|
||||
(if (not items)
|
||||
(~shared:nav/blog-nav-empty :wrapper-id "menu-items-nav-wrapper")
|
||||
(~shared:misc/scroll-nav-wrapper :wrapper-id "menu-items-nav-wrapper" :container-id container-id
|
||||
:arrow-cls arrow-cls
|
||||
:left-hs (str "on click set #" container-id ".scrollLeft to #" container-id ".scrollLeft - 200")
|
||||
:scroll-hs scroll-hs
|
||||
:right-hs (str "on click set #" container-id ".scrollLeft to #" container-id ".scrollLeft + 200")
|
||||
:items (<> (map (lambda (item)
|
||||
(let* ((img (~shared:misc/img-or-placeholder :src (get item "feature_image") :alt (get item "label")
|
||||
:size-cls "w-8 h-8 rounded-full object-cover flex-shrink-0")))
|
||||
(if (= (get item "slug") "cart")
|
||||
(~shared:nav/blog-nav-item-plain :href (get item "href") :selected (get item "selected")
|
||||
:nav-cls nav-cls :img img :label (get item "label"))
|
||||
(~shared:nav/blog-nav-item-link :href (get item "href") :hx-get (get item "hx_get")
|
||||
:selected (get item "selected") :nav-cls nav-cls :img img :label (get item "label")))))
|
||||
items))
|
||||
:oob true)))
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
;; Blog settings panel components (features, markets, associated entries)
|
||||
|
||||
(defcomp ~blog-features-form (&key features-url calendar-checked market-checked hs-trigger)
|
||||
(defcomp ~settings/features-form (&key (features-url :as string) (calendar-checked :as boolean) (market-checked :as boolean) (hs-trigger :as string))
|
||||
(form :sx-put features-url :sx-target "#features-panel" :sx-swap "outerHTML"
|
||||
:sx-headers "{\"Content-Type\": \"application/json\"}" :sx-encoding "json" :class "space-y-3"
|
||||
:sx-headers {:Content-Type "application/json"} :sx-encoding "json" :class "space-y-3"
|
||||
(label :class "flex items-center gap-3 cursor-pointer"
|
||||
(input :type "checkbox" :name "calendar" :value "true" :checked calendar-checked
|
||||
:class "h-5 w-5 rounded border-stone-300 text-blue-600 focus:ring-blue-500"
|
||||
@@ -18,33 +18,33 @@
|
||||
(i :class "fa fa-shopping-bag text-green-600 mr-1")
|
||||
" Market \u2014 enable product catalog on this page"))))
|
||||
|
||||
(defcomp ~blog-sumup-form (&key sumup-url merchant-code placeholder sumup-configured checkout-prefix)
|
||||
(defcomp ~settings/sumup-form (&key sumup-url merchant-code placeholder sumup-configured checkout-prefix)
|
||||
(div :class "mt-4 pt-4 border-t border-stone-100"
|
||||
(~sumup-settings-form :update-url sumup-url :merchant-code merchant-code
|
||||
(~shared:misc/sumup-settings-form :update-url sumup-url :merchant-code merchant-code
|
||||
:placeholder placeholder :sumup-configured sumup-configured
|
||||
:checkout-prefix checkout-prefix :panel-id "features-panel")))
|
||||
|
||||
(defcomp ~blog-features-panel (&key form sumup)
|
||||
(defcomp ~settings/features-panel (&key form sumup)
|
||||
(div :id "features-panel" :class "space-y-4 p-4 bg-white rounded-lg border border-stone-200"
|
||||
(h3 :class "text-lg font-semibold text-stone-800" "Page Features")
|
||||
form sumup))
|
||||
|
||||
;; Markets panel
|
||||
|
||||
(defcomp ~blog-market-item (&key name slug delete-url confirm-text)
|
||||
(defcomp ~settings/market-item (&key (name :as string) (slug :as string) (delete-url :as string) (confirm-text :as string))
|
||||
(li :class "flex items-center justify-between p-3 bg-stone-50 rounded"
|
||||
(div (span :class "font-medium" name)
|
||||
(span :class "text-stone-400 text-sm ml-2" (str "/" slug "/")))
|
||||
(button :sx-delete delete-url :sx-target "#markets-panel" :sx-swap "outerHTML"
|
||||
:sx-confirm confirm-text :class "text-red-600 hover:text-red-800 text-sm" "Delete")))
|
||||
|
||||
(defcomp ~blog-markets-list (&key items)
|
||||
(defcomp ~settings/markets-list (&key items)
|
||||
(ul :class "space-y-2 mb-4" items))
|
||||
|
||||
(defcomp ~blog-markets-empty ()
|
||||
(defcomp ~settings/markets-empty ()
|
||||
(p :class "text-stone-500 mb-4 text-sm" "No markets yet."))
|
||||
|
||||
(defcomp ~blog-markets-panel (&key list create-url)
|
||||
(defcomp ~settings/markets-panel (&key list create-url)
|
||||
(div :id "markets-panel"
|
||||
(h3 :class "text-lg font-semibold mb-3" "Markets")
|
||||
list
|
||||
@@ -54,13 +54,50 @@
|
||||
(button :type "submit"
|
||||
:class "bg-stone-800 text-white px-4 py-1.5 rounded text-sm hover:bg-stone-700" "Create"))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Data-driven composition defcomps — replace Python render_* functions
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
;; Features panel composition — replaces render_features_panel
|
||||
(defcomp ~settings/features-panel-content (&key features-url calendar-checked market-checked
|
||||
show-sumup sumup-url merchant-code placeholder
|
||||
sumup-configured checkout-prefix)
|
||||
(~settings/features-panel
|
||||
:form (~settings/features-form
|
||||
:features-url features-url
|
||||
:calendar-checked calendar-checked
|
||||
:market-checked market-checked
|
||||
:hs-trigger "on change trigger submit on closest <form/>")
|
||||
:sumup (when show-sumup
|
||||
(~settings/sumup-form
|
||||
:sumup-url sumup-url
|
||||
:merchant-code merchant-code
|
||||
:placeholder placeholder
|
||||
:sumup-configured sumup-configured
|
||||
:checkout-prefix checkout-prefix))))
|
||||
|
||||
;; Markets panel composition — replaces render_markets_panel
|
||||
(defcomp ~settings/markets-panel-content (&key markets create-url)
|
||||
(~settings/markets-panel
|
||||
:list (if (empty? (or markets (list)))
|
||||
(~settings/markets-empty)
|
||||
(~settings/markets-list
|
||||
:items (map (lambda (m)
|
||||
(~settings/market-item
|
||||
:name (get m "name")
|
||||
:slug (get m "slug")
|
||||
:delete-url (get m "delete_url")
|
||||
:confirm-text (str "Delete market '" (get m "name") "'?")))
|
||||
(or markets (list)))))
|
||||
:create-url create-url))
|
||||
|
||||
;; Associated entries
|
||||
|
||||
(defcomp ~blog-entry-image (&key src title)
|
||||
(defcomp ~settings/entry-image (&key (src :as string?) (title :as string))
|
||||
(if src (img :src src :alt title :class "w-8 h-8 rounded-full object-cover flex-shrink-0")
|
||||
(div :class "w-8 h-8 rounded-full bg-stone-200 flex-shrink-0")))
|
||||
|
||||
(defcomp ~blog-associated-entry (&key confirm-text toggle-url hx-headers img name date-str)
|
||||
(defcomp ~settings/associated-entry (&key (confirm-text :as string) (toggle-url :as string) hx-headers img (name :as string) (date-str :as string))
|
||||
(button :type "button"
|
||||
:class "w-full text-left p-3 rounded border bg-green-50 border-green-300 transition hover:bg-green-100"
|
||||
:data-confirm "" :data-confirm-title "Remove entry?"
|
||||
@@ -78,14 +115,178 @@
|
||||
(div :class "text-xs text-stone-600 mt-1" date-str))
|
||||
(i :class "fa fa-times-circle text-green-600 text-lg flex-shrink-0"))))
|
||||
|
||||
(defcomp ~blog-associated-entries-content (&key items)
|
||||
(defcomp ~settings/associated-entries-content (&key items)
|
||||
(div :class "space-y-1" items))
|
||||
|
||||
(defcomp ~blog-associated-entries-empty ()
|
||||
(defcomp ~settings/associated-entries-empty ()
|
||||
(div :class "text-sm text-stone-400"
|
||||
"No entries associated yet. Browse calendars below to add entries."))
|
||||
|
||||
(defcomp ~blog-associated-entries-panel (&key content)
|
||||
(defcomp ~settings/associated-entries-panel (&key content)
|
||||
(div :id "associated-entries-list" :class "border rounded-lg p-4 bg-white"
|
||||
(h3 :class "text-lg font-semibold mb-4" "Associated Entries")
|
||||
content))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Associated entries composition — replaces _render_associated_entries
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~settings/associated-entries-from-data (&key entries csrf)
|
||||
(~settings/associated-entries-panel
|
||||
:content (if (empty? (or entries (list)))
|
||||
(~settings/associated-entries-empty)
|
||||
(~settings/associated-entries-content
|
||||
:items (map (lambda (e)
|
||||
(~settings/associated-entry
|
||||
:confirm-text (get e "confirm_text")
|
||||
:toggle-url (get e "toggle_url")
|
||||
:hx-headers {:X-CSRFToken csrf}
|
||||
:img (~settings/entry-image :src (get e "cal_image") :title (get e "cal_title"))
|
||||
:name (get e "name")
|
||||
:date-str (get e "date_str")))
|
||||
(or entries (list)))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Entries browser composition — replaces _h_post_entries_content
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~settings/calendar-browser-item (&key (name :as string) (title :as string) (image :as string?) (view-url :as string))
|
||||
(details :class "border rounded-lg bg-white" :data-toggle-group "calendar-browser"
|
||||
(summary :class "p-4 cursor-pointer hover:bg-stone-50 flex items-center gap-3"
|
||||
(if image
|
||||
(img :src image :alt title :class "w-12 h-12 rounded object-cover flex-shrink-0")
|
||||
(div :class "w-12 h-12 rounded bg-stone-200 flex-shrink-0"))
|
||||
(div :class "flex-1"
|
||||
(div :class "font-semibold flex items-center gap-2"
|
||||
(i :class "fa fa-calendar text-stone-500") " " name)
|
||||
(div :class "text-sm text-stone-600" title)))
|
||||
(div :class "p-4 border-t" :sx-get view-url :sx-trigger "intersect once" :sx-swap "innerHTML"
|
||||
(div :class "text-sm text-stone-400" "Loading calendar..."))))
|
||||
|
||||
(defcomp ~settings/entries-browser-content (&key entries-panel calendars)
|
||||
(div :id "post-entries-content" :class "space-y-6 p-4"
|
||||
entries-panel
|
||||
(div :class "space-y-3"
|
||||
(h3 :class "text-lg font-semibold" "Browse Calendars")
|
||||
(if (empty? (or calendars (list)))
|
||||
(div :class "text-sm text-stone-400" "No calendars found.")
|
||||
(map (lambda (cal)
|
||||
(~settings/calendar-browser-item
|
||||
:name (get cal "name")
|
||||
:title (get cal "title")
|
||||
:image (get cal "image")
|
||||
:view-url (get cal "view_url")))
|
||||
(or calendars (list)))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Post settings form composition — replaces _h_post_settings_content
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~settings/field-label (&key (text :as string) (field-for :as string))
|
||||
(label :for field-for
|
||||
:class "block text-[13px] font-medium text-stone-500 mb-[4px]" text))
|
||||
|
||||
(defcomp ~settings/section (&key (title :as string) content (is-open :as boolean))
|
||||
(details :class "border border-stone-200 rounded-[8px] overflow-hidden" :open is-open
|
||||
(summary :class "px-[16px] py-[10px] bg-stone-50 text-[14px] font-medium text-stone-600 cursor-pointer select-none hover:bg-stone-100 transition-colors"
|
||||
title)
|
||||
(div :class "px-[16px] py-[12px] space-y-[12px]" content)))
|
||||
|
||||
(defcomp ~settings/form-content (&key csrf updated-at is-page save-success
|
||||
slug published-at featured visibility email-only
|
||||
tags feature-image-alt
|
||||
meta-title meta-description canonical-url
|
||||
og-title og-description og-image
|
||||
twitter-title twitter-description twitter-image
|
||||
custom-template)
|
||||
(let* ((input-cls "w-full text-[14px] rounded-[6px] border border-stone-200 px-[10px] py-[7px] bg-white text-stone-700 placeholder:text-stone-300 focus:outline-none focus:border-stone-400 focus:ring-1 focus:ring-stone-300")
|
||||
(textarea-cls (str input-cls " resize-y"))
|
||||
(slug-placeholder (if is-page "page-slug" "post-slug"))
|
||||
(tmpl-placeholder (if is-page "custom-page.hbs" "custom-post.hbs"))
|
||||
(featured-label (if is-page "Featured page" "Featured post")))
|
||||
(form :method "post" :class "max-w-[640px] mx-auto pb-[48px] px-[16px]"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(input :type "hidden" :name "updated_at" :value (or updated-at ""))
|
||||
(div :class "space-y-[12px] mt-[16px]"
|
||||
;; General
|
||||
(~settings/section :title "General" :is-open true :content
|
||||
(<>
|
||||
(div (~settings/field-label :text "Slug" :field-for "settings-slug")
|
||||
(input :type "text" :name "slug" :id "settings-slug" :value (or slug "")
|
||||
:placeholder slug-placeholder :class input-cls))
|
||||
(div (~settings/field-label :text "Published at" :field-for "settings-published_at")
|
||||
(input :type "datetime-local" :name "published_at" :id "settings-published_at"
|
||||
:value (or published-at "") :class input-cls))
|
||||
(div (label :class "inline-flex items-center gap-[8px] cursor-pointer"
|
||||
(input :type "checkbox" :name "featured" :id "settings-featured" :checked featured
|
||||
:class "rounded border-stone-300 text-stone-600 focus:ring-stone-300")
|
||||
(span :class "text-[14px] text-stone-600" featured-label)))
|
||||
(div (~settings/field-label :text "Visibility" :field-for "settings-visibility")
|
||||
(select :name "visibility" :id "settings-visibility" :class input-cls
|
||||
(option :value "public" :selected (= visibility "public") "Public")
|
||||
(option :value "members" :selected (= visibility "members") "Members")
|
||||
(option :value "paid" :selected (= visibility "paid") "Paid")))
|
||||
(div (label :class "inline-flex items-center gap-[8px] cursor-pointer"
|
||||
(input :type "checkbox" :name "email_only" :id "settings-email_only" :checked email-only
|
||||
:class "rounded border-stone-300 text-stone-600 focus:ring-stone-300")
|
||||
(span :class "text-[14px] text-stone-600" "Email only")))))
|
||||
;; Tags
|
||||
(~settings/section :title "Tags" :content
|
||||
(div (~settings/field-label :text "Tags (comma-separated)" :field-for "settings-tags")
|
||||
(input :type "text" :name "tags" :id "settings-tags" :value (or tags "")
|
||||
:placeholder "news, updates, featured" :class input-cls)
|
||||
(p :class "text-[12px] text-stone-400 mt-[4px]" "Unknown tags will be created automatically.")))
|
||||
;; Feature Image
|
||||
(~settings/section :title "Feature Image" :content
|
||||
(div (~settings/field-label :text "Alt text" :field-for "settings-feature_image_alt")
|
||||
(input :type "text" :name "feature_image_alt" :id "settings-feature_image_alt"
|
||||
:value (or feature-image-alt "") :placeholder "Describe the feature image" :class input-cls)))
|
||||
;; SEO / Meta
|
||||
(~settings/section :title "SEO / Meta" :content
|
||||
(<>
|
||||
(div (~settings/field-label :text "Meta title" :field-for "settings-meta_title")
|
||||
(input :type "text" :name "meta_title" :id "settings-meta_title" :value (or meta-title "")
|
||||
:placeholder "SEO title" :maxlength "300" :class input-cls)
|
||||
(p :class "text-[12px] text-stone-400 mt-[2px]" "Recommended: 70 characters. Max: 300."))
|
||||
(div (~settings/field-label :text "Meta description" :field-for "settings-meta_description")
|
||||
(textarea :name "meta_description" :id "settings-meta_description" :rows "2"
|
||||
:placeholder "SEO description" :maxlength "500" :class textarea-cls
|
||||
(or meta-description ""))
|
||||
(p :class "text-[12px] text-stone-400 mt-[2px]" "Recommended: 156 characters."))
|
||||
(div (~settings/field-label :text "Canonical URL" :field-for "settings-canonical_url")
|
||||
(input :type "url" :name "canonical_url" :id "settings-canonical_url"
|
||||
:value (or canonical-url "") :placeholder "https://example.com/original-post" :class input-cls))))
|
||||
;; Facebook / OpenGraph
|
||||
(~settings/section :title "Facebook / OpenGraph" :content
|
||||
(<>
|
||||
(div (~settings/field-label :text "OG title" :field-for "settings-og_title")
|
||||
(input :type "text" :name "og_title" :id "settings-og_title" :value (or og-title "") :class input-cls))
|
||||
(div (~settings/field-label :text "OG description" :field-for "settings-og_description")
|
||||
(textarea :name "og_description" :id "settings-og_description" :rows "2" :class textarea-cls
|
||||
(or og-description "")))
|
||||
(div (~settings/field-label :text "OG image URL" :field-for "settings-og_image")
|
||||
(input :type "url" :name "og_image" :id "settings-og_image" :value (or og-image "")
|
||||
:placeholder "https://..." :class input-cls))))
|
||||
;; X / Twitter
|
||||
(~settings/section :title "X / Twitter" :content
|
||||
(<>
|
||||
(div (~settings/field-label :text "Twitter title" :field-for "settings-twitter_title")
|
||||
(input :type "text" :name "twitter_title" :id "settings-twitter_title"
|
||||
:value (or twitter-title "") :class input-cls))
|
||||
(div (~settings/field-label :text "Twitter description" :field-for "settings-twitter_description")
|
||||
(textarea :name "twitter_description" :id "settings-twitter_description" :rows "2" :class textarea-cls
|
||||
(or twitter-description "")))
|
||||
(div (~settings/field-label :text "Twitter image URL" :field-for "settings-twitter_image")
|
||||
(input :type "url" :name "twitter_image" :id "settings-twitter_image"
|
||||
:value (or twitter-image "") :placeholder "https://..." :class input-cls))))
|
||||
;; Advanced
|
||||
(~settings/section :title "Advanced" :content
|
||||
(div (~settings/field-label :text "Custom template" :field-for "settings-custom_template")
|
||||
(input :type "text" :name "custom_template" :id "settings-custom_template"
|
||||
:value (or custom-template "") :placeholder tmpl-placeholder :class input-cls))))
|
||||
(div :class "flex items-center gap-[16px] mt-[24px] pt-[16px] border-t border-stone-200"
|
||||
(button :type "submit"
|
||||
:class "px-[20px] py-[6px] bg-stone-700 text-white text-[14px] rounded-[8px] hover:bg-stone-800 transition-colors cursor-pointer"
|
||||
"Save settings")
|
||||
(when save-success
|
||||
(span :class "text-[14px] text-green-600" "Saved."))))))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,11 @@
|
||||
"""Blog defpage setup — registers layouts, page helpers, and loads .sx pages."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def setup_blog_pages() -> None:
|
||||
"""Register blog-specific layouts, page helpers, and load page definitions."""
|
||||
from .layouts import _register_blog_layouts
|
||||
from .helpers import _register_blog_helpers
|
||||
_register_blog_layouts()
|
||||
_register_blog_helpers()
|
||||
_load_blog_page_files()
|
||||
@@ -14,265 +14,7 @@ def setup_blog_pages() -> None:
|
||||
def _load_blog_page_files() -> None:
|
||||
import os
|
||||
from shared.sx.pages import load_page_dir
|
||||
from shared.sx.jinja_bridge import load_service_components
|
||||
blog_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
load_service_components(blog_dir, service_name="blog")
|
||||
load_page_dir(os.path.dirname(__file__), "blog")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Layouts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _register_blog_layouts() -> None:
|
||||
from shared.sx.layouts import register_custom_layout
|
||||
# :blog — root + blog header (for new-post, new-page)
|
||||
register_custom_layout("blog", _blog_full, _blog_oob)
|
||||
# :blog-settings — root + settings header (with settings nav menu)
|
||||
register_custom_layout("blog-settings", _settings_full, _settings_oob,
|
||||
mobile_fn=_settings_mobile)
|
||||
# Sub-settings layouts (root + settings + sub header)
|
||||
register_custom_layout("blog-cache", _cache_full, _cache_oob)
|
||||
register_custom_layout("blog-snippets", _snippets_full, _snippets_oob)
|
||||
register_custom_layout("blog-menu-items", _menu_items_full, _menu_items_oob)
|
||||
register_custom_layout("blog-tag-groups", _tag_groups_full, _tag_groups_oob)
|
||||
register_custom_layout("blog-tag-group-edit",
|
||||
_tag_group_edit_full, _tag_group_edit_oob)
|
||||
|
||||
|
||||
# --- Blog layout (root + blog header) ---
|
||||
|
||||
def _blog_full(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import root_header_sx
|
||||
from sx.sx_components import _blog_header_sx
|
||||
root_hdr = root_header_sx(ctx)
|
||||
blog_hdr = _blog_header_sx(ctx)
|
||||
return "(<> " + root_hdr + " " + blog_hdr + ")"
|
||||
|
||||
|
||||
def _blog_oob(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import root_header_sx, oob_header_sx
|
||||
from sx.sx_components import _blog_header_sx
|
||||
root_hdr = root_header_sx(ctx)
|
||||
blog_hdr = _blog_header_sx(ctx)
|
||||
rows = "(<> " + root_hdr + " " + blog_hdr + ")"
|
||||
return oob_header_sx("root-header-child", "blog-header-child", rows)
|
||||
|
||||
|
||||
# --- Settings layout (root + settings header) ---
|
||||
|
||||
def _settings_full(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import root_header_sx
|
||||
from sx.sx_components import _settings_header_sx
|
||||
root_hdr = root_header_sx(ctx)
|
||||
settings_hdr = _settings_header_sx(ctx)
|
||||
return "(<> " + root_hdr + " " + settings_hdr + ")"
|
||||
|
||||
|
||||
def _settings_oob(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import root_header_sx, oob_header_sx
|
||||
from sx.sx_components import _settings_header_sx
|
||||
root_hdr = root_header_sx(ctx)
|
||||
settings_hdr = _settings_header_sx(ctx)
|
||||
rows = "(<> " + root_hdr + " " + settings_hdr + ")"
|
||||
return oob_header_sx("root-header-child", "root-settings-header-child", rows)
|
||||
|
||||
|
||||
def _settings_mobile(ctx: dict, **kw: Any) -> str:
|
||||
from sx.sx_components import _settings_nav_sx
|
||||
return _settings_nav_sx(ctx)
|
||||
|
||||
|
||||
# --- Sub-settings helpers ---
|
||||
|
||||
def _sub_settings_full(ctx: dict, row_id: str, child_id: str,
|
||||
endpoint: str, icon: str, label: str) -> str:
|
||||
from shared.sx.helpers import root_header_sx
|
||||
from sx.sx_components import _settings_header_sx, _sub_settings_header_sx
|
||||
from quart import url_for as qurl
|
||||
root_hdr = root_header_sx(ctx)
|
||||
settings_hdr = _settings_header_sx(ctx)
|
||||
sub_hdr = _sub_settings_header_sx(row_id, child_id,
|
||||
qurl(endpoint), icon, label, ctx)
|
||||
return "(<> " + root_hdr + " " + settings_hdr + " " + sub_hdr + ")"
|
||||
|
||||
|
||||
def _sub_settings_oob(ctx: dict, row_id: str, child_id: str,
|
||||
endpoint: str, icon: str, label: str) -> str:
|
||||
from shared.sx.helpers import oob_header_sx
|
||||
from sx.sx_components import _settings_header_sx, _sub_settings_header_sx
|
||||
from quart import url_for as qurl
|
||||
settings_hdr_oob = _settings_header_sx(ctx, oob=True)
|
||||
sub_hdr = _sub_settings_header_sx(row_id, child_id,
|
||||
qurl(endpoint), icon, label, ctx)
|
||||
sub_oob = oob_header_sx("root-settings-header-child", child_id, sub_hdr)
|
||||
return "(<> " + settings_hdr_oob + " " + sub_oob + ")"
|
||||
|
||||
|
||||
# --- Cache ---
|
||||
|
||||
def _cache_full(ctx: dict, **kw: Any) -> str:
|
||||
return _sub_settings_full(ctx, "cache-row", "cache-header-child",
|
||||
"settings.defpage_cache_page", "refresh", "Cache")
|
||||
|
||||
|
||||
def _cache_oob(ctx: dict, **kw: Any) -> str:
|
||||
return _sub_settings_oob(ctx, "cache-row", "cache-header-child",
|
||||
"settings.defpage_cache_page", "refresh", "Cache")
|
||||
|
||||
|
||||
# --- Snippets ---
|
||||
|
||||
def _snippets_full(ctx: dict, **kw: Any) -> str:
|
||||
return _sub_settings_full(ctx, "snippets-row", "snippets-header-child",
|
||||
"snippets.defpage_snippets_page", "puzzle-piece", "Snippets")
|
||||
|
||||
|
||||
def _snippets_oob(ctx: dict, **kw: Any) -> str:
|
||||
return _sub_settings_oob(ctx, "snippets-row", "snippets-header-child",
|
||||
"snippets.defpage_snippets_page", "puzzle-piece", "Snippets")
|
||||
|
||||
|
||||
# --- Menu Items ---
|
||||
|
||||
def _menu_items_full(ctx: dict, **kw: Any) -> str:
|
||||
return _sub_settings_full(ctx, "menu_items-row", "menu_items-header-child",
|
||||
"menu_items.defpage_menu_items_page", "bars", "Menu Items")
|
||||
|
||||
|
||||
def _menu_items_oob(ctx: dict, **kw: Any) -> str:
|
||||
return _sub_settings_oob(ctx, "menu_items-row", "menu_items-header-child",
|
||||
"menu_items.defpage_menu_items_page", "bars", "Menu Items")
|
||||
|
||||
|
||||
# --- Tag Groups ---
|
||||
|
||||
def _tag_groups_full(ctx: dict, **kw: Any) -> str:
|
||||
return _sub_settings_full(ctx, "tag-groups-row", "tag-groups-header-child",
|
||||
"blog.tag_groups_admin.defpage_tag_groups_page", "tags", "Tag Groups")
|
||||
|
||||
|
||||
def _tag_groups_oob(ctx: dict, **kw: Any) -> str:
|
||||
return _sub_settings_oob(ctx, "tag-groups-row", "tag-groups-header-child",
|
||||
"blog.tag_groups_admin.defpage_tag_groups_page", "tags", "Tag Groups")
|
||||
|
||||
|
||||
# --- Tag Group Edit ---
|
||||
|
||||
def _tag_group_edit_full(ctx: dict, **kw: Any) -> str:
|
||||
from quart import request
|
||||
g_id = (request.view_args or {}).get("id")
|
||||
from quart import url_for as qurl
|
||||
from shared.sx.helpers import root_header_sx
|
||||
from sx.sx_components import _settings_header_sx, _sub_settings_header_sx
|
||||
root_hdr = root_header_sx(ctx)
|
||||
settings_hdr = _settings_header_sx(ctx)
|
||||
sub_hdr = _sub_settings_header_sx("tag-groups-row", "tag-groups-header-child",
|
||||
qurl("blog.tag_groups_admin.defpage_tag_group_edit", id=g_id),
|
||||
"tags", "Tag Groups", ctx)
|
||||
return "(<> " + root_hdr + " " + settings_hdr + " " + sub_hdr + ")"
|
||||
|
||||
|
||||
def _tag_group_edit_oob(ctx: dict, **kw: Any) -> str:
|
||||
from quart import request
|
||||
g_id = (request.view_args or {}).get("id")
|
||||
from quart import url_for as qurl
|
||||
from shared.sx.helpers import oob_header_sx
|
||||
from sx.sx_components import _settings_header_sx, _sub_settings_header_sx
|
||||
settings_hdr_oob = _settings_header_sx(ctx, oob=True)
|
||||
sub_hdr = _sub_settings_header_sx("tag-groups-row", "tag-groups-header-child",
|
||||
qurl("blog.tag_groups_admin.defpage_tag_group_edit", id=g_id),
|
||||
"tags", "Tag Groups", ctx)
|
||||
sub_oob = oob_header_sx("root-settings-header-child", "tag-groups-header-child", sub_hdr)
|
||||
return "(<> " + settings_hdr_oob + " " + sub_oob + ")"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Page helpers (sync functions available in .sx defpage expressions)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _register_blog_helpers() -> None:
|
||||
from shared.sx.pages import register_page_helpers
|
||||
register_page_helpers("blog", {
|
||||
"editor-content": _h_editor_content,
|
||||
"editor-page-content": _h_editor_page_content,
|
||||
"post-admin-content": _h_post_admin_content,
|
||||
"post-data-content": _h_post_data_content,
|
||||
"post-preview-content": _h_post_preview_content,
|
||||
"post-entries-content": _h_post_entries_content,
|
||||
"post-settings-content": _h_post_settings_content,
|
||||
"post-edit-content": _h_post_edit_content,
|
||||
"settings-content": _h_settings_content,
|
||||
"cache-content": _h_cache_content,
|
||||
"snippets-content": _h_snippets_content,
|
||||
"menu-items-content": _h_menu_items_content,
|
||||
"tag-groups-content": _h_tag_groups_content,
|
||||
"tag-group-edit-content": _h_tag_group_edit_content,
|
||||
})
|
||||
|
||||
|
||||
def _h_editor_content():
|
||||
from quart import g
|
||||
return getattr(g, "editor_content", "")
|
||||
|
||||
|
||||
def _h_editor_page_content():
|
||||
from quart import g
|
||||
return getattr(g, "editor_page_content", "")
|
||||
|
||||
|
||||
def _h_post_admin_content():
|
||||
from quart import g
|
||||
return getattr(g, "post_admin_content", "")
|
||||
|
||||
|
||||
def _h_post_data_content():
|
||||
from quart import g
|
||||
return getattr(g, "post_data_content", "")
|
||||
|
||||
|
||||
def _h_post_preview_content():
|
||||
from quart import g
|
||||
return getattr(g, "post_preview_content", "")
|
||||
|
||||
|
||||
def _h_post_entries_content():
|
||||
from quart import g
|
||||
return getattr(g, "post_entries_content", "")
|
||||
|
||||
|
||||
def _h_post_settings_content():
|
||||
from quart import g
|
||||
return getattr(g, "post_settings_content", "")
|
||||
|
||||
|
||||
def _h_post_edit_content():
|
||||
from quart import g
|
||||
return getattr(g, "post_edit_content", "")
|
||||
|
||||
|
||||
def _h_settings_content():
|
||||
from quart import g
|
||||
return getattr(g, "settings_content", "")
|
||||
|
||||
|
||||
def _h_cache_content():
|
||||
from quart import g
|
||||
return getattr(g, "cache_content", "")
|
||||
|
||||
|
||||
def _h_snippets_content():
|
||||
from quart import g
|
||||
return getattr(g, "snippets_content", "")
|
||||
|
||||
|
||||
def _h_menu_items_content():
|
||||
from quart import g
|
||||
return getattr(g, "menu_items_content", "")
|
||||
|
||||
|
||||
def _h_tag_groups_content():
|
||||
from quart import g
|
||||
return getattr(g, "tag_groups_content", "")
|
||||
|
||||
|
||||
def _h_tag_group_edit_content():
|
||||
from quart import g
|
||||
return getattr(g, "tag_group_edit_content", "")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
; Blog app defpage declarations
|
||||
; Pages kept as Python: home, index, post-detail (cache_page / complex branching)
|
||||
; All helpers return data dicts — markup composition in SX.
|
||||
|
||||
; --- New post/page editors ---
|
||||
|
||||
@@ -7,92 +8,147 @@
|
||||
:path "/new/"
|
||||
:auth :admin
|
||||
:layout :blog
|
||||
:content (editor-content))
|
||||
:data (editor-data)
|
||||
:content (~editor/content
|
||||
:csrf csrf :title-placeholder title-placeholder
|
||||
:create-label create-label :css-href css-href
|
||||
:js-src js-src :sx-editor-js-src sx-editor-js-src
|
||||
:init-js init-js))
|
||||
|
||||
(defpage new-page
|
||||
:path "/new-page/"
|
||||
:auth :admin
|
||||
:layout :blog
|
||||
:content (editor-page-content))
|
||||
:data (editor-page-data)
|
||||
:content (~editor/content
|
||||
:csrf csrf :title-placeholder title-placeholder
|
||||
:create-label create-label :css-href css-href
|
||||
:js-src js-src :sx-editor-js-src sx-editor-js-src
|
||||
:init-js init-js))
|
||||
|
||||
; --- Post admin pages (nested under /<slug>/admin/) ---
|
||||
; --- Post admin pages (absolute paths under /<slug>/admin/) ---
|
||||
|
||||
(defpage post-admin
|
||||
:path "/"
|
||||
:path "/<slug>/admin/"
|
||||
:auth :admin
|
||||
:layout (:post-admin :selected "admin")
|
||||
:content (post-admin-content))
|
||||
:data (post-admin-data slug)
|
||||
:content (~admin/placeholder))
|
||||
|
||||
(defpage post-data
|
||||
:path "/data/"
|
||||
:path "/<slug>/admin/data/"
|
||||
:auth :admin
|
||||
:layout (:post-admin :selected "data")
|
||||
:content (post-data-content))
|
||||
:data (post-data-data slug)
|
||||
:content (~admin/data-table-content :tablename tablename :model-data model-data))
|
||||
|
||||
(defpage post-preview
|
||||
:path "/preview/"
|
||||
:path "/<slug>/admin/preview/"
|
||||
:auth :admin
|
||||
:layout (:post-admin :selected "preview")
|
||||
:content (post-preview-content))
|
||||
:data (post-preview-data slug)
|
||||
:content (~admin/preview-content
|
||||
:sx-pretty sx-pretty :json-pretty json-pretty
|
||||
:sx-rendered sx-rendered :lex-rendered lex-rendered))
|
||||
|
||||
(defpage post-entries
|
||||
:path "/entries/"
|
||||
:path "/<slug>/admin/entries/"
|
||||
:auth :admin
|
||||
:layout (:post-admin :selected "entries")
|
||||
:content (post-entries-content))
|
||||
:data (post-entries-data slug)
|
||||
:content (~settings/entries-browser-content
|
||||
:entries-panel (~settings/associated-entries-from-data :entries entries :csrf csrf)
|
||||
:calendars calendars))
|
||||
|
||||
(defpage post-settings
|
||||
:path "/settings/"
|
||||
:path "/<slug>/admin/settings/"
|
||||
:auth :post_author
|
||||
:layout (:post-admin :selected "settings")
|
||||
:content (post-settings-content))
|
||||
:data (post-settings-data slug)
|
||||
:content (~settings/form-content
|
||||
:csrf csrf :updated-at updated-at :is-page is-page
|
||||
:save-success save-success :slug settings-slug
|
||||
:published-at published-at :featured featured
|
||||
:visibility visibility :email-only email-only
|
||||
:tags tags :feature-image-alt feature-image-alt
|
||||
:meta-title meta-title :meta-description meta-description
|
||||
:canonical-url canonical-url :og-title og-title
|
||||
:og-description og-description :og-image og-image
|
||||
:twitter-title twitter-title :twitter-description twitter-description
|
||||
:twitter-image twitter-image :custom-template custom-template))
|
||||
|
||||
(defpage post-edit
|
||||
:path "/edit/"
|
||||
:path "/<slug>/admin/edit/"
|
||||
:auth :post_author
|
||||
:layout (:post-admin :selected "edit")
|
||||
:content (post-edit-content))
|
||||
:data (post-edit-data slug)
|
||||
:content (~editor/edit-content
|
||||
:csrf csrf :updated-at updated-at
|
||||
:title-val title-val :excerpt-val excerpt-val
|
||||
:feature-image feature-image :feature-image-caption feature-image-caption
|
||||
:sx-content-val sx-content-val :lexical-json lexical-json
|
||||
:has-sx has-sx :title-placeholder title-placeholder
|
||||
:status status :already-emailed already-emailed
|
||||
:newsletter-options (<>
|
||||
(option :value "" "Select newsletter\u2026")
|
||||
(map (fn (nl) (option :value (get nl "slug") (get nl "name"))) newsletters))
|
||||
:footer-extra (when badges
|
||||
(<> (map (fn (b) (span :class (get b "cls") (get b "text"))) badges)))
|
||||
:css-href css-href :js-src js-src
|
||||
:sx-editor-js-src sx-editor-js-src
|
||||
:init-js init-js :save-error save-error))
|
||||
|
||||
; --- Settings pages ---
|
||||
; --- Settings pages (absolute paths) ---
|
||||
|
||||
(defpage settings-home
|
||||
:path "/"
|
||||
:path "/settings/"
|
||||
:auth :admin
|
||||
:layout :blog-settings
|
||||
:content (settings-content))
|
||||
:content (div :class "max-w-2xl mx-auto px-4 py-6"))
|
||||
|
||||
(defpage cache-page
|
||||
:path "/cache/"
|
||||
:path "/settings/cache/"
|
||||
:auth :admin
|
||||
:layout :blog-cache
|
||||
:content (cache-content))
|
||||
:data (service "blog-page" "cache-data")
|
||||
:content (~admin/cache-panel :clear-url clear-url :csrf csrf))
|
||||
|
||||
; --- Snippets ---
|
||||
|
||||
(defpage snippets-page
|
||||
:path "/"
|
||||
:path "/settings/snippets/"
|
||||
:auth :login
|
||||
:layout :blog-snippets
|
||||
:content (snippets-content))
|
||||
:data (service "blog-page" "snippets-data")
|
||||
:content (~admin/snippets-content
|
||||
:snippets snippets :is-admin is-admin :csrf csrf))
|
||||
|
||||
; --- Menu Items ---
|
||||
|
||||
(defpage menu-items-page
|
||||
:path "/"
|
||||
:path "/settings/menu_items/"
|
||||
:auth :admin
|
||||
:layout :blog-menu-items
|
||||
:content (menu-items-content))
|
||||
:data (service "blog-page" "menu-items-data")
|
||||
:content (~admin/menu-items-content
|
||||
:menu-items menu-items :new-url new-url :csrf csrf))
|
||||
|
||||
; --- Tag Groups ---
|
||||
|
||||
(defpage tag-groups-page
|
||||
:path "/"
|
||||
:path "/settings/tag-groups/"
|
||||
:auth :admin
|
||||
:layout :blog-tag-groups
|
||||
:content (tag-groups-content))
|
||||
:data (service "blog-page" "tag-groups-data")
|
||||
:content (~admin/tag-groups-content
|
||||
:groups groups :unassigned-tags unassigned-tags
|
||||
:create-url create-url :csrf csrf))
|
||||
|
||||
(defpage tag-group-edit
|
||||
:path "/<int:id>/"
|
||||
:path "/settings/tag-groups/<int:id>/"
|
||||
:auth :admin
|
||||
:layout :blog-tag-group-edit
|
||||
:content (tag-group-edit-content))
|
||||
:data (service "blog-page" "tag-group-edit-data" :id id)
|
||||
:content (~admin/tag-group-edit-content
|
||||
:group group :all-tags all-tags
|
||||
:save-url save-url :delete-url delete-url :csrf csrf))
|
||||
|
||||
703
blog/sxc/pages/helpers.py
Normal file
703
blog/sxc/pages/helpers.py
Normal file
@@ -0,0 +1,703 @@
|
||||
"""Blog page helpers — async functions available in .sx defpage expressions.
|
||||
|
||||
All helpers return data values (dicts, lists) — no sx_call().
|
||||
Markup composition lives entirely in .sx defpage and .sx defcomp files.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared hydration helpers (kept for auth/g._defpage_ctx side effects)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _add_to_defpage_ctx(**kwargs: Any) -> None:
|
||||
from quart import g
|
||||
if not hasattr(g, '_defpage_ctx'):
|
||||
g._defpage_ctx = {}
|
||||
g._defpage_ctx.update(kwargs)
|
||||
|
||||
|
||||
async def _ensure_post_data(slug: str | None) -> None:
|
||||
"""Load post data and set g.post_data + defpage context.
|
||||
|
||||
Replicates post bp's hydrate_post_data + context_processor.
|
||||
"""
|
||||
from quart import g, abort
|
||||
|
||||
if hasattr(g, 'post_data') and g.post_data:
|
||||
await _inject_post_context(g.post_data)
|
||||
return
|
||||
|
||||
if not slug:
|
||||
abort(404)
|
||||
|
||||
from bp.post.services.post_data import post_data
|
||||
|
||||
is_admin = bool((g.get("rights") or {}).get("admin"))
|
||||
p_data = await post_data(slug, g.s, include_drafts=True)
|
||||
if not p_data:
|
||||
abort(404)
|
||||
|
||||
# Draft access control
|
||||
if p_data["post"].get("status") != "published":
|
||||
if is_admin:
|
||||
pass
|
||||
elif g.user and p_data["post"].get("user_id") == g.user.id:
|
||||
pass
|
||||
else:
|
||||
abort(404)
|
||||
|
||||
g.post_data = p_data
|
||||
g.post_slug = slug
|
||||
await _inject_post_context(p_data)
|
||||
|
||||
|
||||
async def _inject_post_context(p_data: dict) -> None:
|
||||
"""Add post context_processor data to defpage context."""
|
||||
from shared.config import config
|
||||
from shared.infrastructure.fragments import fetch_fragment
|
||||
from shared.infrastructure.data_client import fetch_data
|
||||
from shared.contracts.dtos import CartSummaryDTO, dto_from_dict
|
||||
from shared.infrastructure.cart_identity import current_cart_identity
|
||||
|
||||
db_post_id = p_data["post"]["id"]
|
||||
post_slug = p_data["post"]["slug"]
|
||||
|
||||
container_nav = await fetch_fragment("relations", "container-nav", params={
|
||||
"container_type": "page",
|
||||
"container_id": str(db_post_id),
|
||||
"post_slug": post_slug,
|
||||
})
|
||||
|
||||
ctx: dict = {
|
||||
**p_data,
|
||||
"base_title": config()["title"],
|
||||
"container_nav": container_nav,
|
||||
}
|
||||
|
||||
if p_data["post"].get("is_page"):
|
||||
ident = current_cart_identity()
|
||||
summary_params: dict = {"page_slug": post_slug}
|
||||
if ident["user_id"] is not None:
|
||||
summary_params["user_id"] = ident["user_id"]
|
||||
if ident["session_id"] is not None:
|
||||
summary_params["session_id"] = ident["session_id"]
|
||||
raw_summary = await fetch_data(
|
||||
"cart", "cart-summary", params=summary_params, required=False,
|
||||
)
|
||||
page_summary = dto_from_dict(CartSummaryDTO, raw_summary) if raw_summary else CartSummaryDTO()
|
||||
ctx["page_cart_count"] = (
|
||||
page_summary.count + page_summary.calendar_count + page_summary.ticket_count
|
||||
)
|
||||
ctx["page_cart_total"] = float(
|
||||
page_summary.total + page_summary.calendar_total + page_summary.ticket_total
|
||||
)
|
||||
|
||||
_add_to_defpage_ctx(**ctx)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _register_blog_helpers() -> None:
|
||||
from shared.sx.pages import register_page_helpers
|
||||
register_page_helpers("blog", {
|
||||
"editor-data": _h_editor_data,
|
||||
"editor-page-data": _h_editor_page_data,
|
||||
"post-admin-data": _h_post_admin_data,
|
||||
"post-data-data": _h_post_data_data,
|
||||
"post-preview-data": _h_post_preview_data,
|
||||
"post-entries-data": _h_post_entries_data,
|
||||
"post-settings-data": _h_post_settings_data,
|
||||
"post-edit-data": _h_post_edit_data,
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Editor helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _editor_init_js(urls: dict, *, form_id: str = "post-edit-form",
|
||||
has_initial_json: bool = True) -> str:
|
||||
"""Build the editor initialization JavaScript string.
|
||||
|
||||
URLs dict must contain: upload_image, upload_media, upload_file, oembed,
|
||||
snippets, unsplash_key.
|
||||
"""
|
||||
font_size_preamble = (
|
||||
"(function() {"
|
||||
" function applyEditorFontSize() {"
|
||||
" document.documentElement.style.fontSize = '62.5%';"
|
||||
" document.body.style.fontSize = '1.6rem';"
|
||||
" }"
|
||||
" function restoreDefaultFontSize() {"
|
||||
" document.documentElement.style.fontSize = '';"
|
||||
" document.body.style.fontSize = '';"
|
||||
" }"
|
||||
" applyEditorFontSize();"
|
||||
" document.body.addEventListener('htmx:beforeSwap', function cleanup(e) {"
|
||||
" if (e.detail.target && e.detail.target.id === 'main-panel') {"
|
||||
" restoreDefaultFontSize();"
|
||||
" document.body.removeEventListener('htmx:beforeSwap', cleanup);"
|
||||
" }"
|
||||
" });"
|
||||
)
|
||||
|
||||
upload_image = urls["upload_image"]
|
||||
upload_media = urls["upload_media"]
|
||||
upload_file = urls["upload_file"]
|
||||
oembed = urls["oembed"]
|
||||
unsplash_key = urls["unsplash_key"]
|
||||
snippets = urls["snippets"]
|
||||
|
||||
init_body = (
|
||||
" function init() {"
|
||||
" var csrfToken = document.querySelector('input[name=\"csrf_token\"]').value;"
|
||||
f" var uploadUrl = '{upload_image}';"
|
||||
" var uploadUrls = {"
|
||||
" image: uploadUrl,"
|
||||
f" media: '{upload_media}',"
|
||||
f" file: '{upload_file}',"
|
||||
" };"
|
||||
" var fileInput = document.getElementById('feature-image-file');"
|
||||
" var addBtn = document.getElementById('feature-image-add-btn');"
|
||||
" var deleteBtn = document.getElementById('feature-image-delete-btn');"
|
||||
" var preview = document.getElementById('feature-image-preview');"
|
||||
" var emptyState = document.getElementById('feature-image-empty');"
|
||||
" var filledState = document.getElementById('feature-image-filled');"
|
||||
" var hiddenUrl = document.getElementById('feature-image-input');"
|
||||
" var hiddenCaption = document.getElementById('feature-image-caption-input');"
|
||||
" var captionInput = document.getElementById('feature-image-caption');"
|
||||
" var uploading = document.getElementById('feature-image-uploading');"
|
||||
" function showFilled(url) {"
|
||||
" preview.src = url; hiddenUrl.value = url;"
|
||||
" emptyState.classList.add('hidden'); filledState.classList.remove('hidden'); uploading.classList.add('hidden');"
|
||||
" }"
|
||||
" function showEmpty() {"
|
||||
" preview.src = ''; hiddenUrl.value = ''; hiddenCaption.value = ''; captionInput.value = '';"
|
||||
" emptyState.classList.remove('hidden'); filledState.classList.add('hidden'); uploading.classList.add('hidden');"
|
||||
" }"
|
||||
" function uploadFile(file) {"
|
||||
" emptyState.classList.add('hidden'); uploading.classList.remove('hidden');"
|
||||
" var fd = new FormData(); fd.append('file', file);"
|
||||
" fetch(uploadUrl, { method: 'POST', body: fd, headers: { 'X-CSRFToken': csrfToken } })"
|
||||
" .then(function(r) { if (!r.ok) throw new Error('Upload failed (' + r.status + ')'); return r.json(); })"
|
||||
" .then(function(data) {"
|
||||
" var url = data.images && data.images[0] && data.images[0].url;"
|
||||
" if (url) showFilled(url); else { showEmpty(); alert('Upload succeeded but no image URL returned.'); }"
|
||||
" })"
|
||||
" .catch(function(e) { showEmpty(); alert(e.message); });"
|
||||
" }"
|
||||
" addBtn.addEventListener('click', function() { fileInput.click(); });"
|
||||
" preview.addEventListener('click', function() { fileInput.click(); });"
|
||||
" deleteBtn.addEventListener('click', function(e) { e.stopPropagation(); showEmpty(); });"
|
||||
" fileInput.addEventListener('change', function() {"
|
||||
" if (fileInput.files && fileInput.files[0]) { uploadFile(fileInput.files[0]); fileInput.value = ''; }"
|
||||
" });"
|
||||
" captionInput.addEventListener('input', function() { hiddenCaption.value = captionInput.value; });"
|
||||
" var excerpt = document.querySelector('textarea[name=\"custom_excerpt\"]');"
|
||||
" function autoResize() { excerpt.style.height = 'auto'; excerpt.style.height = excerpt.scrollHeight + 'px'; }"
|
||||
" excerpt.addEventListener('input', autoResize); autoResize();"
|
||||
)
|
||||
|
||||
if has_initial_json:
|
||||
init_body += (
|
||||
" var dataEl = document.getElementById('lexical-initial-data');"
|
||||
" var initialJson = dataEl ? dataEl.textContent.trim() : null;"
|
||||
" if (initialJson) { var hidden = document.getElementById('lexical-json-input'); if (hidden) hidden.value = initialJson; }"
|
||||
)
|
||||
initial_json_arg = "initialJson: initialJson,"
|
||||
else:
|
||||
initial_json_arg = "initialJson: null,"
|
||||
|
||||
init_body += (
|
||||
" window.mountEditor('lexical-editor', {"
|
||||
f" {initial_json_arg}"
|
||||
" csrfToken: csrfToken,"
|
||||
" uploadUrls: uploadUrls,"
|
||||
f" oembedUrl: '{oembed}',"
|
||||
f" unsplashApiKey: '{unsplash_key}',"
|
||||
f" snippetsUrl: '{snippets}',"
|
||||
" });"
|
||||
" if (typeof SxEditor !== 'undefined') {"
|
||||
" SxEditor.mount('sx-editor', {"
|
||||
" initialSx: (document.getElementById('sx-content-input') || {}).value || null,"
|
||||
" csrfToken: csrfToken,"
|
||||
" uploadUrls: uploadUrls,"
|
||||
f" oembedUrl: '{oembed}',"
|
||||
" onChange: function(sx) {"
|
||||
" document.getElementById('sx-content-input').value = sx;"
|
||||
" }"
|
||||
" });"
|
||||
" }"
|
||||
" document.addEventListener('keydown', function(e) {"
|
||||
" if ((e.ctrlKey || e.metaKey) && e.key === 's') {"
|
||||
f" e.preventDefault(); document.getElementById('{form_id}').requestSubmit();"
|
||||
" }"
|
||||
" });"
|
||||
" }"
|
||||
" if (typeof window.mountEditor === 'function') { init(); }"
|
||||
" else { var _t = setInterval(function() {"
|
||||
" if (typeof window.mountEditor === 'function') { clearInterval(_t); init(); }"
|
||||
" }, 50); }"
|
||||
"})();"
|
||||
)
|
||||
|
||||
return font_size_preamble + init_body
|
||||
|
||||
|
||||
def _editor_urls() -> dict:
|
||||
"""Extract editor API URLs and asset paths."""
|
||||
import os
|
||||
from quart import url_for as qurl, current_app
|
||||
|
||||
asset_url_fn = current_app.jinja_env.globals.get("asset_url", lambda p: "")
|
||||
return {
|
||||
"upload_image": qurl("blog.editor_api.upload_image"),
|
||||
"upload_media": qurl("blog.editor_api.upload_media"),
|
||||
"upload_file": qurl("blog.editor_api.upload_file"),
|
||||
"oembed": qurl("blog.editor_api.oembed_proxy"),
|
||||
"snippets": qurl("blog.editor_api.list_snippets"),
|
||||
"unsplash_key": os.environ.get("UNSPLASH_ACCESS_KEY", ""),
|
||||
"css_href": asset_url_fn("scripts/editor.css"),
|
||||
"js_src": asset_url_fn("scripts/editor.js"),
|
||||
"sx_editor_js_src": asset_url_fn("scripts/sx-editor.js"),
|
||||
}
|
||||
|
||||
|
||||
def _h_editor_data(**kw) -> dict:
|
||||
"""New post editor — return data for ~blog-editor-content."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
|
||||
urls = _editor_urls()
|
||||
csrf = generate_csrf_token()
|
||||
init_js = _editor_init_js(urls, form_id="post-new-form", has_initial_json=False)
|
||||
|
||||
return {
|
||||
"csrf": csrf,
|
||||
"title-placeholder": "Post title...",
|
||||
"create-label": "Create Post",
|
||||
"css-href": urls["css_href"],
|
||||
"js-src": urls["js_src"],
|
||||
"sx-editor-js-src": urls["sx_editor_js_src"],
|
||||
"init-js": init_js,
|
||||
}
|
||||
|
||||
|
||||
def _h_editor_page_data(**kw) -> dict:
|
||||
"""New page editor — return data for ~blog-editor-content."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
|
||||
urls = _editor_urls()
|
||||
csrf = generate_csrf_token()
|
||||
init_js = _editor_init_js(urls, form_id="post-new-form", has_initial_json=False)
|
||||
|
||||
return {
|
||||
"csrf": csrf,
|
||||
"title-placeholder": "Page title...",
|
||||
"create-label": "Create Page",
|
||||
"css-href": urls["css_href"],
|
||||
"js-src": urls["js_src"],
|
||||
"sx-editor-js-src": urls["sx_editor_js_src"],
|
||||
"init-js": init_js,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Post admin helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _h_post_admin_data(slug=None, **kw) -> dict:
|
||||
await _ensure_post_data(slug)
|
||||
return {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data introspection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _extract_model_data(obj, depth=0, max_depth=2) -> dict:
|
||||
"""Recursively extract ORM model data into a nested dict for .sx rendering."""
|
||||
from markupsafe import escape as esc
|
||||
|
||||
# Scalar columns
|
||||
columns = []
|
||||
for col in obj.__mapper__.columns:
|
||||
key = col.key
|
||||
if key == "_sa_instance_state":
|
||||
continue
|
||||
val = getattr(obj, key, None)
|
||||
if val is None:
|
||||
columns.append({"key": str(key), "value": "", "type": "nil"})
|
||||
elif hasattr(val, "isoformat"):
|
||||
columns.append({"key": str(key), "value": str(esc(val.isoformat())), "type": "date"})
|
||||
elif isinstance(val, str):
|
||||
columns.append({"key": str(key), "value": str(esc(val)), "type": "str"})
|
||||
else:
|
||||
columns.append({"key": str(key), "value": str(esc(str(val))), "type": "other"})
|
||||
|
||||
# Relationships
|
||||
relationships = []
|
||||
for rel in obj.__mapper__.relationships:
|
||||
rel_name = rel.key
|
||||
loaded = rel_name in obj.__dict__
|
||||
value = getattr(obj, rel_name, None) if loaded else None
|
||||
cardinality = "many" if rel.uselist else "one"
|
||||
cls_name = rel.mapper.class_.__name__
|
||||
|
||||
rel_data: dict[str, Any] = {
|
||||
"name": rel_name,
|
||||
"cardinality": cardinality,
|
||||
"class_name": cls_name,
|
||||
"loaded": loaded,
|
||||
"value": None,
|
||||
}
|
||||
|
||||
if value is None:
|
||||
pass # value stays None
|
||||
elif rel.uselist:
|
||||
items_list = list(value) if value else []
|
||||
val_data: dict[str, Any] = {"is_list": True, "count": len(items_list)}
|
||||
if items_list and depth < max_depth:
|
||||
items = []
|
||||
for i, it in enumerate(items_list, 1):
|
||||
summary = _obj_summary(it)
|
||||
children = _extract_model_data(it, depth + 1, max_depth) if depth < max_depth else None
|
||||
items.append({"index": i, "summary": summary, "children": children})
|
||||
val_data["items"] = items
|
||||
rel_data["value"] = val_data
|
||||
else:
|
||||
child = value
|
||||
summary = _obj_summary(child)
|
||||
children = _extract_model_data(child, depth + 1, max_depth) if depth < max_depth else None
|
||||
rel_data["value"] = {"is_list": False, "summary": summary, "children": children}
|
||||
|
||||
relationships.append(rel_data)
|
||||
|
||||
return {"columns": columns, "relationships": relationships}
|
||||
|
||||
|
||||
def _obj_summary(obj) -> str:
|
||||
"""Build a summary string for an ORM object."""
|
||||
from markupsafe import escape as esc
|
||||
ident_parts = []
|
||||
for k in ("id", "ghost_id", "uuid", "slug", "name", "title"):
|
||||
if k in obj.__mapper__.c:
|
||||
v = getattr(obj, k, "")
|
||||
ident_parts.append(f"{k}={v}")
|
||||
return str(esc(" \u2022 ".join(ident_parts) if ident_parts else str(obj)))
|
||||
|
||||
|
||||
async def _h_post_data_data(slug=None, **kw) -> dict:
|
||||
await _ensure_post_data(slug)
|
||||
from quart import g
|
||||
|
||||
original_post = getattr(g, "post_data", {}).get("original_post")
|
||||
if original_post is None:
|
||||
return {"tablename": None, "model-data": None}
|
||||
|
||||
tablename = getattr(original_post, "__tablename__", "?")
|
||||
model_data = _extract_model_data(original_post, 0, 2)
|
||||
|
||||
return {"tablename": tablename, "model-data": model_data}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Preview content
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _h_post_preview_data(slug=None, **kw) -> dict:
|
||||
await _ensure_post_data(slug)
|
||||
from quart import g
|
||||
from shared.services.registry import services
|
||||
from shared.sx.helpers import SxExpr
|
||||
|
||||
preview = await services.blog_page.preview_data(g.s)
|
||||
|
||||
return {
|
||||
"sx-pretty": SxExpr(preview["sx_pretty"]) if preview.get("sx_pretty") else None,
|
||||
"json-pretty": SxExpr(preview["json_pretty"]) if preview.get("json_pretty") else None,
|
||||
"sx-rendered": preview.get("sx_rendered") or None,
|
||||
"lex-rendered": preview.get("lex_rendered") or None,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entries browser
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _extract_associated_entries_data(all_calendars, associated_entry_ids, post_slug: str) -> list:
|
||||
"""Extract associated entry data for .sx rendering."""
|
||||
from quart import url_for as qurl
|
||||
from shared.utils import host_url
|
||||
|
||||
entries = []
|
||||
for calendar in all_calendars:
|
||||
cal_entries = getattr(calendar, "entries", []) or []
|
||||
cal_name = getattr(calendar, "name", "")
|
||||
cal_post = getattr(calendar, "post", None)
|
||||
cal_fi = getattr(cal_post, "feature_image", None) if cal_post else None
|
||||
cal_title = getattr(cal_post, "title", "") if cal_post else ""
|
||||
|
||||
for entry in cal_entries:
|
||||
e_id = getattr(entry, "id", None)
|
||||
if e_id not in associated_entry_ids:
|
||||
continue
|
||||
if getattr(entry, "deleted_at", None) is not None:
|
||||
continue
|
||||
|
||||
e_name = getattr(entry, "name", "")
|
||||
e_start = getattr(entry, "start_at", None)
|
||||
e_end = getattr(entry, "end_at", None)
|
||||
|
||||
toggle_url = host_url(qurl("blog.post.admin.toggle_entry",
|
||||
slug=post_slug, entry_id=e_id))
|
||||
|
||||
date_str = e_start.strftime("%A, %B %d, %Y at %H:%M") if e_start else ""
|
||||
if e_end:
|
||||
date_str += f" \u2013 {e_end.strftime('%H:%M')}"
|
||||
|
||||
entries.append({
|
||||
"name": e_name,
|
||||
"confirm_text": f"This will remove {e_name} from this post",
|
||||
"toggle_url": toggle_url,
|
||||
"cal_image": cal_fi or "",
|
||||
"cal_title": cal_title,
|
||||
"date_str": f"{cal_name} \u2022 {date_str}",
|
||||
})
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
def _extract_calendar_browser_data(all_calendars, post_slug: str) -> list:
|
||||
"""Extract calendar browser data for .sx rendering."""
|
||||
from quart import url_for as qurl
|
||||
from shared.utils import host_url
|
||||
|
||||
calendars = []
|
||||
for cal in all_calendars:
|
||||
cal_post = getattr(cal, "post", None)
|
||||
cal_fi = getattr(cal_post, "feature_image", None) if cal_post else None
|
||||
cal_title = getattr(cal_post, "title", "") if cal_post else ""
|
||||
cal_name = getattr(cal, "name", "")
|
||||
view_url = host_url(qurl("blog.post.admin.calendar_view",
|
||||
slug=post_slug, calendar_id=cal.id))
|
||||
calendars.append({
|
||||
"name": cal_name,
|
||||
"title": cal_title,
|
||||
"image": cal_fi or "",
|
||||
"view_url": view_url,
|
||||
})
|
||||
return calendars
|
||||
|
||||
|
||||
async def _h_post_entries_data(slug=None, **kw) -> dict:
|
||||
await _ensure_post_data(slug)
|
||||
from quart import g
|
||||
from sqlalchemy import select
|
||||
from shared.models.calendars import Calendar
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from bp.post.services.entry_associations import get_post_entry_ids
|
||||
|
||||
post_id = g.post_data["post"]["id"]
|
||||
post_slug = g.post_data["post"]["slug"]
|
||||
associated_entry_ids = await get_post_entry_ids(post_id)
|
||||
result = await g.s.execute(
|
||||
select(Calendar)
|
||||
.where(Calendar.deleted_at.is_(None))
|
||||
.order_by(Calendar.name.asc())
|
||||
)
|
||||
all_calendars = result.scalars().all()
|
||||
for calendar in all_calendars:
|
||||
await g.s.refresh(calendar, ["entries", "post"])
|
||||
|
||||
csrf = generate_csrf_token()
|
||||
entries = _extract_associated_entries_data(
|
||||
all_calendars, associated_entry_ids, post_slug)
|
||||
calendars = _extract_calendar_browser_data(all_calendars, post_slug)
|
||||
|
||||
return {"entries": entries, "calendars": calendars, "csrf": csrf}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Settings form
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _h_post_settings_data(slug=None, **kw) -> dict:
|
||||
await _ensure_post_data(slug)
|
||||
from quart import g, request
|
||||
from models.ghost_content import Post
|
||||
from sqlalchemy import select as sa_select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from bp.post.admin.routes import _post_to_edit_dict
|
||||
|
||||
post_id = g.post_data["post"]["id"]
|
||||
post = (await g.s.execute(
|
||||
sa_select(Post)
|
||||
.where(Post.id == post_id)
|
||||
.options(selectinload(Post.tags))
|
||||
)).scalar_one_or_none()
|
||||
ghost_post = _post_to_edit_dict(post) if post else {}
|
||||
save_success = request.args.get("saved") == "1"
|
||||
csrf = generate_csrf_token()
|
||||
|
||||
p = g.post_data.get("post", {}) if hasattr(g, "post_data") else {}
|
||||
is_page = p.get("is_page", False)
|
||||
gp = ghost_post
|
||||
|
||||
# Extract tag names
|
||||
tags = gp.get("tags") or []
|
||||
if tags:
|
||||
tag_names = ", ".join(
|
||||
getattr(t, "name", t.get("name", "") if isinstance(t, dict) else str(t))
|
||||
for t in tags
|
||||
)
|
||||
else:
|
||||
tag_names = ""
|
||||
|
||||
# Published at — trim to datetime-local format
|
||||
pub_at = gp.get("published_at") or ""
|
||||
pub_at_val = pub_at[:16] if pub_at else ""
|
||||
|
||||
return {
|
||||
"csrf": csrf,
|
||||
"updated-at": gp.get("updated_at") or "",
|
||||
"is-page": is_page,
|
||||
"save-success": save_success,
|
||||
"settings-slug": gp.get("slug") or "",
|
||||
"published-at": pub_at_val,
|
||||
"featured": bool(gp.get("featured")),
|
||||
"visibility": gp.get("visibility") or "public",
|
||||
"email-only": bool(gp.get("email_only")),
|
||||
"tags": tag_names,
|
||||
"feature-image-alt": gp.get("feature_image_alt") or "",
|
||||
"meta-title": gp.get("meta_title") or "",
|
||||
"meta-description": gp.get("meta_description") or "",
|
||||
"canonical-url": gp.get("canonical_url") or "",
|
||||
"og-title": gp.get("og_title") or "",
|
||||
"og-description": gp.get("og_description") or "",
|
||||
"og-image": gp.get("og_image") or "",
|
||||
"twitter-title": gp.get("twitter_title") or "",
|
||||
"twitter-description": gp.get("twitter_description") or "",
|
||||
"twitter-image": gp.get("twitter_image") or "",
|
||||
"custom-template": gp.get("custom_template") or "",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Post edit content
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _extract_newsletter_options(newsletters) -> list:
|
||||
"""Extract newsletter data for .sx rendering."""
|
||||
return [{"slug": getattr(nl, "slug", ""),
|
||||
"name": getattr(nl, "name", "")} for nl in newsletters]
|
||||
|
||||
|
||||
def _extract_footer_badges(ghost_post: dict, post: dict, save_success: bool,
|
||||
publish_requested: bool, already_emailed: bool) -> list:
|
||||
"""Extract footer badge data for .sx rendering."""
|
||||
badges = []
|
||||
if save_success:
|
||||
badges.append({"cls": "text-[14px] text-green-600", "text": "Saved."})
|
||||
if publish_requested:
|
||||
badges.append({"cls": "text-[14px] text-blue-600",
|
||||
"text": "Publish requested \u2014 an admin will review."})
|
||||
if post.get("publish_requested"):
|
||||
badges.append({"cls": "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800",
|
||||
"text": "Publish requested"})
|
||||
if already_emailed:
|
||||
nl_name = ""
|
||||
newsletter = ghost_post.get("newsletter")
|
||||
if newsletter:
|
||||
nl_name = (getattr(newsletter, "name", "")
|
||||
if not isinstance(newsletter, dict)
|
||||
else newsletter.get("name", ""))
|
||||
suffix = f" to {nl_name}" if nl_name else ""
|
||||
badges.append({"cls": "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-800",
|
||||
"text": f"Emailed{suffix}"})
|
||||
return badges
|
||||
|
||||
|
||||
async def _h_post_edit_data(slug=None, **kw) -> dict:
|
||||
await _ensure_post_data(slug)
|
||||
from quart import g, request as qrequest
|
||||
from models.ghost_content import Post
|
||||
from sqlalchemy import select as sa_select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from shared.infrastructure.data_client import fetch_data
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from bp.post.admin.routes import _post_to_edit_dict
|
||||
|
||||
post_id = g.post_data["post"]["id"]
|
||||
db_post = (await g.s.execute(
|
||||
sa_select(Post)
|
||||
.where(Post.id == post_id)
|
||||
.options(selectinload(Post.tags))
|
||||
)).scalar_one_or_none()
|
||||
ghost_post = _post_to_edit_dict(db_post) if db_post else {}
|
||||
save_success = qrequest.args.get("saved") == "1"
|
||||
save_error = qrequest.args.get("error", "")
|
||||
raw_newsletters = await fetch_data("account", "newsletters", required=False) or []
|
||||
from types import SimpleNamespace
|
||||
newsletters = [SimpleNamespace(**nl) for nl in raw_newsletters]
|
||||
|
||||
csrf = generate_csrf_token()
|
||||
urls = _editor_urls()
|
||||
|
||||
post = g.post_data.get("post", {}) if hasattr(g, "post_data") else {}
|
||||
is_page = post.get("is_page", False)
|
||||
|
||||
feature_image = ghost_post.get("feature_image") or ""
|
||||
feature_image_caption = ghost_post.get("feature_image_caption") or ""
|
||||
title_val = ghost_post.get("title") or ""
|
||||
excerpt_val = ghost_post.get("custom_excerpt") or ""
|
||||
updated_at = ghost_post.get("updated_at") or ""
|
||||
status = ghost_post.get("status") or "draft"
|
||||
lexical_json = ghost_post.get("lexical") or '{"root":{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}'
|
||||
sx_content = ghost_post.get("sx_content") or ""
|
||||
has_sx = bool(sx_content)
|
||||
|
||||
already_emailed = bool(ghost_post and ghost_post.get("email") and
|
||||
(ghost_post["email"] if isinstance(ghost_post["email"], dict) else {}).get("status"))
|
||||
email_obj = ghost_post.get("email")
|
||||
if email_obj and not isinstance(email_obj, dict):
|
||||
already_emailed = bool(getattr(email_obj, "status", None))
|
||||
|
||||
title_placeholder = "Page title..." if is_page else "Post title..."
|
||||
|
||||
# Return newsletter data as list of dicts (composed in SX)
|
||||
nl_options = _extract_newsletter_options(newsletters)
|
||||
|
||||
# Return footer badge data as list of dicts (composed in SX)
|
||||
publish_requested = bool(qrequest.args.get("publish_requested")) if hasattr(qrequest, 'args') else False
|
||||
badges = _extract_footer_badges(ghost_post, post, save_success,
|
||||
publish_requested, already_emailed)
|
||||
|
||||
init_js = _editor_init_js(urls, form_id="post-edit-form", has_initial_json=True)
|
||||
|
||||
return {
|
||||
"csrf": csrf,
|
||||
"updated-at": str(updated_at),
|
||||
"title-val": title_val,
|
||||
"excerpt-val": excerpt_val,
|
||||
"feature-image": feature_image,
|
||||
"feature-image-caption": feature_image_caption,
|
||||
"sx-content-val": sx_content,
|
||||
"lexical-json": lexical_json,
|
||||
"has-sx": has_sx,
|
||||
"title-placeholder": title_placeholder,
|
||||
"status": status,
|
||||
"already-emailed": already_emailed,
|
||||
"newsletters": nl_options,
|
||||
"badges": badges,
|
||||
"css-href": urls["css_href"],
|
||||
"js-src": urls["js_src"],
|
||||
"sx-editor-js-src": urls["sx_editor_js_src"],
|
||||
"init-js": init_js,
|
||||
"save-error": save_error or None,
|
||||
}
|
||||
19
blog/sxc/pages/layouts.py
Normal file
19
blog/sxc/pages/layouts.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""Blog layout registration — all layouts delegate to .sx defcomps."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def _register_blog_layouts() -> None:
|
||||
from shared.sx.layouts import register_sx_layout
|
||||
register_sx_layout("blog", "blog-layout-full", "blog-layout-oob")
|
||||
register_sx_layout("blog-settings", "blog-settings-layout-full",
|
||||
"blog-settings-layout-oob", "blog-settings-layout-mobile")
|
||||
register_sx_layout("blog-cache", "blog-cache-layout-full",
|
||||
"blog-cache-layout-oob")
|
||||
register_sx_layout("blog-snippets", "blog-snippets-layout-full",
|
||||
"blog-snippets-layout-oob")
|
||||
register_sx_layout("blog-menu-items", "blog-menu-items-layout-full",
|
||||
"blog-menu-items-layout-oob")
|
||||
register_sx_layout("blog-tag-groups", "blog-tag-groups-layout-full",
|
||||
"blog-tag-groups-layout-oob")
|
||||
register_sx_layout("blog-tag-group-edit", "blog-tag-group-edit-layout-full",
|
||||
"blog-tag-group-edit-layout-oob")
|
||||
25
blog/sxc/pages/renders.py
Normal file
25
blog/sxc/pages/renders.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""Blog editor panel rendering."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def render_editor_panel(save_error: str | None = None, is_page: bool = False) -> str:
|
||||
"""Build the WYSIWYG editor panel for new post/page creation."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from shared.sx.helpers import sx_call
|
||||
from .helpers import _editor_urls, _editor_init_js
|
||||
|
||||
urls = _editor_urls()
|
||||
csrf = generate_csrf_token()
|
||||
title_placeholder = "Page title..." if is_page else "Post title..."
|
||||
create_label = "Create Page" if is_page else "Create Post"
|
||||
init_js = _editor_init_js(urls, form_id="post-new-form", has_initial_json=False)
|
||||
|
||||
return sx_call("blog-editor-content",
|
||||
csrf=csrf,
|
||||
title_placeholder=title_placeholder,
|
||||
create_label=create_label,
|
||||
css_href=urls["css_href"],
|
||||
js_src=urls["js_src"],
|
||||
sx_editor_js_src=urls["sx_editor_js_src"],
|
||||
init_js=init_js,
|
||||
save_error=save_error or None)
|
||||
@@ -167,7 +167,7 @@ class TestCards:
|
||||
result = lexical_to_sx(_doc({
|
||||
"type": "image", "src": "photo.jpg", "alt": "test"
|
||||
}))
|
||||
assert '(~kg-image :src "photo.jpg" :alt "test")' == result
|
||||
assert '(~kg_cards/kg-image :src "photo.jpg" :alt "test")' == result
|
||||
|
||||
def test_image_wide_with_caption(self):
|
||||
result = lexical_to_sx(_doc({
|
||||
@@ -189,7 +189,7 @@ class TestCards:
|
||||
"type": "bookmark", "url": "https://example.com",
|
||||
"metadata": {"title": "Example", "description": "A site"}
|
||||
}))
|
||||
assert "(~kg-bookmark " in result
|
||||
assert "(~kg_cards/kg-bookmark " in result
|
||||
assert ':url "https://example.com"' in result
|
||||
assert ':title "Example"' in result
|
||||
|
||||
@@ -199,7 +199,7 @@ class TestCards:
|
||||
"calloutEmoji": "💡",
|
||||
"children": [_text("Note")]
|
||||
}))
|
||||
assert "(~kg-callout " in result
|
||||
assert "(~kg_cards/kg-callout " in result
|
||||
assert ':color "blue"' in result
|
||||
|
||||
def test_button(self):
|
||||
@@ -207,7 +207,7 @@ class TestCards:
|
||||
"type": "button", "buttonText": "Click",
|
||||
"buttonUrl": "https://example.com"
|
||||
}))
|
||||
assert "(~kg-button " in result
|
||||
assert "(~kg_cards/kg-button " in result
|
||||
assert ':text "Click"' in result
|
||||
|
||||
def test_toggle(self):
|
||||
@@ -215,28 +215,28 @@ class TestCards:
|
||||
"type": "toggle", "heading": "FAQ",
|
||||
"children": [_text("Answer")]
|
||||
}))
|
||||
assert "(~kg-toggle " in result
|
||||
assert "(~kg_cards/kg-toggle " in result
|
||||
assert ':heading "FAQ"' in result
|
||||
|
||||
def test_html(self):
|
||||
result = lexical_to_sx(_doc({
|
||||
"type": "html", "html": "<div>custom</div>"
|
||||
}))
|
||||
assert result == '(~kg-html (div "custom"))'
|
||||
assert result == '(~kg_cards/kg-html (div "custom"))'
|
||||
|
||||
def test_embed(self):
|
||||
result = lexical_to_sx(_doc({
|
||||
"type": "embed", "html": "<iframe></iframe>",
|
||||
"caption": "Video"
|
||||
}))
|
||||
assert "(~kg-embed " in result
|
||||
assert "(~kg_cards/kg-embed " in result
|
||||
assert ':caption "Video"' in result
|
||||
|
||||
def test_markdown(self):
|
||||
result = lexical_to_sx(_doc({
|
||||
"type": "markdown", "markdown": "**bold** text"
|
||||
}))
|
||||
assert result.startswith("(~kg-md ")
|
||||
assert result.startswith("(~kg_cards/kg-md ")
|
||||
assert "(p " in result
|
||||
assert "(strong " in result
|
||||
|
||||
@@ -244,14 +244,14 @@ class TestCards:
|
||||
result = lexical_to_sx(_doc({
|
||||
"type": "video", "src": "v.mp4", "cardWidth": "wide"
|
||||
}))
|
||||
assert "(~kg-video " in result
|
||||
assert "(~kg_cards/kg-video " in result
|
||||
assert ':width "wide"' in result
|
||||
|
||||
def test_audio(self):
|
||||
result = lexical_to_sx(_doc({
|
||||
"type": "audio", "src": "s.mp3", "title": "Song", "duration": 195
|
||||
}))
|
||||
assert "(~kg-audio " in result
|
||||
assert "(~kg_cards/kg-audio " in result
|
||||
assert ':duration "3:15"' in result
|
||||
|
||||
def test_file(self):
|
||||
@@ -259,13 +259,13 @@ class TestCards:
|
||||
"type": "file", "src": "f.pdf", "fileName": "doc.pdf",
|
||||
"fileSize": 2100000
|
||||
}))
|
||||
assert "(~kg-file " in result
|
||||
assert "(~kg_cards/kg-file " in result
|
||||
assert ':filename "doc.pdf"' in result
|
||||
assert "MB" in result
|
||||
|
||||
def test_paywall(self):
|
||||
result = lexical_to_sx(_doc({"type": "paywall"}))
|
||||
assert result == "(~kg-paywall)"
|
||||
assert result == "(~kg_cards/kg-paywall)"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
10
cart/actions.sx
Normal file
10
cart/actions.sx
Normal file
@@ -0,0 +1,10 @@
|
||||
;; Cart service — inter-service action endpoints
|
||||
|
||||
(defaction adopt-cart-for-user (&key user-id session-id)
|
||||
"Transfer anonymous cart items to a logged-in user."
|
||||
(do
|
||||
(service "cart" "adopt-cart-for-user"
|
||||
:user-id user-id :session-id session-id)
|
||||
{"ok" true}))
|
||||
|
||||
;; clear-cart-for-order: remains as Python fallback (complex object construction)
|
||||
25
cart/app.py
25
cart/app.py
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
import path_setup # noqa: F401 # adds shared/ to sys.path
|
||||
import sx.sx_components as sx_components # noqa: F401 # ensure Hypercorn --reload watches this file
|
||||
from shared.sx.jinja_bridge import load_service_components # noqa: F401
|
||||
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
@@ -17,7 +17,6 @@ from bp import (
|
||||
register_page_cart,
|
||||
register_cart_global,
|
||||
register_page_admin,
|
||||
register_fragments,
|
||||
register_actions,
|
||||
register_data,
|
||||
register_inbox,
|
||||
@@ -141,7 +140,12 @@ def create_app() -> "Quart":
|
||||
app.jinja_env.globals["cart_quantity_url"] = lambda product_id: f"/quantity/{product_id}/"
|
||||
app.jinja_env.globals["cart_delete_url"] = lambda product_id: f"/delete/{product_id}/"
|
||||
|
||||
app.register_blueprint(register_fragments())
|
||||
import os as _os
|
||||
load_service_components(_os.path.dirname(_os.path.abspath(__file__)), service_name="cart")
|
||||
|
||||
from shared.sx.handlers import auto_mount_fragment_handlers
|
||||
auto_mount_fragment_handlers(app, "cart")
|
||||
|
||||
app.register_blueprint(register_actions())
|
||||
app.register_blueprint(register_data())
|
||||
app.register_blueprint(register_inbox())
|
||||
@@ -185,8 +189,6 @@ def create_app() -> "Quart":
|
||||
from sxc.pages import setup_cart_pages
|
||||
setup_cart_pages()
|
||||
|
||||
from shared.sx.pages import mount_pages
|
||||
|
||||
# --- Blueprint registration ---
|
||||
# Static prefixes first, dynamic (page_slug) last
|
||||
|
||||
@@ -196,21 +198,22 @@ def create_app() -> "Quart":
|
||||
url_prefix="/",
|
||||
)
|
||||
|
||||
# Cart overview at GET /
|
||||
# Cart overview blueprint (no defpage routes, just action endpoints)
|
||||
overview_bp = register_cart_overview(url_prefix="/")
|
||||
mount_pages(overview_bp, "cart", names=["cart-overview"])
|
||||
app.register_blueprint(overview_bp, url_prefix="/")
|
||||
|
||||
# Page admin at /<page_slug>/admin/ (before page_cart catch-all)
|
||||
# Page admin (PUT /payments/ etc.)
|
||||
admin_bp = register_page_admin()
|
||||
mount_pages(admin_bp, "cart", names=["cart-admin", "cart-payments"])
|
||||
app.register_blueprint(admin_bp, url_prefix="/<page_slug>/admin")
|
||||
|
||||
# Page cart at /<page_slug>/ (dynamic, matched last)
|
||||
# Page cart (POST /checkout/ etc.)
|
||||
page_cart_bp = register_page_cart(url_prefix="/")
|
||||
mount_pages(page_cart_bp, "cart", names=["page-cart-view"])
|
||||
app.register_blueprint(page_cart_bp, url_prefix="/<page_slug>")
|
||||
|
||||
# Auto-mount all defpages with absolute paths
|
||||
from shared.sx.pages import auto_mount_pages
|
||||
auto_mount_pages(app, "cart")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ from .cart.overview_routes import register as register_cart_overview
|
||||
from .cart.page_routes import register as register_page_cart
|
||||
from .cart.global_routes import register as register_cart_global
|
||||
from .page_admin.routes import register as register_page_admin
|
||||
from .fragments import register_fragments
|
||||
from .actions import register_actions
|
||||
from .data import register_data
|
||||
from .inbox import register_inbox
|
||||
|
||||
@@ -1,64 +1,26 @@
|
||||
"""Cart app action endpoints.
|
||||
|
||||
Exposes write operations at ``/internal/actions/<action_name>`` for
|
||||
cross-app callers (login handler) via the internal action client.
|
||||
adopt-cart-for-user is defined in ``cart/actions.sx``.
|
||||
clear-cart-for-order remains as a Python fallback (complex object construction).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, jsonify, request
|
||||
from quart import Blueprint, g, request
|
||||
|
||||
from shared.infrastructure.actions import ACTION_HEADER
|
||||
from shared.services.registry import services
|
||||
from shared.infrastructure.query_blueprint import create_action_blueprint
|
||||
|
||||
|
||||
def register() -> Blueprint:
|
||||
bp = Blueprint("actions", __name__, url_prefix="/internal/actions")
|
||||
bp, _handlers = create_action_blueprint("cart")
|
||||
|
||||
@bp.before_request
|
||||
async def _require_action_header():
|
||||
if not request.headers.get(ACTION_HEADER):
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
from shared.infrastructure.internal_auth import validate_internal_request
|
||||
if not validate_internal_request():
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
|
||||
_handlers: dict[str, object] = {}
|
||||
|
||||
@bp.post("/<action_name>")
|
||||
async def handle_action(action_name: str):
|
||||
handler = _handlers.get(action_name)
|
||||
if handler is None:
|
||||
return jsonify({"error": "unknown action"}), 404
|
||||
try:
|
||||
result = await handler()
|
||||
return jsonify(result)
|
||||
except Exception as exc:
|
||||
import logging
|
||||
logging.getLogger(__name__).exception("Action %s failed", action_name)
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
|
||||
# --- adopt-cart-for-user ---
|
||||
async def _adopt_cart():
|
||||
data = await request.get_json()
|
||||
await services.cart.adopt_cart_for_user(
|
||||
g.s, data["user_id"], data["session_id"],
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
_handlers["adopt-cart-for-user"] = _adopt_cart
|
||||
|
||||
# --- clear-cart-for-order ---
|
||||
async def _clear_cart_for_order():
|
||||
"""Soft-delete cart items after an order is paid. Called by orders service."""
|
||||
from bp.cart.services.clear_cart_for_order import clear_cart_for_order
|
||||
from shared.models.order import Order
|
||||
|
||||
data = await request.get_json()
|
||||
user_id = data.get("user_id")
|
||||
session_id = data.get("session_id")
|
||||
page_post_id = data.get("page_post_id")
|
||||
|
||||
# Build a minimal order-like object with the fields clear_cart_for_order needs
|
||||
order = type("_Order", (), {
|
||||
"user_id": user_id,
|
||||
"session_id": session_id,
|
||||
|
||||
@@ -56,9 +56,9 @@ def register(url_prefix: str) -> Blueprint:
|
||||
|
||||
if request.headers.get("SX-Request") == "true" or request.headers.get("HX-Request") == "true":
|
||||
# Redirect to overview for HTMX
|
||||
return redirect(url_for("cart_overview.defpage_cart_overview"))
|
||||
return redirect(url_for("defpage_cart_overview"))
|
||||
|
||||
return redirect(url_for("cart_overview.defpage_cart_overview"))
|
||||
return redirect(url_for("defpage_cart_overview"))
|
||||
|
||||
@bp.post("/quantity/<int:product_id>/")
|
||||
async def update_quantity(product_id: int):
|
||||
@@ -137,7 +137,7 @@ def register(url_prefix: str) -> Blueprint:
|
||||
tickets = await get_ticket_cart_entries(g.s)
|
||||
|
||||
if not cart and not calendar_entries and not tickets:
|
||||
return redirect(url_for("cart_overview.defpage_cart_overview"))
|
||||
return redirect(url_for("defpage_cart_overview"))
|
||||
|
||||
product_total = total(cart) or 0
|
||||
calendar_amount = calendar_total(calendar_entries) or 0
|
||||
@@ -145,13 +145,13 @@ def register(url_prefix: str) -> Blueprint:
|
||||
cart_total = product_total + calendar_amount + ticket_amount
|
||||
|
||||
if cart_total <= 0:
|
||||
return redirect(url_for("cart_overview.defpage_cart_overview"))
|
||||
return redirect(url_for("defpage_cart_overview"))
|
||||
|
||||
try:
|
||||
page_config = await resolve_page_config(g.s, cart, calendar_entries, tickets)
|
||||
except ValueError as e:
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_checkout_error_page
|
||||
from sxc.pages.renders import render_checkout_error_page
|
||||
tctx = await get_template_context()
|
||||
html = await render_checkout_error_page(tctx, error=str(e))
|
||||
return await make_response(html, 400)
|
||||
@@ -208,7 +208,7 @@ def register(url_prefix: str) -> Blueprint:
|
||||
|
||||
if not hosted_url:
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_checkout_error_page
|
||||
from sxc.pages.renders import render_checkout_error_page
|
||||
tctx = await get_template_context()
|
||||
html = await render_checkout_error_page(tctx, error="No hosted checkout URL returned from SumUp.")
|
||||
return await make_response(html, 500)
|
||||
|
||||
@@ -3,24 +3,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, request
|
||||
|
||||
from .services import get_cart_grouped_by_page
|
||||
from quart import Blueprint
|
||||
|
||||
|
||||
def register(url_prefix: str) -> Blueprint:
|
||||
bp = Blueprint("cart_overview", __name__, url_prefix=url_prefix)
|
||||
|
||||
@bp.before_request
|
||||
async def _prepare_page_data():
|
||||
"""Load overview data for defpage route."""
|
||||
endpoint = request.endpoint or ""
|
||||
if not endpoint.endswith("defpage_cart_overview"):
|
||||
return
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _overview_main_panel_sx
|
||||
page_groups = await get_cart_grouped_by_page(g.s)
|
||||
ctx = await get_template_context()
|
||||
g.overview_content = _overview_main_panel_sx(page_groups, ctx)
|
||||
|
||||
return bp
|
||||
|
||||
@@ -19,26 +19,6 @@ from .services import current_cart_identity
|
||||
def register(url_prefix: str) -> Blueprint:
|
||||
bp = Blueprint("page_cart", __name__, url_prefix=url_prefix)
|
||||
|
||||
@bp.before_request
|
||||
async def _prepare_page_data():
|
||||
"""Load page cart data for defpage route."""
|
||||
endpoint = request.endpoint or ""
|
||||
if not endpoint.endswith("defpage_page_cart_view"):
|
||||
return
|
||||
post = g.page_post
|
||||
cart = await get_cart_for_page(g.s, post.id)
|
||||
cal_entries = await get_calendar_entries_for_page(g.s, post.id)
|
||||
page_tickets = await get_tickets_for_page(g.s, post.id)
|
||||
ticket_groups = group_tickets(page_tickets)
|
||||
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _page_cart_main_panel_sx
|
||||
ctx = await get_template_context()
|
||||
g.page_cart_content = _page_cart_main_panel_sx(
|
||||
ctx, cart, cal_entries, page_tickets, ticket_groups,
|
||||
total, calendar_total, ticket_total,
|
||||
)
|
||||
|
||||
@bp.post("/checkout/")
|
||||
async def page_checkout():
|
||||
post = g.page_post
|
||||
@@ -48,7 +28,7 @@ def register(url_prefix: str) -> Blueprint:
|
||||
page_tickets = await get_tickets_for_page(g.s, post.id)
|
||||
|
||||
if not cart and not cal_entries and not page_tickets:
|
||||
return redirect(url_for("page_cart.defpage_page_cart_view"))
|
||||
return redirect(url_for("defpage_page_cart_view"))
|
||||
|
||||
product_total_val = total(cart) or 0
|
||||
calendar_amount = calendar_total(cal_entries) or 0
|
||||
@@ -56,7 +36,7 @@ def register(url_prefix: str) -> Blueprint:
|
||||
cart_total = product_total_val + calendar_amount + ticket_amount
|
||||
|
||||
if cart_total <= 0:
|
||||
return redirect(url_for("page_cart.defpage_page_cart_view"))
|
||||
return redirect(url_for("defpage_page_cart_view"))
|
||||
|
||||
ident = current_cart_identity()
|
||||
|
||||
@@ -93,7 +73,7 @@ def register(url_prefix: str) -> Blueprint:
|
||||
|
||||
if not hosted_url:
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_checkout_error_page
|
||||
from sxc.pages.renders import render_checkout_error_page
|
||||
tctx = await get_template_context()
|
||||
html = await render_checkout_error_page(tctx, error="No hosted checkout URL returned from SumUp.")
|
||||
return await make_response(html, 500)
|
||||
|
||||
@@ -1,79 +1,14 @@
|
||||
"""Cart app data endpoints.
|
||||
|
||||
Exposes read-only JSON queries at ``/internal/data/<query_name>`` for
|
||||
cross-app callers via the internal data client.
|
||||
All queries are defined in ``cart/queries.sx``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, jsonify, request
|
||||
from quart import Blueprint
|
||||
|
||||
from shared.infrastructure.data_client import DATA_HEADER
|
||||
from shared.contracts.dtos import dto_to_dict
|
||||
from shared.services.registry import services
|
||||
from shared.infrastructure.query_blueprint import create_data_blueprint
|
||||
|
||||
|
||||
def register() -> Blueprint:
|
||||
bp = Blueprint("data", __name__, url_prefix="/internal/data")
|
||||
|
||||
@bp.before_request
|
||||
async def _require_data_header():
|
||||
if not request.headers.get(DATA_HEADER):
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
from shared.infrastructure.internal_auth import validate_internal_request
|
||||
if not validate_internal_request():
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
|
||||
_handlers: dict[str, object] = {}
|
||||
|
||||
@bp.get("/<query_name>")
|
||||
async def handle_query(query_name: str):
|
||||
handler = _handlers.get(query_name)
|
||||
if handler is None:
|
||||
return jsonify({"error": "unknown query"}), 404
|
||||
result = await handler()
|
||||
return jsonify(result)
|
||||
|
||||
# --- cart-summary ---
|
||||
async def _cart_summary():
|
||||
user_id = request.args.get("user_id", type=int)
|
||||
session_id = request.args.get("session_id")
|
||||
page_slug = request.args.get("page_slug")
|
||||
summary = await services.cart.cart_summary(
|
||||
g.s, user_id=user_id, session_id=session_id, page_slug=page_slug,
|
||||
)
|
||||
return dto_to_dict(summary)
|
||||
|
||||
_handlers["cart-summary"] = _cart_summary
|
||||
|
||||
# --- cart-items (product slugs + quantities for template rendering) ---
|
||||
async def _cart_items():
|
||||
from sqlalchemy import select
|
||||
from shared.models.market import CartItem
|
||||
|
||||
user_id = request.args.get("user_id", type=int)
|
||||
session_id = request.args.get("session_id")
|
||||
|
||||
filters = [CartItem.deleted_at.is_(None)]
|
||||
if user_id is not None:
|
||||
filters.append(CartItem.user_id == user_id)
|
||||
elif session_id is not None:
|
||||
filters.append(CartItem.session_id == session_id)
|
||||
else:
|
||||
return []
|
||||
|
||||
result = await g.s.execute(
|
||||
select(CartItem).where(*filters)
|
||||
)
|
||||
items = result.scalars().all()
|
||||
return [
|
||||
{
|
||||
"product_id": item.product_id,
|
||||
"product_slug": item.product_slug,
|
||||
"quantity": item.quantity,
|
||||
}
|
||||
for item in items
|
||||
]
|
||||
|
||||
_handlers["cart-items"] = _cart_items
|
||||
|
||||
bp, _handlers = create_data_blueprint("cart")
|
||||
return bp
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
from .routes import register as register_fragments
|
||||
@@ -1,36 +0,0 @@
|
||||
"""Cart app fragment endpoints.
|
||||
|
||||
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
|
||||
by other coop apps via the fragment client.
|
||||
|
||||
All handlers are defined declaratively in .sx files under
|
||||
``cart/sx/handlers/`` and dispatched via the sx handler registry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, Response, request
|
||||
|
||||
from shared.infrastructure.fragments import FRAGMENT_HEADER
|
||||
from shared.sx.handlers import get_handler, execute_handler
|
||||
|
||||
|
||||
def register():
|
||||
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
|
||||
|
||||
@bp.before_request
|
||||
async def _require_fragment_header():
|
||||
if not request.headers.get(FRAGMENT_HEADER):
|
||||
return Response("", status=403)
|
||||
|
||||
@bp.get("/<fragment_type>")
|
||||
async def get_fragment(fragment_type: str):
|
||||
handler_def = get_handler("cart", fragment_type)
|
||||
if handler_def is not None:
|
||||
result = await execute_handler(
|
||||
handler_def, "cart", args=dict(request.args),
|
||||
)
|
||||
return Response(result, status=200, content_type="text/sx")
|
||||
return Response("", status=200, content_type="text/sx")
|
||||
|
||||
return bp
|
||||
@@ -57,7 +57,7 @@ def register() -> Blueprint:
|
||||
if not order:
|
||||
return await make_response("Order not found", 404)
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_order_page, render_order_oob
|
||||
from sxc.pages.renders import render_order_page, render_order_oob
|
||||
|
||||
ctx = await get_template_context()
|
||||
calendar_entries = ctx.get("calendar_entries")
|
||||
@@ -122,7 +122,7 @@ def register() -> Blueprint:
|
||||
|
||||
if not hosted_url:
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_checkout_error_page
|
||||
from sxc.pages.renders import render_checkout_error_page
|
||||
tctx = await get_template_context()
|
||||
html = await render_checkout_error_page(tctx, error="No hosted checkout URL returned from SumUp when trying to reopen payment.", order=order)
|
||||
return await make_response(html, 500)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, render_template, redirect, url_for, make_response
|
||||
from quart import Blueprint, g, redirect, url_for, make_response
|
||||
from sqlalchemy import select, func, or_, cast, String, exists
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
@@ -138,7 +138,7 @@ def register(url_prefix: str) -> Blueprint:
|
||||
orders = result.scalars().all()
|
||||
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import (
|
||||
from sxc.pages.renders import (
|
||||
render_orders_page,
|
||||
render_orders_rows,
|
||||
render_orders_oob,
|
||||
@@ -154,7 +154,7 @@ def register(url_prefix: str) -> Blueprint:
|
||||
)
|
||||
resp = await make_response(html)
|
||||
elif page > 1:
|
||||
sx_src = await render_orders_rows(
|
||||
sx_src = render_orders_rows(
|
||||
ctx, orders, page, total_pages, url_for, qs_fn,
|
||||
)
|
||||
resp = sx_response(sx_src)
|
||||
|
||||
@@ -13,23 +13,6 @@ from shared.sx.helpers import sx_response
|
||||
def register():
|
||||
bp = Blueprint("page_admin", __name__)
|
||||
|
||||
@bp.before_request
|
||||
async def _prepare_page_data():
|
||||
"""Pre-render admin content for defpage routes."""
|
||||
endpoint = request.endpoint or ""
|
||||
if request.method != "GET":
|
||||
return
|
||||
if endpoint.endswith("defpage_cart_admin"):
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _cart_admin_main_panel_sx
|
||||
ctx = await get_template_context()
|
||||
g.cart_admin_content = _cart_admin_main_panel_sx(ctx)
|
||||
elif endpoint.endswith("defpage_cart_payments"):
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _cart_payments_main_panel_sx
|
||||
ctx = await get_template_context()
|
||||
g.cart_payments_content = _cart_payments_main_panel_sx(ctx)
|
||||
|
||||
@bp.put("/payments/")
|
||||
@require_admin
|
||||
async def update_sumup(**kwargs):
|
||||
@@ -64,7 +47,7 @@ def register():
|
||||
g.page_config = SimpleNamespace(**raw_pc) if raw_pc else None
|
||||
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_cart_payments_panel
|
||||
from sxc.pages.renders import render_cart_payments_panel
|
||||
ctx = await get_template_context()
|
||||
html = render_cart_payments_panel(ctx)
|
||||
return sx_response(html)
|
||||
|
||||
11
cart/queries.sx
Normal file
11
cart/queries.sx
Normal file
@@ -0,0 +1,11 @@
|
||||
;; Cart service — inter-service data queries
|
||||
|
||||
(defquery cart-summary (&key user-id session-id page-slug)
|
||||
"Cart summary for a user or session, optionally filtered by page."
|
||||
(service "cart" "cart-summary"
|
||||
:user-id user-id :session-id session-id :page-slug page-slug))
|
||||
|
||||
(defquery cart-items (&key user-id session-id)
|
||||
"Product slugs and quantities in the cart."
|
||||
(service "cart-data" "cart-items"
|
||||
:user-id user-id :session-id session-id))
|
||||
@@ -12,3 +12,9 @@ def register_domain_services() -> None:
|
||||
from shared.services.cart_impl import SqlCartService
|
||||
|
||||
services.cart = SqlCartService()
|
||||
|
||||
from shared.services.cart_items_impl import SqlCartItemsService
|
||||
services.register("cart_data", SqlCartItemsService())
|
||||
|
||||
from .cart_page import CartPageService
|
||||
services.register("cart_page", CartPageService())
|
||||
|
||||
226
cart/services/cart_page.py
Normal file
226
cart/services/cart_page.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""Cart page data service — provides serialized dicts for .sx defpages."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _serialize_cart_item(item: Any) -> dict:
|
||||
from quart import url_for
|
||||
from shared.infrastructure.urls import market_product_url
|
||||
|
||||
p = item.product if hasattr(item, "product") else item
|
||||
slug = p.slug if hasattr(p, "slug") else ""
|
||||
unit_price = getattr(p, "special_price", None) or getattr(p, "regular_price", None)
|
||||
currency = getattr(p, "regular_price_currency", "GBP") or "GBP"
|
||||
return {
|
||||
"slug": slug,
|
||||
"title": p.title if hasattr(p, "title") else "",
|
||||
"image": p.image if hasattr(p, "image") else None,
|
||||
"brand": getattr(p, "brand", None),
|
||||
"is_deleted": getattr(item, "is_deleted", False),
|
||||
"unit_price": float(unit_price) if unit_price else None,
|
||||
"special_price": float(p.special_price) if getattr(p, "special_price", None) else None,
|
||||
"regular_price": float(p.regular_price) if getattr(p, "regular_price", None) else None,
|
||||
"currency": currency,
|
||||
"quantity": item.quantity,
|
||||
"product_id": p.id,
|
||||
"product_url": market_product_url(slug),
|
||||
"qty_url": url_for("cart_global.update_quantity", product_id=p.id),
|
||||
}
|
||||
|
||||
|
||||
def _serialize_cal_entry(e: Any) -> dict:
|
||||
name = getattr(e, "name", None) or getattr(e, "calendar_name", "")
|
||||
start = e.start_at if hasattr(e, "start_at") else ""
|
||||
end = getattr(e, "end_at", None)
|
||||
cost = getattr(e, "cost", 0) or 0
|
||||
end_str = f" \u2013 {end}" if end else ""
|
||||
return {
|
||||
"name": name,
|
||||
"date_str": f"{start}{end_str}",
|
||||
"cost": float(cost),
|
||||
}
|
||||
|
||||
|
||||
def _serialize_ticket_group(tg: Any) -> dict:
|
||||
name = tg.entry_name if hasattr(tg, "entry_name") else tg.get("entry_name", "")
|
||||
tt_name = tg.ticket_type_name if hasattr(tg, "ticket_type_name") else tg.get("ticket_type_name", "")
|
||||
price = tg.price if hasattr(tg, "price") else tg.get("price", 0)
|
||||
quantity = tg.quantity if hasattr(tg, "quantity") else tg.get("quantity", 0)
|
||||
line_total = tg.line_total if hasattr(tg, "line_total") else tg.get("line_total", 0)
|
||||
entry_id = tg.entry_id if hasattr(tg, "entry_id") else tg.get("entry_id", "")
|
||||
tt_id = tg.ticket_type_id if hasattr(tg, "ticket_type_id") else tg.get("ticket_type_id", "")
|
||||
start_at = tg.entry_start_at if hasattr(tg, "entry_start_at") else tg.get("entry_start_at")
|
||||
end_at = tg.entry_end_at if hasattr(tg, "entry_end_at") else tg.get("entry_end_at")
|
||||
|
||||
date_str = start_at.strftime("%-d %b %Y, %H:%M") if start_at else ""
|
||||
if end_at:
|
||||
date_str += f" \u2013 {end_at.strftime('%-d %b %Y, %H:%M')}"
|
||||
|
||||
return {
|
||||
"entry_name": name,
|
||||
"ticket_type_name": tt_name or None,
|
||||
"price": float(price or 0),
|
||||
"quantity": quantity,
|
||||
"line_total": float(line_total or 0),
|
||||
"entry_id": entry_id,
|
||||
"ticket_type_id": tt_id or None,
|
||||
"date_str": date_str,
|
||||
}
|
||||
|
||||
|
||||
def _serialize_page_group(grp: Any) -> dict | None:
|
||||
post = grp.get("post") if isinstance(grp, dict) else getattr(grp, "post", None)
|
||||
cart_items = grp.get("cart_items", []) if isinstance(grp, dict) else getattr(grp, "cart_items", [])
|
||||
cal_entries = grp.get("calendar_entries", []) if isinstance(grp, dict) else getattr(grp, "calendar_entries", [])
|
||||
tickets = grp.get("tickets", []) if isinstance(grp, dict) else getattr(grp, "tickets", [])
|
||||
|
||||
if not cart_items and not cal_entries and not tickets:
|
||||
return None
|
||||
|
||||
post_data = None
|
||||
if post:
|
||||
post_data = {
|
||||
"slug": post.slug if hasattr(post, "slug") else post.get("slug", ""),
|
||||
"title": post.title if hasattr(post, "title") else post.get("title", ""),
|
||||
"feature_image": post.feature_image if hasattr(post, "feature_image") else post.get("feature_image"),
|
||||
}
|
||||
market_place = grp.get("market_place") if isinstance(grp, dict) else getattr(grp, "market_place", None)
|
||||
mp_data = None
|
||||
if market_place:
|
||||
mp_data = {"name": market_place.name if hasattr(market_place, "name") else market_place.get("name", "")}
|
||||
|
||||
return {
|
||||
"post": post_data,
|
||||
"product_count": grp.get("product_count", 0) if isinstance(grp, dict) else getattr(grp, "product_count", 0),
|
||||
"calendar_count": grp.get("calendar_count", 0) if isinstance(grp, dict) else getattr(grp, "calendar_count", 0),
|
||||
"ticket_count": grp.get("ticket_count", 0) if isinstance(grp, dict) else getattr(grp, "ticket_count", 0),
|
||||
"total": float(grp.get("total", 0) if isinstance(grp, dict) else getattr(grp, "total", 0)),
|
||||
"market_place": mp_data,
|
||||
}
|
||||
|
||||
|
||||
class CartPageService:
|
||||
"""Service for cart page data, callable via (service "cart-page" ...)."""
|
||||
|
||||
async def overview_data(self, session, **kw):
|
||||
from shared.infrastructure.urls import cart_url
|
||||
from bp.cart.services import get_cart_grouped_by_page
|
||||
|
||||
page_groups = await get_cart_grouped_by_page(session)
|
||||
grp_dicts = [d for d in (_serialize_page_group(grp) for grp in page_groups) if d]
|
||||
return {
|
||||
"page_groups": grp_dicts,
|
||||
"cart_url_base": cart_url(""),
|
||||
}
|
||||
|
||||
async def page_cart_data(self, session, **kw):
|
||||
from quart import g, request, url_for
|
||||
from shared.infrastructure.urls import login_url
|
||||
from shared.utils import route_prefix
|
||||
from bp.cart.services import total, calendar_total, ticket_total
|
||||
from bp.cart.services.page_cart import (
|
||||
get_cart_for_page, get_calendar_entries_for_page, get_tickets_for_page,
|
||||
)
|
||||
from bp.cart.services.ticket_groups import group_tickets
|
||||
|
||||
post = g.page_post
|
||||
cart = await get_cart_for_page(session, post.id)
|
||||
cal_entries = await get_calendar_entries_for_page(session, post.id)
|
||||
page_tickets = await get_tickets_for_page(session, post.id)
|
||||
ticket_groups = group_tickets(page_tickets)
|
||||
|
||||
# Build summary data
|
||||
product_qty = sum(ci.quantity for ci in cart) if cart else 0
|
||||
ticket_qty = len(page_tickets) if page_tickets else 0
|
||||
item_count = product_qty + ticket_qty
|
||||
|
||||
product_total = total(cart) or 0
|
||||
cal_total = calendar_total(cal_entries) or 0
|
||||
tk_total = ticket_total(page_tickets) or 0
|
||||
grand = float(product_total) + float(cal_total) + float(tk_total)
|
||||
|
||||
symbol = "\u00a3"
|
||||
if cart and hasattr(cart[0], "product") and getattr(cart[0].product, "regular_price_currency", None):
|
||||
cur = cart[0].product.regular_price_currency
|
||||
symbol = "\u00a3" if cur == "GBP" else cur
|
||||
|
||||
user = getattr(g, "user", None)
|
||||
page_post = getattr(g, "page_post", None)
|
||||
|
||||
summary = {
|
||||
"item_count": item_count,
|
||||
"grand_total": grand,
|
||||
"symbol": symbol,
|
||||
"is_logged_in": bool(user),
|
||||
}
|
||||
|
||||
if user:
|
||||
if page_post:
|
||||
action = url_for("page_cart.page_checkout")
|
||||
else:
|
||||
action = url_for("cart_global.checkout")
|
||||
summary["checkout_action"] = route_prefix() + action
|
||||
summary["user_email"] = user.email
|
||||
else:
|
||||
summary["login_href"] = login_url(request.url)
|
||||
|
||||
return {
|
||||
"cart_items": [_serialize_cart_item(i) for i in cart],
|
||||
"cal_entries": [_serialize_cal_entry(e) for e in cal_entries],
|
||||
"ticket_groups": [_serialize_ticket_group(tg) for tg in ticket_groups],
|
||||
"summary": summary,
|
||||
}
|
||||
|
||||
async def admin_data(self, session, **kw):
|
||||
"""Populate post context for cart-admin layout headers."""
|
||||
from quart import g
|
||||
from shared.infrastructure.fragments import fetch_fragments
|
||||
|
||||
post = g.page_post
|
||||
slug = post.slug if post else ""
|
||||
post_id = post.id if post else None
|
||||
|
||||
# Fetch container_nav for post header
|
||||
container_nav = ""
|
||||
if post_id:
|
||||
nav_params = {
|
||||
"container_type": "page",
|
||||
"container_id": str(post_id),
|
||||
"post_slug": slug,
|
||||
}
|
||||
events_nav, market_nav = await fetch_fragments([
|
||||
("events", "container-nav", nav_params),
|
||||
("market", "container-nav", nav_params),
|
||||
], required=False)
|
||||
container_nav = events_nav + market_nav
|
||||
|
||||
return {
|
||||
"post": {
|
||||
"id": post_id,
|
||||
"slug": slug,
|
||||
"title": (post.title if post else "")[:160],
|
||||
"feature_image": getattr(post, "feature_image", None),
|
||||
},
|
||||
"container_nav": container_nav,
|
||||
}
|
||||
|
||||
async def payments_admin_data(self, session, **kw):
|
||||
"""Admin data + payments data combined for cart-payments page."""
|
||||
admin = await self.admin_data(session)
|
||||
payments = await self.payments_data(session)
|
||||
return {**admin, **payments}
|
||||
|
||||
async def payments_data(self, session, **kw):
|
||||
from shared.sx.page import get_template_context
|
||||
|
||||
ctx = await get_template_context()
|
||||
page_config = ctx.get("page_config")
|
||||
pc_data = None
|
||||
if page_config:
|
||||
pc_data = {
|
||||
"sumup_api_key": bool(getattr(page_config, "sumup_api_key", None)),
|
||||
"sumup_merchant_code": getattr(page_config, "sumup_merchant_code", None) or "",
|
||||
"sumup_checkout_prefix": getattr(page_config, "sumup_checkout_prefix", None) or "",
|
||||
}
|
||||
return {"page_config": pc_data}
|
||||
@@ -1,12 +1,12 @@
|
||||
;; Cart calendar entry components
|
||||
|
||||
(defcomp ~cart-cal-entry (&key name date-str cost)
|
||||
(defcomp ~calendar/cal-entry (&key (name :as string) (date-str :as string) (cost :as string))
|
||||
(li :class "flex items-start justify-between text-sm"
|
||||
(div (div :class "font-medium" name)
|
||||
(div :class "text-xs text-stone-500" date-str))
|
||||
(div :class "ml-4 font-medium" cost)))
|
||||
|
||||
(defcomp ~cart-cal-section (&key items)
|
||||
(defcomp ~calendar/cal-section (&key items)
|
||||
(div :class "mt-6 border-t border-stone-200 pt-4"
|
||||
(h2 :class "text-base font-semibold mb-2" "Calendar bookings")
|
||||
(ul :class "space-y-2" items)))
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
;; Cart account-nav-item fragment handler
|
||||
;; returns: sx
|
||||
;;
|
||||
;; Renders the "orders" link for the account dashboard nav.
|
||||
|
||||
(defhandler account-nav-item (&key)
|
||||
(~account-nav-item
|
||||
(~shared:fragments/account-nav-item
|
||||
:href (app-url "cart" "/orders/")
|
||||
:label "orders"))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
;; Cart cart-mini fragment handler
|
||||
;; returns: sx
|
||||
;;
|
||||
;; Renders the cart icon with badge (or logo when empty).
|
||||
|
||||
@@ -9,7 +10,7 @@
|
||||
(count (+ (or (get summary "count") 0)
|
||||
(or (get summary "calendar_count") 0)
|
||||
(or (get summary "ticket_count") 0))))
|
||||
(~cart-mini
|
||||
(~shared:fragments/cart-mini
|
||||
:cart-count count
|
||||
:blog-url (app-url "blog" "")
|
||||
:cart-url (app-url "cart" "")
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
;; Cart header components
|
||||
|
||||
(defcomp ~cart-page-label-img (&key src)
|
||||
(defcomp ~header/page-label-img (&key src)
|
||||
(img :src src :class "h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0"))
|
||||
|
||||
(defcomp ~cart-all-carts-link (&key href)
|
||||
(defcomp ~header/page-label (&key feature-image title)
|
||||
(<> (when feature-image
|
||||
(~header/page-label-img :src feature-image))
|
||||
(span title)))
|
||||
|
||||
(defcomp ~header/all-carts-link (&key href)
|
||||
(a :href href :class "inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"
|
||||
(i :class "fa fa-arrow-left text-xs" :aria-hidden "true") "All carts"))
|
||||
|
||||
|
||||
129
cart/sx/items.sx
129
cart/sx/items.sx
@@ -1,29 +1,29 @@
|
||||
;; Cart item components
|
||||
|
||||
(defcomp ~cart-item-img (&key src alt)
|
||||
(defcomp ~items/img (&key (src :as string) (alt :as string))
|
||||
(img :src src :alt alt :class "w-24 h-24 sm:w-32 sm:h-28 object-cover rounded-xl border border-stone-100" :loading "lazy"))
|
||||
|
||||
(defcomp ~cart-item-price (&key text)
|
||||
(defcomp ~items/price (&key (text :as string))
|
||||
(p :class "text-sm sm:text-base font-semibold text-stone-900" text))
|
||||
|
||||
(defcomp ~cart-item-price-was (&key text)
|
||||
(defcomp ~items/price-was (&key (text :as string))
|
||||
(p :class "text-xs text-stone-400 line-through" text))
|
||||
|
||||
(defcomp ~cart-item-no-price ()
|
||||
(defcomp ~items/no-price ()
|
||||
(p :class "text-xs text-stone-500" "No price"))
|
||||
|
||||
(defcomp ~cart-item-deleted ()
|
||||
(defcomp ~items/deleted ()
|
||||
(p :class "mt-2 inline-flex items-center gap-1 text-[0.65rem] sm:text-xs font-medium text-amber-700 bg-amber-50 border border-amber-200 rounded-full px-2 py-0.5"
|
||||
(i :class "fa-solid fa-triangle-exclamation text-[0.6rem]" :aria-hidden "true")
|
||||
" This item is no longer available or price has changed"))
|
||||
|
||||
(defcomp ~cart-item-brand (&key brand)
|
||||
(defcomp ~items/brand (&key (brand :as string))
|
||||
(p :class "mt-0.5 text-[0.7rem] sm:text-xs text-stone-500" brand))
|
||||
|
||||
(defcomp ~cart-item-line-total (&key text)
|
||||
(defcomp ~items/line-total (&key (text :as string))
|
||||
(p :class "text-sm sm:text-base font-semibold text-stone-900" text))
|
||||
|
||||
(defcomp ~cart-item (&key id img prod-url title brand deleted price qty-url csrf minus qty plus line-total)
|
||||
(defcomp ~items/index (&key (id :as string) img (prod-url :as string) (title :as string) brand deleted price (qty-url :as string) (csrf :as string) (minus :as string) (qty :as string) (plus :as string) line-total)
|
||||
(article :id id :class "flex flex-col sm:flex-row gap-3 sm:gap-4 rounded-2xl bg-white shadow-sm border border-stone-200 p-3 sm:p-4 md:p-5"
|
||||
(div :class "w-full sm:w-32 shrink-0 flex justify-center sm:block" (when img img))
|
||||
(div :class "flex-1 min-w-0"
|
||||
@@ -47,8 +47,119 @@
|
||||
(button :type "submit" :class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl" "+")))
|
||||
(div :class "flex items-center justify-between sm:justify-end gap-3" (when line-total line-total))))))
|
||||
|
||||
(defcomp ~cart-page-panel (&key items cal tickets summary)
|
||||
(defcomp ~items/page-panel (&key items cal tickets summary)
|
||||
(div :class "max-w-full px-3 py-3 space-y-3"
|
||||
(div :id "cart"
|
||||
(div (section :class "space-y-3 sm:space-y-4" items cal tickets)
|
||||
summary))))
|
||||
|
||||
;; Assembled cart item from serialized data — replaces Python _cart_item_sx
|
||||
(defcomp ~items/from-data (&key (item :as dict))
|
||||
(let* ((slug (or (get item "slug") ""))
|
||||
(title (or (get item "title") ""))
|
||||
(image (get item "image"))
|
||||
(brand (get item "brand"))
|
||||
(is-deleted (get item "is_deleted"))
|
||||
(unit-price (get item "unit_price"))
|
||||
(special-price (get item "special_price"))
|
||||
(regular-price (get item "regular_price"))
|
||||
(currency (or (get item "currency") "GBP"))
|
||||
(symbol (if (= currency "GBP") "\u00a3" currency))
|
||||
(quantity (or (get item "quantity") 1))
|
||||
(product-id (get item "product_id"))
|
||||
(prod-url (or (get item "product_url") ""))
|
||||
(qty-url (or (get item "qty_url") ""))
|
||||
(csrf (csrf-token))
|
||||
(line-total (when unit-price (* unit-price quantity))))
|
||||
(~items/index
|
||||
:id (str "cart-item-" slug)
|
||||
:img (if image
|
||||
(~items/img :src image :alt title)
|
||||
(~shared:misc/img-or-placeholder :src nil
|
||||
:size-cls "w-24 h-24 sm:w-32 sm:h-28 rounded-xl border border-dashed border-stone-300"
|
||||
:placeholder-text "No image"))
|
||||
:prod-url prod-url
|
||||
:title title
|
||||
:brand (when brand (~items/brand :brand brand))
|
||||
:deleted (when is-deleted (~items/deleted))
|
||||
:price (if unit-price
|
||||
(<>
|
||||
(~items/price :text (str symbol (format-decimal unit-price 2)))
|
||||
(when (and special-price (!= special-price regular-price))
|
||||
(~items/price-was :text (str symbol (format-decimal regular-price 2)))))
|
||||
(~items/no-price))
|
||||
:qty-url qty-url :csrf csrf
|
||||
:minus (str (- quantity 1))
|
||||
:qty (str quantity)
|
||||
:plus (str (+ quantity 1))
|
||||
:line-total (when line-total
|
||||
(~items/line-total :text (str "Line total: " symbol (format-decimal line-total 2)))))))
|
||||
|
||||
;; Assembled calendar entries section — replaces Python _calendar_entries_sx
|
||||
(defcomp ~items/cal-section-from-data (&key (entries :as list))
|
||||
(when (not (empty? entries))
|
||||
(~calendar/cal-section
|
||||
:items (map (lambda (e)
|
||||
(let* ((name (or (get e "name") ""))
|
||||
(date-str (or (get e "date_str") "")))
|
||||
(~calendar/cal-entry
|
||||
:name name :date-str date-str
|
||||
:cost (str "\u00a3" (format-decimal (or (get e "cost") 0) 2)))))
|
||||
entries))))
|
||||
|
||||
;; Assembled ticket groups section — replaces Python _ticket_groups_sx
|
||||
(defcomp ~items/tickets-section-from-data (&key (ticket-groups :as list))
|
||||
(when (not (empty? ticket-groups))
|
||||
(let* ((csrf (csrf-token))
|
||||
(qty-url (url-for "cart_global.update_ticket_quantity")))
|
||||
(~tickets/section
|
||||
:items (map (lambda (tg)
|
||||
(let* ((name (or (get tg "entry_name") ""))
|
||||
(tt-name (get tg "ticket_type_name"))
|
||||
(price (or (get tg "price") 0))
|
||||
(quantity (or (get tg "quantity") 0))
|
||||
(line-total (or (get tg "line_total") 0))
|
||||
(entry-id (str (or (get tg "entry_id") "")))
|
||||
(tt-id (get tg "ticket_type_id"))
|
||||
(date-str (or (get tg "date_str") "")))
|
||||
(~tickets/article
|
||||
:name name
|
||||
:type-name (when tt-name (~tickets/type-name :name tt-name))
|
||||
:date-str date-str
|
||||
:price (str "\u00a3" (format-decimal price 2))
|
||||
:qty-url qty-url :csrf csrf
|
||||
:entry-id entry-id
|
||||
:type-hidden (when tt-id (~tickets/type-hidden :value (str tt-id)))
|
||||
:minus (str (max (- quantity 1) 0))
|
||||
:qty (str quantity)
|
||||
:plus (str (+ quantity 1))
|
||||
:line-total (str "Line total: \u00a3" (format-decimal line-total 2)))))
|
||||
ticket-groups)))))
|
||||
|
||||
;; Assembled cart summary — replaces Python _cart_summary_sx
|
||||
(defcomp ~items/summary-from-data (&key (item-count :as number) (grand-total :as number) (symbol :as string) (is-logged-in :as boolean) (checkout-action :as string) (login-href :as string) (user-email :as string?))
|
||||
(~summary/panel
|
||||
:item-count (str item-count)
|
||||
:subtotal (str symbol (format-decimal grand-total 2))
|
||||
:checkout (if is-logged-in
|
||||
(~summary/checkout-form
|
||||
:action checkout-action :csrf (csrf-token)
|
||||
:label (str " Checkout as " user-email))
|
||||
(~summary/checkout-signin :href login-href))))
|
||||
|
||||
;; Assembled page cart content — replaces Python _page_cart_main_panel_sx
|
||||
(defcomp ~items/page-cart-content (&key (cart-items :as list?) (cal-entries :as list?) (ticket-groups :as list?) summary)
|
||||
(if (and (empty? (or cart-items (list)))
|
||||
(empty? (or cal-entries (list)))
|
||||
(empty? (or ticket-groups (list))))
|
||||
(div :class "max-w-full px-3 py-3 space-y-3"
|
||||
(div :id "cart"
|
||||
(div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center"
|
||||
(~shared:misc/empty-state :icon "fa fa-shopping-cart" :message "Your cart is empty" :cls "text-center"))))
|
||||
(~items/page-panel
|
||||
:items (map (lambda (item) (~items/from-data :item item)) (or cart-items (list)))
|
||||
:cal (when (not (empty? (or cal-entries (list))))
|
||||
(~items/cal-section-from-data :entries cal-entries))
|
||||
:tickets (when (not (empty? (or ticket-groups (list))))
|
||||
(~items/tickets-section-from-data :ticket-groups ticket-groups))
|
||||
:summary summary)))
|
||||
|
||||
136
cart/sx/layouts.sx
Normal file
136
cart/sx/layouts.sx
Normal file
@@ -0,0 +1,136 @@
|
||||
;; Cart layout defcomps — fully self-contained via IO primitives.
|
||||
;; Registered via register_sx_layout in __init__.py.
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Auto-fetching cart page header macros
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defmacro ~cart-page-header-auto (oob)
|
||||
"Cart page header: cart-row + page-cart-row using (cart-page-ctx)."
|
||||
(quasiquote
|
||||
(let ((__cpctx (cart-page-ctx)))
|
||||
(<>
|
||||
(~shared:layout/menu-row-sx :id "cart-row" :level 1 :colour "sky"
|
||||
:link-href (get __cpctx "cart-url")
|
||||
:link-label "cart" :icon "fa fa-shopping-cart"
|
||||
:child-id "cart-header-child")
|
||||
(~shared:layout/header-child-sx :id "cart-header-child"
|
||||
:inner (~shared:layout/menu-row-sx :id "page-cart-row" :level 2 :colour "sky"
|
||||
:link-href (get __cpctx "page-cart-url")
|
||||
:link-label-content (~header/page-label
|
||||
:feature-image (get __cpctx "feature-image")
|
||||
:title (get __cpctx "title"))
|
||||
:nav (~header/all-carts-link :href (get __cpctx "cart-url"))
|
||||
:oob (unquote oob)))))))
|
||||
|
||||
(defmacro ~cart-page-header-oob ()
|
||||
"Cart page OOB: individual oob rows."
|
||||
(quasiquote
|
||||
(let ((__cpctx (cart-page-ctx)))
|
||||
(<>
|
||||
(~shared:layout/menu-row-sx :id "page-cart-row" :level 2 :colour "sky"
|
||||
:link-href (get __cpctx "page-cart-url")
|
||||
:link-label-content (~header/page-label
|
||||
:feature-image (get __cpctx "feature-image")
|
||||
:title (get __cpctx "title"))
|
||||
:nav (~header/all-carts-link :href (get __cpctx "cart-url"))
|
||||
:oob true)
|
||||
(~shared:layout/menu-row-sx :id "cart-row" :level 1 :colour "sky"
|
||||
:link-href (get __cpctx "cart-url")
|
||||
:link-label "cart" :icon "fa fa-shopping-cart"
|
||||
:child-id "cart-header-child"
|
||||
:oob true)))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; cart-page layout: root + cart row + page-cart row
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~layouts/page-layout-full ()
|
||||
(<> (~root-header-auto)
|
||||
(~shared:layout/header-child-sx
|
||||
:inner (~cart-page-header-auto))))
|
||||
|
||||
(defcomp ~layouts/page-layout-oob ()
|
||||
(<> (~cart-page-header-oob)
|
||||
(~root-header-auto true)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; cart-admin layout: root + post header + admin header
|
||||
;; Uses (post-header-ctx) — requires :data handler to populate g._defpage_ctx
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~layouts/admin-layout-full (&key selected)
|
||||
(<> (~root-header-auto)
|
||||
(~shared:layout/header-child-sx
|
||||
:inner (~post-header-auto nil))))
|
||||
|
||||
(defcomp ~layouts/admin-layout-oob (&key selected)
|
||||
(<> (~post-header-auto true)
|
||||
(~shared:layout/oob-header-sx :parent-id "post-header-child"
|
||||
:row (~post-admin-header-auto nil selected))
|
||||
(~root-header-auto true)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; orders-within-cart: root + auth-simple + orders
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~layouts/orders-layout-full (&key list-url)
|
||||
(<> (~root-header-auto)
|
||||
(~shared:layout/header-child-sx
|
||||
:inner (<> (~auth-header-row-simple-auto)
|
||||
(~shared:layout/header-child-sx :id "auth-header-child"
|
||||
:inner (~shared:auth/orders-header-row :list-url list-url))))))
|
||||
|
||||
(defcomp ~layouts/orders-layout-oob (&key list-url)
|
||||
(<> (~auth-header-row-simple-auto true)
|
||||
(~shared:layout/oob-header-sx
|
||||
:parent-id "auth-header-child"
|
||||
:row (~shared:auth/orders-header-row :list-url list-url))
|
||||
(~root-header-auto true)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; order-detail-within-cart: root + auth-simple + orders + order
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~layouts/order-detail-layout-full (&key list-url detail-url order-label)
|
||||
(<> (~root-header-auto)
|
||||
(~shared:layout/header-child-sx
|
||||
:inner (<> (~auth-header-row-simple-auto)
|
||||
(~shared:layout/header-child-sx :id "auth-header-child"
|
||||
:inner (<> (~shared:auth/orders-header-row :list-url list-url)
|
||||
(~shared:layout/header-child-sx :id "orders-header-child"
|
||||
:inner (~shared:layout/menu-row-sx :id "order-row" :level 3 :colour "sky"
|
||||
:link-href detail-url
|
||||
:link-label order-label
|
||||
:icon "fa fa-gbp"))))))))
|
||||
|
||||
(defcomp ~layouts/order-detail-layout-oob (&key detail-url order-label)
|
||||
(<> (~shared:layout/oob-header-sx
|
||||
:parent-id "orders-header-child"
|
||||
:row (~shared:layout/menu-row-sx :id "order-row" :level 3 :colour "sky"
|
||||
:link-href detail-url :link-label order-label
|
||||
:icon "fa fa-gbp" :oob true))
|
||||
(~root-header-auto true)))
|
||||
|
||||
;; --- orders rows wrapper (for infinite scroll) ---
|
||||
|
||||
(defcomp ~layouts/orders-rows (&key rows next-scroll)
|
||||
(<> rows next-scroll))
|
||||
|
||||
;; Composition defcomp — replaces Python loop in render_orders_rows
|
||||
(defcomp ~layouts/orders-rows-content (&key orders detail-url-prefix page total-pages next-url)
|
||||
(~layouts/orders-rows
|
||||
:rows (map (lambda (od)
|
||||
(~shared:orders/row-pair :order od :detail-url-prefix detail-url-prefix))
|
||||
(or orders (list)))
|
||||
:next-scroll (if (< page total-pages)
|
||||
(~shared:controls/infinite-scroll :url next-url :page page
|
||||
:total-pages total-pages :id-prefix "orders" :colspan 5)
|
||||
(~shared:orders/end-row))))
|
||||
|
||||
;; Composition defcomp — replaces conditional composition in render_checkout_error_page
|
||||
(defcomp ~layouts/checkout-error-from-data (&key msg order-id back-url)
|
||||
(~shared:orders/checkout-error-content
|
||||
:msg msg
|
||||
:order (when order-id (~shared:orders/checkout-error-order-id :oid (str "#" order-id)))
|
||||
:back-url back-url))
|
||||
@@ -1,20 +1,20 @@
|
||||
;; Cart overview components
|
||||
|
||||
(defcomp ~cart-badge (&key icon text)
|
||||
(defcomp ~overview/badge (&key (icon :as string) (text :as string))
|
||||
(span :class "inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-stone-100"
|
||||
(i :class icon :aria-hidden "true") text))
|
||||
|
||||
(defcomp ~cart-badges-wrap (&key badges)
|
||||
(defcomp ~overview/badges-wrap (&key badges)
|
||||
(div :class "mt-1 flex flex-wrap gap-2 text-xs text-stone-600"
|
||||
badges))
|
||||
|
||||
(defcomp ~cart-group-card-img (&key src alt)
|
||||
(defcomp ~overview/group-card-img (&key (src :as string) (alt :as string))
|
||||
(img :src src :alt alt :class "h-16 w-16 rounded-xl object-cover border border-stone-200 flex-shrink-0"))
|
||||
|
||||
(defcomp ~cart-mp-subtitle (&key title)
|
||||
(defcomp ~overview/mp-subtitle (&key (title :as string))
|
||||
(p :class "text-xs text-stone-500 truncate" title))
|
||||
|
||||
(defcomp ~cart-group-card (&key href img display-title subtitle badges total)
|
||||
(defcomp ~overview/group-card (&key (href :as string) img (display-title :as string) subtitle badges (total :as string))
|
||||
(a :href href :class "block rounded-2xl border border-stone-200 bg-white shadow-sm hover:shadow-md hover:border-stone-300 transition p-4 sm:p-5"
|
||||
(div :class "flex items-start gap-4"
|
||||
img
|
||||
@@ -25,7 +25,7 @@
|
||||
(div :class "text-lg font-bold text-stone-900" total)
|
||||
(div :class "mt-1 text-xs text-emerald-700 font-medium" "View cart \u2192")))))
|
||||
|
||||
(defcomp ~cart-orphan-card (&key badges total)
|
||||
(defcomp ~overview/orphan-card (&key badges (total :as string))
|
||||
(div :class "rounded-2xl border border-dashed border-amber-300 bg-amber-50/60 p-4 sm:p-5"
|
||||
(div :class "flex items-start gap-4"
|
||||
(div :class "h-16 w-16 rounded-xl bg-amber-100 flex items-center justify-center flex-shrink-0"
|
||||
@@ -36,6 +36,59 @@
|
||||
(div :class "text-right flex-shrink-0"
|
||||
(div :class "text-lg font-bold text-stone-900" total)))))
|
||||
|
||||
(defcomp ~cart-overview-panel (&key cards)
|
||||
(defcomp ~overview/panel (&key cards)
|
||||
(div :class "max-w-full px-3 py-3 space-y-3"
|
||||
(div :class "space-y-4" cards)))
|
||||
|
||||
(defcomp ~overview/empty ()
|
||||
(div :class "max-w-full px-3 py-3 space-y-3"
|
||||
(div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center"
|
||||
(~shared:misc/empty-state :icon "fa fa-shopping-cart" :message "Your cart is empty" :cls "text-center"))))
|
||||
|
||||
;; Assembled page group card — replaces Python _page_group_card_sx
|
||||
(defcomp ~overview/page-group-card-from-data (&key (grp :as dict) (cart-url-base :as string))
|
||||
(let* ((post (get grp "post"))
|
||||
(product-count (or (get grp "product_count") 0))
|
||||
(calendar-count (or (get grp "calendar_count") 0))
|
||||
(ticket-count (or (get grp "ticket_count") 0))
|
||||
(total (or (get grp "total") 0))
|
||||
(market-place (get grp "market_place"))
|
||||
(badges (<>
|
||||
(when (> product-count 0)
|
||||
(~overview/badge :icon "fa fa-box-open"
|
||||
:text (str product-count " item" (pluralize product-count))))
|
||||
(when (> calendar-count 0)
|
||||
(~overview/badge :icon "fa fa-calendar"
|
||||
:text (str calendar-count " booking" (pluralize calendar-count))))
|
||||
(when (> ticket-count 0)
|
||||
(~overview/badge :icon "fa fa-ticket"
|
||||
:text (str ticket-count " ticket" (pluralize ticket-count)))))))
|
||||
(if post
|
||||
(let* ((slug (or (get post "slug") ""))
|
||||
(title (or (get post "title") ""))
|
||||
(feature-image (get post "feature_image"))
|
||||
(mp-name (if market-place (or (get market-place "name") "") ""))
|
||||
(display-title (if (!= mp-name "") mp-name title)))
|
||||
(~overview/group-card
|
||||
:href (str cart-url-base "/" slug "/")
|
||||
:img (if feature-image
|
||||
(~overview/group-card-img :src feature-image :alt title)
|
||||
(~shared:misc/img-or-placeholder :src nil :size-cls "h-16 w-16 rounded-xl"
|
||||
:placeholder-icon "fa fa-store text-xl"))
|
||||
:display-title display-title
|
||||
:subtitle (when (!= mp-name "")
|
||||
(~overview/mp-subtitle :title title))
|
||||
:badges (~overview/badges-wrap :badges badges)
|
||||
:total (str "\u00a3" (format-decimal total 2))))
|
||||
(~overview/orphan-card
|
||||
:badges (~overview/badges-wrap :badges badges)
|
||||
:total (str "\u00a3" (format-decimal total 2))))))
|
||||
|
||||
;; Assembled cart overview content — replaces Python _overview_main_panel_sx
|
||||
(defcomp ~overview/content (&key (page-groups :as list) (cart-url-base :as string))
|
||||
(if (empty? page-groups)
|
||||
(~overview/empty)
|
||||
(~overview/panel
|
||||
:cards (map (lambda (grp)
|
||||
(~overview/page-group-card-from-data :grp grp :cart-url-base cart-url-base))
|
||||
page-groups))))
|
||||
|
||||
@@ -1,7 +1,31 @@
|
||||
;; Cart payments components
|
||||
|
||||
(defcomp ~cart-payments-panel (&key update-url csrf merchant-code placeholder input-cls sumup-configured checkout-prefix)
|
||||
(defcomp ~payments/panel (&key update-url csrf merchant-code placeholder input-cls sumup-configured checkout-prefix)
|
||||
(section :class "p-4 max-w-lg mx-auto"
|
||||
(~sumup-settings-form :update-url update-url :csrf csrf :merchant-code merchant-code
|
||||
(~shared:misc/sumup-settings-form :update-url update-url :csrf csrf :merchant-code merchant-code
|
||||
:placeholder placeholder :input-cls input-cls :sumup-configured sumup-configured
|
||||
:checkout-prefix checkout-prefix :sx-select "#payments-panel")))
|
||||
|
||||
;; Assembled cart admin overview content
|
||||
(defcomp ~payments/admin-content ()
|
||||
(let* ((payments-href (url-for "defpage_cart_payments")))
|
||||
(div :id "main-panel"
|
||||
(div :class "flex items-center justify-between p-3 border-b"
|
||||
(span :class "font-medium" (i :class "fa fa-credit-card text-purple-600 mr-1") " Payments")
|
||||
(a :href payments-href :class "text-sm underline" "configure")))))
|
||||
|
||||
;; Assembled cart payments content
|
||||
(defcomp ~payments/content (&key page-config)
|
||||
(let* ((sumup-configured (and page-config (get page-config "sumup_api_key")))
|
||||
(merchant-code (or (get page-config "sumup_merchant_code") ""))
|
||||
(checkout-prefix (or (get page-config "sumup_checkout_prefix") ""))
|
||||
(placeholder (if sumup-configured "--------" "sup_sk_..."))
|
||||
(input-cls "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"))
|
||||
(~payments/panel
|
||||
:update-url (url-for "page_admin.update_sumup")
|
||||
:csrf (csrf-token)
|
||||
:merchant-code merchant-code
|
||||
:placeholder placeholder
|
||||
:input-cls input-cls
|
||||
:sumup-configured sumup-configured
|
||||
:checkout-prefix checkout-prefix)))
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
;; Cart summary / checkout components
|
||||
|
||||
(defcomp ~cart-checkout-form (&key action csrf label)
|
||||
(defcomp ~summary/checkout-form (&key (action :as string) (csrf :as string) (label :as string))
|
||||
(form :method "post" :action action :class "w-full"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(button :type "submit" :class "w-full inline-flex items-center justify-center px-4 py-2 text-xs sm:text-sm rounded-full border border-emerald-600 bg-emerald-600 text-white hover:bg-emerald-700 transition"
|
||||
(i :class "fa-solid fa-credit-card mr-2" :aria-hidden "true") label)))
|
||||
|
||||
(defcomp ~cart-checkout-signin (&key href)
|
||||
(defcomp ~summary/checkout-signin (&key (href :as string))
|
||||
(div :class "w-full flex"
|
||||
(a :href href :class "w-full cursor-pointer flex flex-row items-center justify-center p-3 gap-2 rounded bg-stone-200 text-black hover:bg-stone-300 transition"
|
||||
(i :class "fa-solid fa-key") (span "sign in or register to checkout"))))
|
||||
|
||||
(defcomp ~cart-summary-panel (&key item-count subtotal checkout)
|
||||
(defcomp ~summary/panel (&key (item-count :as string) (subtotal :as string) checkout)
|
||||
(aside :id "cart-summary" :class "lg:pl-2"
|
||||
(div :class "rounded-2xl bg-white shadow-sm border border-stone-200 p-4 sm:p-5"
|
||||
(h2 :class "text-sm sm:text-base font-semibold text-stone-900 mb-3 sm:mb-4" "Order summary")
|
||||
|
||||
@@ -1,807 +0,0 @@
|
||||
"""
|
||||
Cart service s-expression page components.
|
||||
|
||||
Renders cart overview, page cart, orders list, and single order detail.
|
||||
Called from route handlers in place of ``render_template()``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
from markupsafe import escape
|
||||
|
||||
from shared.sx.jinja_bridge import load_service_components
|
||||
from shared.sx.helpers import (
|
||||
call_url, root_header_sx, post_admin_header_sx,
|
||||
post_header_sx as _shared_post_header_sx,
|
||||
search_desktop_sx, search_mobile_sx,
|
||||
full_page_sx, oob_page_sx, header_child_sx,
|
||||
sx_call, SxExpr,
|
||||
)
|
||||
from shared.infrastructure.urls import market_product_url, cart_url
|
||||
|
||||
# Load cart-specific .sx components + handlers at import time
|
||||
load_service_components(os.path.dirname(os.path.dirname(__file__)),
|
||||
service_name="cart")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Header helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _ensure_post_ctx(ctx: dict, page_post: Any) -> dict:
|
||||
"""Ensure ctx has a 'post' dict from page_post DTO (for shared post_header_sx)."""
|
||||
if ctx.get("post") or not page_post:
|
||||
return ctx
|
||||
ctx = {**ctx, "post": {
|
||||
"id": getattr(page_post, "id", None),
|
||||
"slug": getattr(page_post, "slug", ""),
|
||||
"title": getattr(page_post, "title", ""),
|
||||
"feature_image": getattr(page_post, "feature_image", None),
|
||||
}}
|
||||
return ctx
|
||||
|
||||
|
||||
async def _ensure_container_nav(ctx: dict) -> dict:
|
||||
"""Fetch container_nav if not already present (for post header row)."""
|
||||
if ctx.get("container_nav"):
|
||||
return ctx
|
||||
post = ctx.get("post") or {}
|
||||
post_id = post.get("id")
|
||||
slug = post.get("slug", "")
|
||||
if not post_id:
|
||||
return ctx
|
||||
from shared.infrastructure.fragments import fetch_fragments
|
||||
nav_params = {
|
||||
"container_type": "page",
|
||||
"container_id": str(post_id),
|
||||
"post_slug": slug,
|
||||
}
|
||||
events_nav, market_nav = await fetch_fragments([
|
||||
("events", "container-nav", nav_params),
|
||||
("market", "container-nav", nav_params),
|
||||
], required=False)
|
||||
return {**ctx, "container_nav": events_nav + market_nav}
|
||||
|
||||
|
||||
async def _post_header_sx(ctx: dict, page_post: Any, *, oob: bool = False) -> str:
|
||||
"""Build post-level header row from page_post DTO, using shared helper."""
|
||||
ctx = _ensure_post_ctx(ctx, page_post)
|
||||
ctx = await _ensure_container_nav(ctx)
|
||||
return _shared_post_header_sx(ctx, oob=oob)
|
||||
|
||||
|
||||
def _cart_header_sx(ctx: dict, *, oob: bool = False) -> str:
|
||||
"""Build the cart section header row."""
|
||||
return sx_call(
|
||||
"menu-row-sx",
|
||||
id="cart-row", level=1, colour="sky",
|
||||
link_href=call_url(ctx, "cart_url", "/"),
|
||||
link_label="cart", icon="fa fa-shopping-cart",
|
||||
child_id="cart-header-child", oob=oob,
|
||||
)
|
||||
|
||||
|
||||
def _page_cart_header_sx(ctx: dict, page_post: Any, *, oob: bool = False) -> str:
|
||||
"""Build the per-page cart header row."""
|
||||
slug = page_post.slug if page_post else ""
|
||||
title = ((page_post.title if page_post else None) or "")[:160]
|
||||
label_parts = []
|
||||
if page_post and page_post.feature_image:
|
||||
label_parts.append(sx_call("cart-page-label-img", src=page_post.feature_image))
|
||||
label_parts.append(f'(span "{escape(title)}")')
|
||||
label_sx = "(<> " + " ".join(label_parts) + ")"
|
||||
nav_sx = sx_call("cart-all-carts-link", href=call_url(ctx, "cart_url", "/"))
|
||||
return sx_call(
|
||||
"menu-row-sx",
|
||||
id="page-cart-row", level=2, colour="sky",
|
||||
link_href=call_url(ctx, "cart_url", f"/{slug}/"),
|
||||
link_label_content=SxExpr(label_sx),
|
||||
nav=SxExpr(nav_sx), oob=oob,
|
||||
)
|
||||
|
||||
|
||||
def _auth_header_sx(ctx: dict, *, oob: bool = False) -> str:
|
||||
"""Build the account section header row (for orders)."""
|
||||
return sx_call(
|
||||
"menu-row-sx",
|
||||
id="auth-row", level=1, colour="sky",
|
||||
link_href=call_url(ctx, "account_url", "/"),
|
||||
link_label="account", icon="fa-solid fa-user",
|
||||
child_id="auth-header-child", oob=oob,
|
||||
)
|
||||
|
||||
|
||||
def _orders_header_sx(ctx: dict, list_url: str) -> str:
|
||||
"""Build the orders section header row."""
|
||||
return sx_call(
|
||||
"menu-row-sx",
|
||||
id="orders-row", level=2, colour="sky",
|
||||
link_href=list_url, link_label="Orders", icon="fa fa-gbp",
|
||||
child_id="orders-header-child",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cart overview
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _badge_sx(icon: str, count: int, label: str) -> str:
|
||||
"""Render a count badge."""
|
||||
s = "s" if count != 1 else ""
|
||||
return sx_call("cart-badge", icon=icon, text=f"{count} {label}{s}")
|
||||
|
||||
|
||||
def _page_group_card_sx(grp: Any, ctx: dict) -> str:
|
||||
"""Render a single page group card for cart overview."""
|
||||
post = grp.get("post") if isinstance(grp, dict) else getattr(grp, "post", None)
|
||||
cart_items = grp.get("cart_items", []) if isinstance(grp, dict) else getattr(grp, "cart_items", [])
|
||||
cal_entries = grp.get("calendar_entries", []) if isinstance(grp, dict) else getattr(grp, "calendar_entries", [])
|
||||
tickets = grp.get("tickets", []) if isinstance(grp, dict) else getattr(grp, "tickets", [])
|
||||
product_count = grp.get("product_count", 0) if isinstance(grp, dict) else getattr(grp, "product_count", 0)
|
||||
calendar_count = grp.get("calendar_count", 0) if isinstance(grp, dict) else getattr(grp, "calendar_count", 0)
|
||||
ticket_count = grp.get("ticket_count", 0) if isinstance(grp, dict) else getattr(grp, "ticket_count", 0)
|
||||
total = grp.get("total", 0) if isinstance(grp, dict) else getattr(grp, "total", 0)
|
||||
market_place = grp.get("market_place") if isinstance(grp, dict) else getattr(grp, "market_place", None)
|
||||
|
||||
if not cart_items and not cal_entries and not tickets:
|
||||
return ""
|
||||
|
||||
# Count badges
|
||||
badge_parts = []
|
||||
if product_count > 0:
|
||||
badge_parts.append(_badge_sx("fa fa-box-open", product_count, "item"))
|
||||
if calendar_count > 0:
|
||||
badge_parts.append(_badge_sx("fa fa-calendar", calendar_count, "booking"))
|
||||
if ticket_count > 0:
|
||||
badge_parts.append(_badge_sx("fa fa-ticket", ticket_count, "ticket"))
|
||||
badges_sx = "(<> " + " ".join(badge_parts) + ")" if badge_parts else '""'
|
||||
badges_wrap = sx_call("cart-badges-wrap", badges=SxExpr(badges_sx))
|
||||
|
||||
if post:
|
||||
slug = post.slug if hasattr(post, "slug") else post.get("slug", "")
|
||||
title = post.title if hasattr(post, "title") else post.get("title", "")
|
||||
feature_image = post.feature_image if hasattr(post, "feature_image") else post.get("feature_image")
|
||||
cart_href = call_url(ctx, "cart_url", f"/{slug}/")
|
||||
|
||||
if feature_image:
|
||||
img = sx_call("cart-group-card-img", src=feature_image, alt=title)
|
||||
else:
|
||||
img = sx_call("img-or-placeholder", src=None,
|
||||
size_cls="h-16 w-16 rounded-xl",
|
||||
placeholder_icon="fa fa-store text-xl")
|
||||
|
||||
mp_sub = ""
|
||||
if market_place:
|
||||
mp_name = market_place.name if hasattr(market_place, "name") else market_place.get("name", "")
|
||||
mp_sub = sx_call("cart-mp-subtitle", title=title)
|
||||
else:
|
||||
mp_name = ""
|
||||
display_title = mp_name or title
|
||||
|
||||
return sx_call(
|
||||
"cart-group-card",
|
||||
href=cart_href, img=SxExpr(img), display_title=display_title,
|
||||
subtitle=SxExpr(mp_sub) if mp_sub else None,
|
||||
badges=SxExpr(badges_wrap),
|
||||
total=f"\u00a3{total:.2f}",
|
||||
)
|
||||
else:
|
||||
# Orphan items
|
||||
return sx_call(
|
||||
"cart-orphan-card",
|
||||
badges=SxExpr(badges_wrap),
|
||||
total=f"\u00a3{total:.2f}",
|
||||
)
|
||||
|
||||
|
||||
def _empty_cart_sx() -> str:
|
||||
"""Empty cart state."""
|
||||
empty = sx_call("empty-state", icon="fa fa-shopping-cart",
|
||||
message="Your cart is empty", cls="text-center")
|
||||
return (
|
||||
'(div :class "max-w-full px-3 py-3 space-y-3"'
|
||||
' (div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center"'
|
||||
f' {empty}))'
|
||||
)
|
||||
|
||||
|
||||
def _overview_main_panel_sx(page_groups: list, ctx: dict) -> str:
|
||||
"""Cart overview main panel."""
|
||||
if not page_groups:
|
||||
return _empty_cart_sx()
|
||||
|
||||
cards = [_page_group_card_sx(grp, ctx) for grp in page_groups]
|
||||
has_items = any(c for c in cards)
|
||||
if not has_items:
|
||||
return _empty_cart_sx()
|
||||
|
||||
cards_sx = "(<> " + " ".join(c for c in cards if c) + ")"
|
||||
return sx_call("cart-overview-panel", cards=SxExpr(cards_sx))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Page cart
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _cart_item_sx(item: Any, ctx: dict) -> str:
|
||||
"""Render a single product cart item."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from quart import url_for
|
||||
|
||||
p = item.product if hasattr(item, "product") else item
|
||||
slug = p.slug if hasattr(p, "slug") else ""
|
||||
unit_price = getattr(p, "special_price", None) or getattr(p, "regular_price", None)
|
||||
currency = getattr(p, "regular_price_currency", "GBP") or "GBP"
|
||||
symbol = "\u00a3" if currency == "GBP" else currency
|
||||
csrf = generate_csrf_token()
|
||||
qty_url = url_for("cart_global.update_quantity", product_id=p.id)
|
||||
prod_url = market_product_url(slug)
|
||||
|
||||
if p.image:
|
||||
img = sx_call("cart-item-img", src=p.image, alt=p.title)
|
||||
else:
|
||||
img = sx_call("img-or-placeholder", src=None,
|
||||
size_cls="w-24 h-24 sm:w-32 sm:h-28 rounded-xl border border-dashed border-stone-300",
|
||||
placeholder_text="No image")
|
||||
|
||||
price_parts = []
|
||||
if unit_price:
|
||||
price_parts.append(sx_call("cart-item-price", text=f"{symbol}{unit_price:.2f}"))
|
||||
if p.special_price and p.special_price != p.regular_price:
|
||||
price_parts.append(sx_call("cart-item-price-was", text=f"{symbol}{p.regular_price:.2f}"))
|
||||
else:
|
||||
price_parts.append(sx_call("cart-item-no-price"))
|
||||
price_sx = "(<> " + " ".join(price_parts) + ")" if len(price_parts) > 1 else price_parts[0]
|
||||
|
||||
deleted_sx = sx_call("cart-item-deleted") if getattr(item, "is_deleted", False) else None
|
||||
|
||||
brand_sx = sx_call("cart-item-brand", brand=p.brand) if getattr(p, "brand", None) else None
|
||||
|
||||
line_total_sx = None
|
||||
if unit_price:
|
||||
lt = unit_price * item.quantity
|
||||
line_total_sx = sx_call("cart-item-line-total", text=f"Line total: {symbol}{lt:.2f}")
|
||||
|
||||
return sx_call(
|
||||
"cart-item",
|
||||
id=f"cart-item-{slug}", img=SxExpr(img), prod_url=prod_url, title=p.title,
|
||||
brand=SxExpr(brand_sx) if brand_sx else None,
|
||||
deleted=SxExpr(deleted_sx) if deleted_sx else None,
|
||||
price=SxExpr(price_sx),
|
||||
qty_url=qty_url, csrf=csrf, minus=str(item.quantity - 1),
|
||||
qty=str(item.quantity), plus=str(item.quantity + 1),
|
||||
line_total=SxExpr(line_total_sx) if line_total_sx else None,
|
||||
)
|
||||
|
||||
|
||||
def _calendar_entries_sx(entries: list) -> str:
|
||||
"""Render calendar booking entries in cart."""
|
||||
if not entries:
|
||||
return ""
|
||||
parts = []
|
||||
for e in entries:
|
||||
name = getattr(e, "name", None) or getattr(e, "calendar_name", "")
|
||||
start = e.start_at if hasattr(e, "start_at") else ""
|
||||
end = getattr(e, "end_at", None)
|
||||
cost = getattr(e, "cost", 0) or 0
|
||||
end_str = f" \u2013 {end}" if end else ""
|
||||
parts.append(sx_call(
|
||||
"cart-cal-entry",
|
||||
name=name, date_str=f"{start}{end_str}", cost=f"\u00a3{cost:.2f}",
|
||||
))
|
||||
items_sx = "(<> " + " ".join(parts) + ")"
|
||||
return sx_call("cart-cal-section", items=SxExpr(items_sx))
|
||||
|
||||
|
||||
def _ticket_groups_sx(ticket_groups: list, ctx: dict) -> str:
|
||||
"""Render ticket groups in cart."""
|
||||
if not ticket_groups:
|
||||
return ""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from quart import url_for
|
||||
|
||||
csrf = generate_csrf_token()
|
||||
qty_url = url_for("cart_global.update_ticket_quantity")
|
||||
parts = []
|
||||
|
||||
for tg in ticket_groups:
|
||||
name = tg.entry_name if hasattr(tg, "entry_name") else tg.get("entry_name", "")
|
||||
tt_name = tg.ticket_type_name if hasattr(tg, "ticket_type_name") else tg.get("ticket_type_name", "")
|
||||
price = tg.price if hasattr(tg, "price") else tg.get("price", 0)
|
||||
quantity = tg.quantity if hasattr(tg, "quantity") else tg.get("quantity", 0)
|
||||
line_total = tg.line_total if hasattr(tg, "line_total") else tg.get("line_total", 0)
|
||||
entry_id = tg.entry_id if hasattr(tg, "entry_id") else tg.get("entry_id", "")
|
||||
tt_id = tg.ticket_type_id if hasattr(tg, "ticket_type_id") else tg.get("ticket_type_id", "")
|
||||
start_at = tg.entry_start_at if hasattr(tg, "entry_start_at") else tg.get("entry_start_at")
|
||||
end_at = tg.entry_end_at if hasattr(tg, "entry_end_at") else tg.get("entry_end_at")
|
||||
|
||||
date_str = start_at.strftime("%-d %b %Y, %H:%M") if start_at else ""
|
||||
if end_at:
|
||||
date_str += f" \u2013 {end_at.strftime('%-d %b %Y, %H:%M')}"
|
||||
|
||||
tt_name_sx = sx_call("cart-ticket-type-name", name=tt_name) if tt_name else None
|
||||
tt_hidden_sx = sx_call("cart-ticket-type-hidden", value=str(tt_id)) if tt_id else None
|
||||
|
||||
parts.append(sx_call(
|
||||
"cart-ticket-article",
|
||||
name=name,
|
||||
type_name=SxExpr(tt_name_sx) if tt_name_sx else None,
|
||||
date_str=date_str,
|
||||
price=f"\u00a3{price or 0:.2f}", qty_url=qty_url, csrf=csrf,
|
||||
entry_id=str(entry_id),
|
||||
type_hidden=SxExpr(tt_hidden_sx) if tt_hidden_sx else None,
|
||||
minus=str(max(quantity - 1, 0)), qty=str(quantity),
|
||||
plus=str(quantity + 1), line_total=f"Line total: \u00a3{line_total:.2f}",
|
||||
))
|
||||
|
||||
items_sx = "(<> " + " ".join(parts) + ")"
|
||||
return sx_call("cart-tickets-section", items=SxExpr(items_sx))
|
||||
|
||||
|
||||
def _cart_summary_sx(ctx: dict, cart: list, cal_entries: list, tickets: list,
|
||||
total_fn: Any, cal_total_fn: Any, ticket_total_fn: Any) -> str:
|
||||
"""Render the order summary sidebar."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from quart import g, url_for, request
|
||||
from shared.infrastructure.urls import login_url
|
||||
|
||||
csrf = generate_csrf_token()
|
||||
product_qty = sum(ci.quantity for ci in cart) if cart else 0
|
||||
ticket_qty = len(tickets) if tickets else 0
|
||||
item_count = product_qty + ticket_qty
|
||||
|
||||
product_total = total_fn(cart) or 0
|
||||
cal_total = cal_total_fn(cal_entries) or 0
|
||||
tk_total = ticket_total_fn(tickets) or 0
|
||||
grand = float(product_total) + float(cal_total) + float(tk_total)
|
||||
|
||||
symbol = "\u00a3"
|
||||
if cart and hasattr(cart[0], "product") and getattr(cart[0].product, "regular_price_currency", None):
|
||||
cur = cart[0].product.regular_price_currency
|
||||
symbol = "\u00a3" if cur == "GBP" else cur
|
||||
|
||||
user = getattr(g, "user", None)
|
||||
page_post = ctx.get("page_post")
|
||||
|
||||
if user:
|
||||
if page_post:
|
||||
action = url_for("page_cart.page_checkout")
|
||||
else:
|
||||
action = url_for("cart_global.checkout")
|
||||
from shared.utils import route_prefix
|
||||
action = route_prefix() + action
|
||||
checkout_sx = sx_call(
|
||||
"cart-checkout-form",
|
||||
action=action, csrf=csrf, label=f" Checkout as {user.email}",
|
||||
)
|
||||
else:
|
||||
href = login_url(request.url)
|
||||
checkout_sx = sx_call("cart-checkout-signin", href=href)
|
||||
|
||||
return sx_call(
|
||||
"cart-summary-panel",
|
||||
item_count=str(item_count), subtotal=f"{symbol}{grand:.2f}",
|
||||
checkout=SxExpr(checkout_sx),
|
||||
)
|
||||
|
||||
|
||||
def _page_cart_main_panel_sx(ctx: dict, cart: list, cal_entries: list,
|
||||
tickets: list, ticket_groups: list,
|
||||
total_fn: Any, cal_total_fn: Any,
|
||||
ticket_total_fn: Any) -> str:
|
||||
"""Page cart main panel."""
|
||||
if not cart and not cal_entries and not tickets:
|
||||
empty = sx_call("empty-state", icon="fa fa-shopping-cart",
|
||||
message="Your cart is empty", cls="text-center")
|
||||
return (
|
||||
'(div :class "max-w-full px-3 py-3 space-y-3"'
|
||||
' (div :id "cart"'
|
||||
' (div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center"'
|
||||
f' {empty})))'
|
||||
)
|
||||
|
||||
item_parts = [_cart_item_sx(item, ctx) for item in cart]
|
||||
items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else '""'
|
||||
cal_sx = _calendar_entries_sx(cal_entries)
|
||||
tickets_sx = _ticket_groups_sx(ticket_groups, ctx)
|
||||
summary_sx = _cart_summary_sx(ctx, cart, cal_entries, tickets, total_fn, cal_total_fn, ticket_total_fn)
|
||||
|
||||
return sx_call(
|
||||
"cart-page-panel",
|
||||
items=SxExpr(items_sx),
|
||||
cal=SxExpr(cal_sx) if cal_sx else None,
|
||||
tickets=SxExpr(tickets_sx) if tickets_sx else None,
|
||||
summary=SxExpr(summary_sx),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Orders list (same pattern as orders service)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _order_row_sx(order: Any, detail_url: str) -> str:
|
||||
"""Render a single order as desktop table row + mobile card."""
|
||||
status = order.status or "pending"
|
||||
sl = status.lower()
|
||||
pill = (
|
||||
"border-emerald-300 bg-emerald-50 text-emerald-700" if sl == "paid"
|
||||
else "border-rose-300 bg-rose-50 text-rose-700" if sl in ("failed", "cancelled")
|
||||
else "border-stone-300 bg-stone-50 text-stone-700"
|
||||
)
|
||||
pill_cls = f"inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] sm:text-xs {pill}"
|
||||
created = order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014"
|
||||
total = f"{order.currency or 'GBP'} {order.total_amount or 0:.2f}"
|
||||
|
||||
desktop = sx_call(
|
||||
"order-row-desktop",
|
||||
oid=f"#{order.id}", created=created, desc=order.description or "",
|
||||
total=total, pill=pill_cls, status=status, url=detail_url,
|
||||
)
|
||||
|
||||
mobile_pill = f"inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] {pill}"
|
||||
mobile = sx_call(
|
||||
"order-row-mobile",
|
||||
oid=f"#{order.id}", pill=mobile_pill, status=status,
|
||||
created=created, total=total, url=detail_url,
|
||||
)
|
||||
|
||||
return "(<> " + desktop + " " + mobile + ")"
|
||||
|
||||
|
||||
def _orders_rows_sx(orders: list, page: int, total_pages: int,
|
||||
url_for_fn: Any, qs_fn: Any) -> str:
|
||||
"""Render order rows + infinite scroll sentinel."""
|
||||
from shared.utils import route_prefix
|
||||
pfx = route_prefix()
|
||||
|
||||
parts = [
|
||||
_order_row_sx(o, pfx + url_for_fn("orders.order.order_detail", order_id=o.id))
|
||||
for o in orders
|
||||
]
|
||||
|
||||
if page < total_pages:
|
||||
next_url = pfx + url_for_fn("orders.list_orders") + qs_fn(page=page + 1)
|
||||
parts.append(sx_call(
|
||||
"infinite-scroll",
|
||||
url=next_url, page=page, total_pages=total_pages,
|
||||
id_prefix="orders", colspan=5,
|
||||
))
|
||||
else:
|
||||
parts.append(sx_call("order-end-row"))
|
||||
|
||||
return "(<> " + " ".join(parts) + ")"
|
||||
|
||||
|
||||
def _orders_main_panel_sx(orders: list, rows_sx: str) -> str:
|
||||
"""Main panel for orders list."""
|
||||
if not orders:
|
||||
return sx_call("order-empty-state")
|
||||
return sx_call("order-table", rows=SxExpr(rows_sx))
|
||||
|
||||
|
||||
def _orders_summary_sx(ctx: dict) -> str:
|
||||
"""Filter section for orders list."""
|
||||
return sx_call("order-list-header", search_mobile=SxExpr(search_mobile_sx(ctx)))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Single order detail
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _order_items_sx(order: Any) -> str:
|
||||
"""Render order items list."""
|
||||
if not order or not order.items:
|
||||
return ""
|
||||
parts = []
|
||||
for item in order.items:
|
||||
prod_url = market_product_url(item.product_slug)
|
||||
if item.product_image:
|
||||
img = sx_call(
|
||||
"order-item-image",
|
||||
src=item.product_image, alt=item.product_title or "Product image",
|
||||
)
|
||||
else:
|
||||
img = sx_call("order-item-no-image")
|
||||
parts.append(sx_call(
|
||||
"order-item-row",
|
||||
href=prod_url, img=SxExpr(img),
|
||||
title=item.product_title or "Unknown product",
|
||||
pid=f"Product ID: {item.product_id}",
|
||||
qty=f"Qty: {item.quantity}",
|
||||
price=f"{item.currency or order.currency or 'GBP'} {item.unit_price or 0:.2f}",
|
||||
))
|
||||
items_sx = "(<> " + " ".join(parts) + ")"
|
||||
return sx_call("order-items-panel", items=SxExpr(items_sx))
|
||||
|
||||
|
||||
def _order_summary_sx(order: Any) -> str:
|
||||
"""Order summary card."""
|
||||
return sx_call(
|
||||
"order-summary-card",
|
||||
order_id=order.id,
|
||||
created_at=order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else None,
|
||||
description=order.description, status=order.status, currency=order.currency,
|
||||
total_amount=f"{order.total_amount:.2f}" if order.total_amount else None,
|
||||
)
|
||||
|
||||
|
||||
def _order_calendar_items_sx(calendar_entries: list | None) -> str:
|
||||
"""Render calendar bookings for an order."""
|
||||
if not calendar_entries:
|
||||
return ""
|
||||
parts = []
|
||||
for e in calendar_entries:
|
||||
st = e.state or ""
|
||||
pill = (
|
||||
"bg-emerald-100 text-emerald-800" if st == "confirmed"
|
||||
else "bg-amber-100 text-amber-800" if st == "provisional"
|
||||
else "bg-blue-100 text-blue-800" if st == "ordered"
|
||||
else "bg-stone-100 text-stone-700"
|
||||
)
|
||||
pill_cls = f"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium {pill}"
|
||||
ds = e.start_at.strftime("%-d %b %Y, %H:%M") if e.start_at else ""
|
||||
if e.end_at:
|
||||
ds += f" \u2013 {e.end_at.strftime('%-d %b %Y, %H:%M')}"
|
||||
parts.append(sx_call(
|
||||
"order-calendar-entry",
|
||||
name=e.name, pill=pill_cls, status=st.capitalize(),
|
||||
date_str=ds, cost=f"\u00a3{e.cost or 0:.2f}",
|
||||
))
|
||||
items_sx = "(<> " + " ".join(parts) + ")"
|
||||
return sx_call("order-calendar-section", items=SxExpr(items_sx))
|
||||
|
||||
|
||||
def _order_main_sx(order: Any, calendar_entries: list | None) -> str:
|
||||
"""Main panel for single order detail."""
|
||||
summary = _order_summary_sx(order)
|
||||
items = _order_items_sx(order)
|
||||
cal = _order_calendar_items_sx(calendar_entries)
|
||||
return sx_call(
|
||||
"order-detail-panel",
|
||||
summary=SxExpr(summary),
|
||||
items=SxExpr(items) if items else None,
|
||||
calendar=SxExpr(cal) if cal else None,
|
||||
)
|
||||
|
||||
|
||||
def _order_filter_sx(order: Any, list_url: str, recheck_url: str,
|
||||
pay_url: str, csrf_token: str) -> str:
|
||||
"""Filter section for single order detail."""
|
||||
created = order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014"
|
||||
status = order.status or "pending"
|
||||
|
||||
pay_sx = None
|
||||
if status != "paid":
|
||||
pay_sx = sx_call("order-pay-btn", url=pay_url)
|
||||
|
||||
return sx_call(
|
||||
"order-detail-filter",
|
||||
info=f"Placed {created} \u00b7 Status: {status}",
|
||||
list_url=list_url, recheck_url=recheck_url, csrf=csrf_token,
|
||||
pay=SxExpr(pay_sx) if pay_sx else None,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Cart overview
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Orders list
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_orders_page(ctx: dict, orders: list, page: int,
|
||||
total_pages: int, search: str | None,
|
||||
search_count: int, url_for_fn: Any,
|
||||
qs_fn: Any) -> str:
|
||||
"""Full page: orders list."""
|
||||
from shared.utils import route_prefix
|
||||
|
||||
ctx["search"] = search
|
||||
ctx["search_count"] = search_count
|
||||
list_url = route_prefix() + url_for_fn("orders.list_orders")
|
||||
|
||||
rows = _orders_rows_sx(orders, page, total_pages, url_for_fn, qs_fn)
|
||||
main = _orders_main_panel_sx(orders, rows)
|
||||
|
||||
hdr = root_header_sx(ctx)
|
||||
auth = _auth_header_sx(ctx)
|
||||
orders_hdr = _orders_header_sx(ctx, list_url)
|
||||
auth_child = sx_call(
|
||||
"header-child-sx",
|
||||
inner=SxExpr("(<> " + auth + " " + sx_call("header-child-sx", id="auth-header-child", inner=SxExpr(orders_hdr)) + ")"),
|
||||
)
|
||||
header_rows = "(<> " + hdr + " " + auth_child + ")"
|
||||
|
||||
return full_page_sx(ctx, header_rows=header_rows,
|
||||
filter=_orders_summary_sx(ctx),
|
||||
aside=search_desktop_sx(ctx),
|
||||
content=main)
|
||||
|
||||
|
||||
async def render_orders_rows(ctx: dict, orders: list, page: int,
|
||||
total_pages: int, url_for_fn: Any,
|
||||
qs_fn: Any) -> str:
|
||||
"""Pagination: just the table rows."""
|
||||
return _orders_rows_sx(orders, page, total_pages, url_for_fn, qs_fn)
|
||||
|
||||
|
||||
async def render_orders_oob(ctx: dict, orders: list, page: int,
|
||||
total_pages: int, search: str | None,
|
||||
search_count: int, url_for_fn: Any,
|
||||
qs_fn: Any) -> str:
|
||||
"""OOB response for orders list."""
|
||||
from shared.utils import route_prefix
|
||||
|
||||
ctx["search"] = search
|
||||
ctx["search_count"] = search_count
|
||||
list_url = route_prefix() + url_for_fn("orders.list_orders")
|
||||
|
||||
rows = _orders_rows_sx(orders, page, total_pages, url_for_fn, qs_fn)
|
||||
main = _orders_main_panel_sx(orders, rows)
|
||||
|
||||
auth_oob = _auth_header_sx(ctx, oob=True)
|
||||
auth_child_oob = sx_call(
|
||||
"oob-header-sx",
|
||||
parent_id="auth-header-child",
|
||||
row=SxExpr(_orders_header_sx(ctx, list_url)),
|
||||
)
|
||||
root_oob = root_header_sx(ctx, oob=True)
|
||||
oobs = "(<> " + auth_oob + " " + auth_child_oob + " " + root_oob + ")"
|
||||
|
||||
return oob_page_sx(oobs=oobs,
|
||||
filter=_orders_summary_sx(ctx),
|
||||
aside=search_desktop_sx(ctx),
|
||||
content=main)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Single order detail
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_order_page(ctx: dict, order: Any,
|
||||
calendar_entries: list | None,
|
||||
url_for_fn: Any) -> str:
|
||||
"""Full page: single order detail."""
|
||||
from shared.utils import route_prefix
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
|
||||
pfx = route_prefix()
|
||||
detail_url = pfx + url_for_fn("orders.order.order_detail", order_id=order.id)
|
||||
list_url = pfx + url_for_fn("orders.list_orders")
|
||||
recheck_url = pfx + url_for_fn("orders.order.order_recheck", order_id=order.id)
|
||||
pay_url = pfx + url_for_fn("orders.order.order_pay", order_id=order.id)
|
||||
|
||||
main = _order_main_sx(order, calendar_entries)
|
||||
filt = _order_filter_sx(order, list_url, recheck_url, pay_url, generate_csrf_token())
|
||||
|
||||
hdr = root_header_sx(ctx)
|
||||
order_row = sx_call(
|
||||
"menu-row-sx",
|
||||
id="order-row", level=3, colour="sky",
|
||||
link_href=detail_url, link_label=f"Order {order.id}", icon="fa fa-gbp",
|
||||
)
|
||||
order_child = sx_call(
|
||||
"header-child-sx",
|
||||
inner=SxExpr("(<> " + _auth_header_sx(ctx) + " " + sx_call("header-child-sx", id="auth-header-child", inner=SxExpr(
|
||||
"(<> " + _orders_header_sx(ctx, list_url) + " " + sx_call("header-child-sx", id="orders-header-child", inner=SxExpr(order_row)) + ")"
|
||||
)) + ")"),
|
||||
)
|
||||
header_rows = "(<> " + hdr + " " + order_child + ")"
|
||||
|
||||
return full_page_sx(ctx, header_rows=header_rows, filter=filt, content=main)
|
||||
|
||||
|
||||
async def render_order_oob(ctx: dict, order: Any,
|
||||
calendar_entries: list | None,
|
||||
url_for_fn: Any) -> str:
|
||||
"""OOB response for single order detail."""
|
||||
from shared.utils import route_prefix
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
|
||||
pfx = route_prefix()
|
||||
detail_url = pfx + url_for_fn("orders.order.order_detail", order_id=order.id)
|
||||
list_url = pfx + url_for_fn("orders.list_orders")
|
||||
recheck_url = pfx + url_for_fn("orders.order.order_recheck", order_id=order.id)
|
||||
pay_url = pfx + url_for_fn("orders.order.order_pay", order_id=order.id)
|
||||
|
||||
main = _order_main_sx(order, calendar_entries)
|
||||
filt = _order_filter_sx(order, list_url, recheck_url, pay_url, generate_csrf_token())
|
||||
|
||||
order_row_oob = sx_call(
|
||||
"menu-row-sx",
|
||||
id="order-row", level=3, colour="sky",
|
||||
link_href=detail_url, link_label=f"Order {order.id}", icon="fa fa-gbp",
|
||||
oob=True,
|
||||
)
|
||||
orders_child_oob = sx_call("oob-header-sx",
|
||||
parent_id="orders-header-child",
|
||||
row=SxExpr(order_row_oob))
|
||||
root_oob = root_header_sx(ctx, oob=True)
|
||||
oobs = "(<> " + orders_child_oob + " " + root_oob + ")"
|
||||
|
||||
return oob_page_sx(oobs=oobs, filter=filt, content=main)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Checkout error
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _checkout_error_filter_sx() -> str:
|
||||
return sx_call("checkout-error-header")
|
||||
|
||||
|
||||
def _checkout_error_content_sx(error: str | None, order: Any | None) -> str:
|
||||
err_msg = error or "Unexpected error while creating the hosted checkout session."
|
||||
order_sx = None
|
||||
if order:
|
||||
order_sx = sx_call("checkout-error-order-id", oid=f"#{order.id}")
|
||||
back_url = cart_url("/")
|
||||
return sx_call(
|
||||
"checkout-error-content",
|
||||
msg=err_msg,
|
||||
order=SxExpr(order_sx) if order_sx else None,
|
||||
back_url=back_url,
|
||||
)
|
||||
|
||||
|
||||
async def render_checkout_error_page(ctx: dict, error: str | None = None, order: Any | None = None) -> str:
|
||||
"""Full page: checkout error."""
|
||||
hdr = root_header_sx(ctx)
|
||||
filt = _checkout_error_filter_sx()
|
||||
content = _checkout_error_content_sx(error, order)
|
||||
return full_page_sx(ctx, header_rows=hdr, filter=filt, content=content)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Page admin (/<page_slug>/admin/)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _cart_page_admin_header_sx(ctx: dict, page_post: Any, *, oob: bool = False,
|
||||
selected: str = "") -> str:
|
||||
"""Build the page-level admin header row -- delegates to shared helper."""
|
||||
slug = page_post.slug if page_post else ""
|
||||
ctx = _ensure_post_ctx(ctx, page_post)
|
||||
return post_admin_header_sx(ctx, slug, oob=oob, selected=selected)
|
||||
|
||||
|
||||
def _cart_admin_main_panel_sx(ctx: dict) -> str:
|
||||
"""Admin overview panel -- links to sub-admin pages."""
|
||||
from quart import url_for
|
||||
payments_href = url_for("page_admin.defpage_cart_payments")
|
||||
return (
|
||||
'(div :id "main-panel"'
|
||||
' (div :class "flex items-center justify-between p-3 border-b"'
|
||||
' (span :class "font-medium" (i :class "fa fa-credit-card text-purple-600 mr-1") " Payments")'
|
||||
f' (a :href "{payments_href}" :class "text-sm underline" "configure")))'
|
||||
)
|
||||
|
||||
|
||||
def _cart_payments_main_panel_sx(ctx: dict) -> str:
|
||||
"""Render SumUp payment config form."""
|
||||
from quart import url_for
|
||||
csrf_token = ctx.get("csrf_token")
|
||||
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
|
||||
page_config = ctx.get("page_config")
|
||||
sumup_configured = bool(page_config and getattr(page_config, "sumup_api_key", None))
|
||||
merchant_code = (getattr(page_config, "sumup_merchant_code", None) or "") if page_config else ""
|
||||
checkout_prefix = (getattr(page_config, "sumup_checkout_prefix", None) or "") if page_config else ""
|
||||
update_url = url_for("page_admin.update_sumup")
|
||||
|
||||
placeholder = "--------" if sumup_configured else "sup_sk_..."
|
||||
input_cls = "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"
|
||||
|
||||
return sx_call("cart-payments-panel",
|
||||
update_url=update_url, csrf=csrf,
|
||||
merchant_code=merchant_code, placeholder=placeholder,
|
||||
input_cls=input_cls, sumup_configured=sumup_configured,
|
||||
checkout_prefix=checkout_prefix)
|
||||
|
||||
|
||||
|
||||
def render_cart_payments_panel(ctx: dict) -> str:
|
||||
"""Render the payments config panel for PUT response."""
|
||||
return _cart_payments_main_panel_sx(ctx)
|
||||
@@ -1,12 +1,12 @@
|
||||
;; Cart ticket components
|
||||
|
||||
(defcomp ~cart-ticket-type-name (&key name)
|
||||
(defcomp ~tickets/type-name (&key (name :as string))
|
||||
(p :class "mt-0.5 text-[0.7rem] sm:text-xs text-stone-500" name))
|
||||
|
||||
(defcomp ~cart-ticket-type-hidden (&key value)
|
||||
(defcomp ~tickets/type-hidden (&key (value :as string))
|
||||
(input :type "hidden" :name "ticket_type_id" :value value))
|
||||
|
||||
(defcomp ~cart-ticket-article (&key name type-name date-str price qty-url csrf entry-id type-hidden minus qty plus line-total)
|
||||
(defcomp ~tickets/article (&key (name :as string) type-name (date-str :as string) (price :as string) (qty-url :as string) (csrf :as string) (entry-id :as string) type-hidden (minus :as string) (qty :as string) (plus :as string) (line-total :as string))
|
||||
(article :class "flex flex-col sm:flex-row gap-3 sm:gap-4 rounded-2xl bg-white shadow-sm border border-stone-200 p-3 sm:p-4"
|
||||
(div :class "flex-1 min-w-0"
|
||||
(div :class "flex flex-col sm:flex-row sm:items-start justify-between gap-2 sm:gap-3"
|
||||
@@ -35,7 +35,7 @@
|
||||
(div :class "flex items-center justify-between sm:justify-end gap-3"
|
||||
(p :class "text-sm sm:text-base font-semibold text-stone-900" line-total))))))
|
||||
|
||||
(defcomp ~cart-tickets-section (&key items)
|
||||
(defcomp ~tickets/section (&key items)
|
||||
(div :class "mt-6 border-t border-stone-200 pt-4"
|
||||
(h2 :class "text-base font-semibold mb-2"
|
||||
(i :class "fa fa-ticket mr-1" :aria-hidden "true") " Event tickets")
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
"""Cart defpage setup — registers layouts, page helpers, and loads .sx pages."""
|
||||
"""Cart defpage setup — registers layouts and loads .sx pages."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def setup_cart_pages() -> None:
|
||||
"""Register cart-specific layouts, page helpers, and load page definitions."""
|
||||
"""Register cart-specific layouts and load page definitions."""
|
||||
from .layouts import _register_cart_layouts
|
||||
_register_cart_layouts()
|
||||
_register_cart_helpers()
|
||||
_load_cart_page_files()
|
||||
|
||||
|
||||
@@ -15,107 +13,3 @@ def _load_cart_page_files() -> None:
|
||||
import os
|
||||
from shared.sx.pages import load_page_dir
|
||||
load_page_dir(os.path.dirname(__file__), "cart")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Layouts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _register_cart_layouts() -> None:
|
||||
from shared.sx.layouts import register_custom_layout
|
||||
register_custom_layout("cart-page", _cart_page_full, _cart_page_oob)
|
||||
register_custom_layout("cart-admin", _cart_admin_full, _cart_admin_oob)
|
||||
|
||||
|
||||
def _cart_page_full(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import root_header_sx, sx_call, SxExpr
|
||||
from sx.sx_components import _cart_header_sx, _page_cart_header_sx
|
||||
|
||||
page_post = ctx.get("page_post")
|
||||
root_hdr = root_header_sx(ctx)
|
||||
child = _cart_header_sx(ctx)
|
||||
page_hdr = _page_cart_header_sx(ctx, page_post)
|
||||
nested = sx_call(
|
||||
"header-child-sx",
|
||||
inner=SxExpr("(<> " + child + " " + sx_call("header-child-sx", id="cart-header-child", inner=SxExpr(page_hdr)) + ")"),
|
||||
)
|
||||
return "(<> " + root_hdr + " " + nested + ")"
|
||||
|
||||
|
||||
def _cart_page_oob(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import root_header_sx, sx_call, SxExpr
|
||||
from sx.sx_components import _cart_header_sx, _page_cart_header_sx
|
||||
|
||||
page_post = ctx.get("page_post")
|
||||
child_oob = sx_call("oob-header-sx",
|
||||
parent_id="cart-header-child",
|
||||
row=SxExpr(_page_cart_header_sx(ctx, page_post)))
|
||||
cart_hdr_oob = _cart_header_sx(ctx, oob=True)
|
||||
root_hdr_oob = root_header_sx(ctx, oob=True)
|
||||
return "(<> " + child_oob + " " + cart_hdr_oob + " " + root_hdr_oob + ")"
|
||||
|
||||
|
||||
async def _cart_admin_full(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import root_header_sx
|
||||
from sx.sx_components import _post_header_sx, _cart_page_admin_header_sx
|
||||
|
||||
page_post = ctx.get("page_post")
|
||||
selected = kw.get("selected", "")
|
||||
root_hdr = root_header_sx(ctx)
|
||||
post_hdr = await _post_header_sx(ctx, page_post)
|
||||
admin_hdr = _cart_page_admin_header_sx(ctx, page_post, selected=selected)
|
||||
return "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
|
||||
|
||||
|
||||
async def _cart_admin_oob(ctx: dict, **kw: Any) -> str:
|
||||
from sx.sx_components import _cart_page_admin_header_sx
|
||||
|
||||
page_post = ctx.get("page_post")
|
||||
selected = kw.get("selected", "")
|
||||
return _cart_page_admin_header_sx(ctx, page_post, oob=True, selected=selected)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Page helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _register_cart_helpers() -> None:
|
||||
from shared.sx.pages import register_page_helpers
|
||||
|
||||
register_page_helpers("cart", {
|
||||
"overview-content": _h_overview_content,
|
||||
"page-cart-content": _h_page_cart_content,
|
||||
"cart-admin-content": _h_cart_admin_content,
|
||||
"cart-payments-content": _h_cart_payments_content,
|
||||
})
|
||||
|
||||
|
||||
def _h_overview_content():
|
||||
from quart import g
|
||||
page_groups = getattr(g, "overview_page_groups", [])
|
||||
from sx.sx_components import _overview_main_panel_sx
|
||||
# _overview_main_panel_sx needs ctx for url helpers — use g-based approach
|
||||
# The function reads cart_url from ctx, which we can get from template context
|
||||
from shared.sx.page import get_template_context
|
||||
import asyncio
|
||||
# Page helpers are sync — we pre-compute in before_request
|
||||
return getattr(g, "overview_content", "")
|
||||
|
||||
|
||||
def _h_page_cart_content():
|
||||
from quart import g
|
||||
return getattr(g, "page_cart_content", "")
|
||||
|
||||
|
||||
def _h_cart_admin_content():
|
||||
from sx.sx_components import _cart_admin_main_panel_sx
|
||||
from shared.sx.page import get_template_context
|
||||
# Sync helper — _cart_admin_main_panel_sx is sync, but needs ctx
|
||||
# We can pre-compute in before_request, or use get_template_context_sync-like pattern
|
||||
from quart import g
|
||||
return getattr(g, "cart_admin_content", "")
|
||||
|
||||
|
||||
def _h_cart_payments_content():
|
||||
from quart import g
|
||||
return getattr(g, "cart_payments_content", "")
|
||||
|
||||
@@ -1,25 +1,44 @@
|
||||
;; Cart app defpage declarations.
|
||||
;; All data fetching via (service ...) IO primitives, no Python helpers.
|
||||
|
||||
(defpage cart-overview
|
||||
:path "/"
|
||||
:auth :public
|
||||
:layout :root
|
||||
:content (overview-content))
|
||||
:data (service "cart-page" "overview-data")
|
||||
:content (~overview/content
|
||||
:page-groups page-groups
|
||||
:cart-url-base cart-url-base))
|
||||
|
||||
(defpage page-cart-view
|
||||
:path "/"
|
||||
:path "/<page_slug>/"
|
||||
:auth :public
|
||||
:layout :cart-page
|
||||
:content (page-cart-content))
|
||||
:data (service "cart-page" "page-cart-data")
|
||||
:content (~items/page-cart-content
|
||||
:cart-items cart-items
|
||||
:cal-entries cal-entries
|
||||
:ticket-groups ticket-groups
|
||||
:summary (~items/summary-from-data
|
||||
:item-count (get summary "item_count")
|
||||
:grand-total (get summary "grand_total")
|
||||
:symbol (get summary "symbol")
|
||||
:is-logged-in (get summary "is_logged_in")
|
||||
:checkout-action (get summary "checkout_action")
|
||||
:login-href (get summary "login_href")
|
||||
:user-email (get summary "user_email"))))
|
||||
|
||||
(defpage cart-admin
|
||||
:path "/"
|
||||
:path "/<page_slug>/admin/"
|
||||
:auth :admin
|
||||
:layout :cart-admin
|
||||
:content (cart-admin-content))
|
||||
:data (service "cart-page" "admin-data")
|
||||
:content (~payments/admin-content))
|
||||
|
||||
(defpage cart-payments
|
||||
:path "/payments/"
|
||||
:path "/<page_slug>/admin/payments/"
|
||||
:auth :admin
|
||||
:layout (:cart-admin :selected "payments")
|
||||
:content (cart-payments-content))
|
||||
:data (service "cart-page" "payments-admin-data")
|
||||
:content (~payments/content
|
||||
:page-config page-config))
|
||||
|
||||
8
cart/sxc/pages/layouts.py
Normal file
8
cart/sxc/pages/layouts.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""Cart layout registration — all layouts delegate to .sx defcomps."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def _register_cart_layouts() -> None:
|
||||
from shared.sx.layouts import register_sx_layout
|
||||
register_sx_layout("cart-page", "cart-page-layout-full", "cart-page-layout-oob")
|
||||
register_sx_layout("cart-admin", "cart-admin-layout-full", "cart-admin-layout-oob")
|
||||
121
cart/sxc/pages/renders.py
Normal file
121
cart/sxc/pages/renders.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""Cart render functions — called from bp routes."""
|
||||
from __future__ import annotations
|
||||
|
||||
from .utils import _serialize_order, _serialize_calendar_entry
|
||||
|
||||
|
||||
async def render_orders_page(ctx, orders, page, total_pages, search, search_count, url_for_fn, qs_fn):
|
||||
from shared.sx.helpers import sx_call, render_to_sx_with_env, search_desktop_sx, search_mobile_sx, full_page_sx
|
||||
from shared.utils import route_prefix
|
||||
ctx["search"] = search
|
||||
ctx["search_count"] = search_count
|
||||
pfx = route_prefix()
|
||||
list_url = pfx + url_for_fn("orders.list_orders")
|
||||
detail_url_prefix = pfx + url_for_fn("orders.order.order_detail", order_id=0).rsplit("0/", 1)[0]
|
||||
order_dicts = [_serialize_order(o) for o in orders]
|
||||
content = sx_call("orders-list-content", orders=order_dicts,
|
||||
page=page, total_pages=total_pages, rows_url=list_url, detail_url_prefix=detail_url_prefix)
|
||||
header_rows = await render_to_sx_with_env("layouts/orders-layout-full", {},
|
||||
list_url=list_url,
|
||||
)
|
||||
filt = sx_call("order-list-header", search_mobile=await search_mobile_sx(ctx))
|
||||
return await full_page_sx(ctx, header_rows=header_rows, filter=filt,
|
||||
aside=await search_desktop_sx(ctx), content=content)
|
||||
|
||||
|
||||
def render_orders_rows(ctx, orders, page, total_pages, url_for_fn, qs_fn):
|
||||
from shared.sx.helpers import sx_call
|
||||
from shared.utils import route_prefix
|
||||
pfx = route_prefix()
|
||||
list_url = pfx + url_for_fn("orders.list_orders")
|
||||
detail_url_prefix = pfx + url_for_fn("orders.order.order_detail", order_id=0).rsplit("0/", 1)[0]
|
||||
order_dicts = [_serialize_order(o) for o in orders]
|
||||
next_url = list_url + qs_fn(page=page + 1) if page < total_pages else ""
|
||||
return sx_call("cart-orders-rows-content",
|
||||
orders=order_dicts, detail_url_prefix=detail_url_prefix,
|
||||
page=page, total_pages=total_pages, next_url=next_url)
|
||||
|
||||
|
||||
async def render_orders_oob(ctx, orders, page, total_pages, search, search_count, url_for_fn, qs_fn):
|
||||
from shared.sx.helpers import sx_call, render_to_sx_with_env, search_desktop_sx, search_mobile_sx, oob_page_sx
|
||||
from shared.utils import route_prefix
|
||||
ctx["search"] = search
|
||||
ctx["search_count"] = search_count
|
||||
pfx = route_prefix()
|
||||
list_url = pfx + url_for_fn("orders.list_orders")
|
||||
detail_url_prefix = pfx + url_for_fn("orders.order.order_detail", order_id=0).rsplit("0/", 1)[0]
|
||||
order_dicts = [_serialize_order(o) for o in orders]
|
||||
content = sx_call("orders-list-content", orders=order_dicts,
|
||||
page=page, total_pages=total_pages, rows_url=list_url, detail_url_prefix=detail_url_prefix)
|
||||
oobs = await render_to_sx_with_env("layouts/orders-layout-oob", {},
|
||||
list_url=list_url,
|
||||
)
|
||||
filt = sx_call("order-list-header", search_mobile=await search_mobile_sx(ctx))
|
||||
return await oob_page_sx(oobs=oobs, filter=filt, aside=await search_desktop_sx(ctx), content=content)
|
||||
|
||||
|
||||
async def render_order_page(ctx, order, calendar_entries, url_for_fn):
|
||||
from shared.sx.helpers import sx_call, render_to_sx_with_env, full_page_sx
|
||||
from shared.utils import route_prefix
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
pfx = route_prefix()
|
||||
detail_url = pfx + url_for_fn("orders.order.order_detail", order_id=order.id)
|
||||
list_url = pfx + url_for_fn("orders.list_orders")
|
||||
recheck_url = pfx + url_for_fn("orders.order.order_recheck", order_id=order.id)
|
||||
pay_url = pfx + url_for_fn("orders.order.order_pay", order_id=order.id)
|
||||
order_data = _serialize_order(order)
|
||||
cal_data = [_serialize_calendar_entry(e) for e in (calendar_entries or [])]
|
||||
main = sx_call("order-detail-content", order=order_data, calendar_entries=cal_data)
|
||||
filt = sx_call("order-detail-filter-content", order=order_data,
|
||||
list_url=list_url, recheck_url=recheck_url, pay_url=pay_url, csrf=generate_csrf_token())
|
||||
header_rows = await render_to_sx_with_env("layouts/order-detail-layout-full", {},
|
||||
list_url=list_url, detail_url=detail_url,
|
||||
order_label=f"Order {order.id}",
|
||||
)
|
||||
return await full_page_sx(ctx, header_rows=header_rows, filter=filt, content=main)
|
||||
|
||||
|
||||
async def render_order_oob(ctx, order, calendar_entries, url_for_fn):
|
||||
from shared.sx.helpers import sx_call, render_to_sx_with_env, oob_page_sx
|
||||
from shared.utils import route_prefix
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
pfx = route_prefix()
|
||||
detail_url = pfx + url_for_fn("orders.order.order_detail", order_id=order.id)
|
||||
list_url = pfx + url_for_fn("orders.list_orders")
|
||||
recheck_url = pfx + url_for_fn("orders.order.order_recheck", order_id=order.id)
|
||||
pay_url = pfx + url_for_fn("orders.order.order_pay", order_id=order.id)
|
||||
order_data = _serialize_order(order)
|
||||
cal_data = [_serialize_calendar_entry(e) for e in (calendar_entries or [])]
|
||||
main = sx_call("order-detail-content", order=order_data, calendar_entries=cal_data)
|
||||
filt = sx_call("order-detail-filter-content", order=order_data,
|
||||
list_url=list_url, recheck_url=recheck_url, pay_url=pay_url, csrf=generate_csrf_token())
|
||||
oobs = await render_to_sx_with_env("layouts/order-detail-layout-oob", {},
|
||||
detail_url=detail_url,
|
||||
order_label=f"Order {order.id}",
|
||||
)
|
||||
return await oob_page_sx(oobs=oobs, filter=filt, content=main)
|
||||
|
||||
|
||||
async def render_checkout_error_page(ctx, error=None, order=None):
|
||||
from shared.sx.helpers import sx_call, render_to_sx_with_env, full_page_sx
|
||||
from shared.infrastructure.urls import cart_url
|
||||
err_msg = error or "Unexpected error while creating the hosted checkout session."
|
||||
hdr = await render_to_sx_with_env("shared:layout/root-full", {})
|
||||
filt = sx_call("checkout-error-header")
|
||||
content = sx_call("cart-checkout-error-from-data",
|
||||
msg=err_msg, order_id=order.id if order else None,
|
||||
back_url=cart_url("/"))
|
||||
return await full_page_sx(ctx, header_rows=hdr, filter=filt, content=content)
|
||||
|
||||
|
||||
def render_cart_payments_panel(ctx):
|
||||
from shared.sx.helpers import sx_call
|
||||
page_config = ctx.get("page_config")
|
||||
pc_data = None
|
||||
if page_config:
|
||||
pc_data = {
|
||||
"sumup_api_key": bool(getattr(page_config, "sumup_api_key", None)),
|
||||
"sumup_merchant_code": getattr(page_config, "sumup_merchant_code", None) or "",
|
||||
"sumup_checkout_prefix": getattr(page_config, "sumup_checkout_prefix", None) or "",
|
||||
}
|
||||
return sx_call("cart-payments-content", page_config=pc_data)
|
||||
40
cart/sxc/pages/utils.py
Normal file
40
cart/sxc/pages/utils.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Cart page utilities — serializers and formatters."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _serialize_order(order: Any) -> dict:
|
||||
from shared.infrastructure.urls import market_product_url
|
||||
created = order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014"
|
||||
items = []
|
||||
if order.items:
|
||||
for item in order.items:
|
||||
items.append({
|
||||
"product_image": item.product_image,
|
||||
"product_title": item.product_title or "Unknown product",
|
||||
"product_id": item.product_id,
|
||||
"product_slug": item.product_slug,
|
||||
"product_url": market_product_url(item.product_slug),
|
||||
"quantity": item.quantity,
|
||||
"unit_price_formatted": f"{item.unit_price or 0:.2f}",
|
||||
"currency": item.currency or order.currency or "GBP",
|
||||
})
|
||||
return {
|
||||
"id": order.id,
|
||||
"status": order.status or "pending",
|
||||
"created_at_formatted": created,
|
||||
"description": order.description or "",
|
||||
"total_formatted": f"{order.total_amount or 0:.2f}",
|
||||
"total_amount": float(order.total_amount or 0),
|
||||
"currency": order.currency or "GBP",
|
||||
"items": items,
|
||||
}
|
||||
|
||||
|
||||
def _serialize_calendar_entry(e: Any) -> dict:
|
||||
st = e.state or ""
|
||||
ds = e.start_at.strftime("%-d %b %Y, %H:%M") if e.start_at else ""
|
||||
if e.end_at:
|
||||
ds += f" \u2013 {e.end_at.strftime('%-d %b %Y, %H:%M')}"
|
||||
return {"name": e.name, "state": st, "date_str": ds, "cost_formatted": f"{e.cost or 0:.2f}"}
|
||||
30
dev-sx.sh
Executable file
30
dev-sx.sh
Executable file
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Dev mode for sx_docs only (standalone, no DB)
|
||||
# Bind-mounted source + auto-reload on externalnet
|
||||
# Browse to sx.rose-ash.com
|
||||
#
|
||||
# Usage:
|
||||
# ./dev-sx.sh # Start sx_docs dev
|
||||
# ./dev-sx.sh down # Stop
|
||||
# ./dev-sx.sh logs # Tail logs
|
||||
# ./dev-sx.sh --build # Rebuild image then start
|
||||
|
||||
COMPOSE="docker compose -p sx-dev -f docker-compose.dev-sx.yml"
|
||||
|
||||
case "${1:-up}" in
|
||||
down)
|
||||
$COMPOSE down
|
||||
;;
|
||||
logs)
|
||||
$COMPOSE logs -f sx_docs
|
||||
;;
|
||||
*)
|
||||
BUILD_FLAG=""
|
||||
if [[ "${1:-}" == "--build" ]]; then
|
||||
BUILD_FLAG="--build"
|
||||
fi
|
||||
$COMPOSE up $BUILD_FLAG
|
||||
;;
|
||||
esac
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user