Compare commits
656 Commits
sexpressio
...
ab015fa2fd
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 544892edd9 | |||
| c243d17eeb | |||
| 5b4cacaf19 | |||
| a8c0741f54 | |||
| 0af07f9f2e | |||
| 222738546a | |||
| 4098c32878 | |||
| 3bd4f4b661 | |||
| 5dd1161816 | |||
| 002cc49f2c | |||
| e6b0849ce3 | |||
| 8024fa5b13 | |||
| ea18a402d6 | |||
| e4e43177a8 | |||
| 8445c36270 | |||
| 5578923242 | |||
| 9754b892d6 | |||
| ab75e505a8 | |||
| 13bcf755f6 | |||
| 3d55145e5f | |||
| 8b2785ccb0 | |||
| 03196c3ad0 | |||
| 815c5285d5 | |||
| ed30f88f05 | |||
| 8aedbc9e62 | |||
| 8ceb9aee62 | |||
| 4668c30890 | |||
| 39f61eddd6 | |||
| 5436dfe76c | |||
| 4ede0368dc | |||
| a8e06e87fb | |||
| 588d240ddc | |||
| aa5c251a45 | |||
| 7ccb463a8b | |||
| 341fc4cf28 | |||
| 1a5969202e | |||
| 3bc5de126d | |||
| 1447122a0c | |||
| ab45e21c7c | |||
| c0d369eb8e | |||
| 755313bd29 | |||
| 01a67029f0 | |||
| b54f7b4b56 | |||
| 5ede32e21c | |||
| 7aea1f1be9 | |||
| 0ef4a93a92 | |||
| 48696498ef | |||
| b7d95a8b4e | |||
| e7d5c6734b | |||
| e4a6d2dfc8 | |||
| 0a5562243b | |||
| 2b41aaa6ce | |||
| cfe66e5342 | |||
| 382d1b7c7a | |||
| a580a53328 | |||
| 0f9af31ffe |
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
|
||||
@@ -58,13 +62,22 @@ jobs:
|
||||
fi
|
||||
fi
|
||||
|
||||
for app in blog market cart events federation account relations likes orders test; do
|
||||
# Map compose service name to source directory
|
||||
app_dir() {
|
||||
case \"\$1\" in
|
||||
sx_docs) echo \"sx\" ;;
|
||||
*) echo \"\$1\" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
for app in blog market cart events federation account relations likes orders test sx_docs; do
|
||||
dir=\$(app_dir \"\$app\")
|
||||
IMAGE_EXISTS=\$(docker image ls -q ${{ env.REGISTRY }}/\$app:latest 2>/dev/null)
|
||||
if [ \"\$REBUILD_ALL\" = true ] || echo \"\$CHANGED\" | grep -q \"^\$app/\" || [ -z \"\$IMAGE_EXISTS\" ]; then
|
||||
if [ \"\$REBUILD_ALL\" = true ] || echo \"\$CHANGED\" | grep -q \"^\$dir/\" || [ -z \"\$IMAGE_EXISTS\" ]; then
|
||||
echo \"Building \$app...\"
|
||||
docker build \
|
||||
--build-arg CACHEBUST=\$(date +%s) \
|
||||
-f \$app/Dockerfile \
|
||||
-f \$dir/Dockerfile \
|
||||
-t ${{ env.REGISTRY }}/\$app:latest \
|
||||
-t ${{ env.REGISTRY }}/\$app:${{ github.sha }} \
|
||||
.
|
||||
@@ -75,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'
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
shared/sx/.cache/
|
||||
.env
|
||||
node_modules/
|
||||
*.egg-info/
|
||||
|
||||
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
|
||||
|
||||
@@ -16,6 +16,9 @@ app_urls:
|
||||
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:
|
||||
|
||||
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.
|
||||
43
account/alembic/versions/0003_add_user_profile_fields.py
Normal file
43
account/alembic/versions/0003_add_user_profile_fields.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Add author profile fields to users table.
|
||||
|
||||
Merges Ghost Author profile data into User — bio, profile_image, cover_image,
|
||||
website, location, facebook, twitter, slug, is_admin.
|
||||
|
||||
Revision ID: 0003
|
||||
Revises: 0002_hash_oauth_tokens
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "acct_0003"
|
||||
down_revision = "acct_0002"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column("users", sa.Column("slug", sa.String(191), nullable=True))
|
||||
op.add_column("users", sa.Column("bio", sa.Text(), nullable=True))
|
||||
op.add_column("users", sa.Column("profile_image", sa.Text(), nullable=True))
|
||||
op.add_column("users", sa.Column("cover_image", sa.Text(), nullable=True))
|
||||
op.add_column("users", sa.Column("website", sa.Text(), nullable=True))
|
||||
op.add_column("users", sa.Column("location", sa.Text(), nullable=True))
|
||||
op.add_column("users", sa.Column("facebook", sa.Text(), nullable=True))
|
||||
op.add_column("users", sa.Column("twitter", sa.Text(), nullable=True))
|
||||
op.add_column("users", sa.Column(
|
||||
"is_admin", sa.Boolean(), nullable=False, server_default=sa.text("false"),
|
||||
))
|
||||
op.create_index("ix_users_slug", "users", ["slug"], unique=True)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index("ix_users_slug")
|
||||
op.drop_column("users", "is_admin")
|
||||
op.drop_column("users", "twitter")
|
||||
op.drop_column("users", "facebook")
|
||||
op.drop_column("users", "location")
|
||||
op.drop_column("users", "website")
|
||||
op.drop_column("users", "cover_image")
|
||||
op.drop_column("users", "profile_image")
|
||||
op.drop_column("users", "bio")
|
||||
op.drop_column("users", "slug")
|
||||
@@ -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,10 +71,23 @@ def create_app() -> "Quart":
|
||||
app.jinja_loader,
|
||||
])
|
||||
|
||||
# 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()
|
||||
|
||||
# --- blueprints ---
|
||||
app.register_blueprint(register_auth_bp())
|
||||
app.register_blueprint(register_account_bp())
|
||||
app.register_blueprint(register_fragments())
|
||||
|
||||
account_bp = register_account_bp()
|
||||
app.register_blueprint(account_bp)
|
||||
|
||||
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
|
||||
|
||||
@@ -1,104 +1,33 @@
|
||||
"""Account pages blueprint.
|
||||
|
||||
Moved from federation/bp/auth — newsletters, fragment pages (tickets, bookings).
|
||||
Mounted at root /.
|
||||
Mounted at root /. GET page handlers replaced by defpage.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import (
|
||||
Blueprint,
|
||||
request,
|
||||
make_response,
|
||||
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
|
||||
|
||||
oob = {
|
||||
"oob_extends": "oob_elements.html",
|
||||
"extends": "_types/root/_index.html",
|
||||
"parent_id": "root-header-child",
|
||||
"child_id": "auth-header-child",
|
||||
"header": "_types/auth/header/_header.html",
|
||||
"parent_header": "_types/root/header/_header.html",
|
||||
"nav": "_types/auth/_nav.html",
|
||||
"main": "_types/auth/_main_panel.html",
|
||||
}
|
||||
from shared.infrastructure.fragments import fetch_fragments
|
||||
from shared.sx.helpers import sx_response, sx_call
|
||||
|
||||
|
||||
def register(url_prefix="/"):
|
||||
account_bp = Blueprint("account", __name__, url_prefix=url_prefix)
|
||||
|
||||
@account_bp.context_processor
|
||||
async def context():
|
||||
@account_bp.before_request
|
||||
async def _prepare_page_data():
|
||||
"""Fetch account_nav fragments for layout."""
|
||||
events_nav, cart_nav, artdag_nav = await fetch_fragments([
|
||||
("events", "account-nav-item", {}),
|
||||
("cart", "account-nav-item", {}),
|
||||
("artdag", "nav-item", {}),
|
||||
], required=False)
|
||||
return {"oob": oob, "account_nav": events_nav + cart_nav + artdag_nav}
|
||||
|
||||
@account_bp.get("/")
|
||||
async def account():
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_account_page, render_account_oob
|
||||
|
||||
if not g.get("user"):
|
||||
return redirect(login_url("/"))
|
||||
|
||||
ctx = await get_template_context()
|
||||
if not is_htmx_request():
|
||||
html = await render_account_page(ctx)
|
||||
return await make_response(html)
|
||||
else:
|
||||
sx_src = await render_account_oob(ctx)
|
||||
return sx_response(sx_src)
|
||||
|
||||
@account_bp.get("/newsletters/")
|
||||
async def newsletters():
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
|
||||
if not g.get("user"):
|
||||
return redirect(login_url("/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,
|
||||
})
|
||||
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_newsletters_page, render_newsletters_oob
|
||||
|
||||
ctx = await get_template_context()
|
||||
if not is_htmx_request():
|
||||
html = await render_newsletters_page(ctx, newsletter_list)
|
||||
return await make_response(html)
|
||||
else:
|
||||
sx_src = await render_newsletters_oob(ctx, newsletter_list)
|
||||
return sx_response(sx_src)
|
||||
g.account_nav = events_nav + cart_nav + artdag_nav
|
||||
|
||||
@account_bp.post("/newsletter/<int:newsletter_id>/toggle/")
|
||||
async def toggle_newsletter(newsletter_id: int):
|
||||
@@ -125,34 +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
|
||||
|
||||
# Catch-all for fragment-provided pages — must be last
|
||||
@account_bp.get("/<slug>/")
|
||||
async def fragment_page(slug):
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
from quart import abort
|
||||
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"
|
||||
|
||||
if not g.get("user"):
|
||||
return redirect(login_url(f"/{slug}/"))
|
||||
|
||||
fragment_html = await fetch_fragment(
|
||||
"events", "account-page",
|
||||
params={"slug": slug, "user_id": str(g.user.id)},
|
||||
)
|
||||
if not fragment_html:
|
||||
abort(404)
|
||||
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_fragment_page, render_fragment_oob
|
||||
|
||||
ctx = await get_template_context()
|
||||
if not is_htmx_request():
|
||||
html = await render_fragment_page(ctx, fragment_html)
|
||||
return await make_response(html)
|
||||
else:
|
||||
sx_src = await render_fragment_oob(ctx, fragment_html)
|
||||
return sx_response(sx_src)
|
||||
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,7 +44,18 @@ from .services import (
|
||||
SESSION_USER_KEY = "uid"
|
||||
ACCOUNT_SESSION_KEY = "account_sid"
|
||||
|
||||
ALLOWED_CLIENTS = {"blog", "market", "cart", "events", "federation", "orders", "test", "artdag", "artdag_l2"}
|
||||
|
||||
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"}
|
||||
|
||||
|
||||
def register(url_prefix="/auth"):
|
||||
@@ -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,54 +0,0 @@
|
||||
"""Account app fragment endpoints.
|
||||
|
||||
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
|
||||
by other coop apps via the fragment client.
|
||||
|
||||
Fragments:
|
||||
auth-menu Desktop + mobile auth menu (sign-in or user link)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, Response, request
|
||||
|
||||
from shared.infrastructure.fragments import FRAGMENT_HEADER
|
||||
|
||||
|
||||
def register():
|
||||
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Fragment handlers — return sx source text
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
async def _auth_menu():
|
||||
from shared.infrastructure.urls import account_url
|
||||
from shared.sx.helpers import sx_call
|
||||
|
||||
user_email = request.args.get("email", "")
|
||||
return sx_call("auth-menu",
|
||||
user_email=user_email or None,
|
||||
account_url=account_url(""))
|
||||
|
||||
_handlers = {
|
||||
"auth-menu": _auth_menu,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Routing
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@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 = _handlers.get(fragment_type)
|
||||
if handler is None:
|
||||
return Response("", status=200, content_type="text/sx")
|
||||
src = await handler()
|
||||
return Response(src, 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,30 +1,12 @@
|
||||
;; Auth page components (login, device, check email)
|
||||
;; Auth page components (device auth — account-specific)
|
||||
;; Login and check-email components are shared: see shared/sx/templates/auth.sx
|
||||
|
||||
(defcomp ~account-login-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-login-form (&key error action csrf-token email)
|
||||
(div :class "py-8 max-w-md mx-auto"
|
||||
(h1 :class "text-2xl font-bold mb-6" "Sign in")
|
||||
error
|
||||
(form :method "post" :action action :class "space-y-4"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf-token)
|
||||
(div
|
||||
(label :for "email" :class "block text-sm font-medium mb-1" "Email address")
|
||||
(input :type "email" :name "email" :id "email" :value email :required true :autofocus true
|
||||
:class "w-full border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"))
|
||||
(button :type "submit"
|
||||
:class "w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition"
|
||||
"Send magic link"))))
|
||||
|
||||
(defcomp ~account-device-error (&key error)
|
||||
(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.")
|
||||
@@ -40,19 +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.")))
|
||||
|
||||
(defcomp ~account-check-email-error (&key error)
|
||||
(when error
|
||||
(div :class "bg-yellow-50 border border-yellow-200 text-yellow-700 p-3 rounded mt-4"
|
||||
error)))
|
||||
;; 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)))))
|
||||
|
||||
(defcomp ~account-check-email (&key email error)
|
||||
(div :class "py-8 max-w-md mx-auto text-center"
|
||||
(h1 :class "text-2xl font-bold mb-4" "Check your email")
|
||||
(p :class "text-stone-600 mb-2" "We sent a sign-in link to " (strong email) ".")
|
||||
(p :class "text-stone-500 text-sm" "Click the link in the email to sign in. The link expires in 15 minutes.")
|
||||
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
|
||||
@@ -42,7 +42,19 @@
|
||||
logout)
|
||||
labels)))
|
||||
|
||||
;; Header child wrapper
|
||||
(defcomp ~account-header-child (&key inner)
|
||||
(div :id "root-header-child" :class "flex flex-col w-full items-center"
|
||||
inner))
|
||||
;; 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")))))))
|
||||
|
||||
9
account/sx/handlers/auth-menu.sx
Normal file
9
account/sx/handlers/auth-menu.sx
Normal file
@@ -0,0 +1,9 @@
|
||||
;; Account auth-menu fragment handler
|
||||
;; returns: sx
|
||||
;;
|
||||
;; Renders the desktop + mobile auth menu (sign-in or user link).
|
||||
|
||||
(defhandler auth-menu (&key email)
|
||||
(~auth-menu
|
||||
:user-email (when email email)
|
||||
:account-url (app-url "account" "")))
|
||||
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,37 +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-toggle-off (&key id url hdrs target)
|
||||
(div :id id :class "flex items-center"
|
||||
(button :sx-post url :sx-headers hdrs :sx-target target :sx-swap "outerHTML"
|
||||
:class "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"
|
||||
:role "switch" :aria-checked "false"
|
||||
(span :class "inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform translate-x-1"))))
|
||||
|
||||
(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,392 +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, header_child_sx, oob_page_sx,
|
||||
)
|
||||
|
||||
# Load account-specific .sx components at import time
|
||||
load_service_components(os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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-off",
|
||||
id=f"nl-{nid}", url=toggle_url,
|
||||
hdrs=f'{{"X-CSRFToken": "{csrf_token}"}}',
|
||||
target=f"#nl-{nid}",
|
||||
)
|
||||
|
||||
|
||||
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("account-login-error", error=error) if error else ""
|
||||
|
||||
return sx_call(
|
||||
"account-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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_account_page(ctx: dict) -> str:
|
||||
"""Full page: account dashboard."""
|
||||
main = _account_main_panel_sx(ctx)
|
||||
|
||||
hdr = root_header_sx(ctx)
|
||||
hdr_child = header_child_sx(_auth_header_sx(ctx))
|
||||
header_rows = "(<> " + hdr + " " + hdr_child + ")"
|
||||
|
||||
return full_page_sx(ctx, header_rows=header_rows,
|
||||
content=main,
|
||||
menu=_auth_nav_mobile_sx(ctx))
|
||||
|
||||
|
||||
async def render_account_oob(ctx: dict) -> str:
|
||||
"""OOB response for account dashboard."""
|
||||
main = _account_main_panel_sx(ctx)
|
||||
|
||||
oobs = "(<> " + _auth_header_sx(ctx, oob=True) + " " + root_header_sx(ctx, oob=True) + ")"
|
||||
|
||||
return oob_page_sx(oobs=oobs,
|
||||
content=main,
|
||||
menu=_auth_nav_mobile_sx(ctx))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Newsletters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_newsletters_page(ctx: dict, newsletter_list: list) -> str:
|
||||
"""Full page: newsletters."""
|
||||
main = _newsletters_panel_sx(ctx, newsletter_list)
|
||||
|
||||
hdr = root_header_sx(ctx)
|
||||
hdr_child = header_child_sx(_auth_header_sx(ctx))
|
||||
header_rows = "(<> " + hdr + " " + hdr_child + ")"
|
||||
|
||||
return full_page_sx(ctx, header_rows=header_rows,
|
||||
content=main,
|
||||
menu=_auth_nav_mobile_sx(ctx))
|
||||
|
||||
|
||||
async def render_newsletters_oob(ctx: dict, newsletter_list: list) -> str:
|
||||
"""OOB response for newsletters."""
|
||||
main = _newsletters_panel_sx(ctx, newsletter_list)
|
||||
|
||||
oobs = "(<> " + _auth_header_sx(ctx, oob=True) + " " + root_header_sx(ctx, oob=True) + ")"
|
||||
|
||||
return oob_page_sx(oobs=oobs,
|
||||
content=main,
|
||||
menu=_auth_nav_mobile_sx(ctx))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Fragment pages
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_fragment_page(ctx: dict, page_fragment_html: str) -> str:
|
||||
"""Full page: fragment-provided content."""
|
||||
hdr = root_header_sx(ctx)
|
||||
hdr_child = header_child_sx(_auth_header_sx(ctx))
|
||||
header_rows = "(<> " + hdr + " " + hdr_child + ")"
|
||||
|
||||
return full_page_sx(ctx, header_rows=header_rows,
|
||||
content=f'(~rich-text :html "{_sx_escape(page_fragment_html)}")',
|
||||
menu=_auth_nav_mobile_sx(ctx))
|
||||
|
||||
|
||||
async def render_fragment_oob(ctx: dict, page_fragment_html: str) -> str:
|
||||
"""OOB response for fragment pages."""
|
||||
oobs = "(<> " + _auth_header_sx(ctx, oob=True) + " " + root_header_sx(ctx, oob=True) + ")"
|
||||
|
||||
return oob_page_sx(oobs=oobs,
|
||||
content=f'(~rich-text :html "{_sx_escape(page_fragment_html)}")',
|
||||
menu=_auth_nav_mobile_sx(ctx))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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(
|
||||
"account-check-email-error", error=str(escape(email_error))
|
||||
) if email_error else ""
|
||||
|
||||
return sx_call(
|
||||
"account-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('"', '\\"')
|
||||
19
account/sxc/pages/__init__.py
Normal file
19
account/sxc/pages/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""Account defpage setup — registers layouts and loads .sx pages."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def setup_account_pages() -> None:
|
||||
"""Register account-specific layouts and load page definitions."""
|
||||
_register_account_layouts()
|
||||
_load_account_page_files()
|
||||
|
||||
|
||||
def _load_account_page_files() -> None:
|
||||
import os
|
||||
from shared.sx.pages import load_page_dir
|
||||
load_page_dir(os.path.dirname(__file__), "account")
|
||||
|
||||
|
||||
def _register_account_layouts() -> None:
|
||||
from shared.sx.layouts import register_sx_layout
|
||||
register_sx_layout("account", "account-layout-full", "account-layout-oob", "account-layout-mobile")
|
||||
40
account/sxc/pages/account.sx
Normal file
40
account/sxc/pages/account.sx
Normal file
@@ -0,0 +1,40 @@
|
||||
;; Account app — declarative page definitions
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Account dashboard
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defpage account-dashboard
|
||||
:path "/"
|
||||
:auth :login
|
||||
:layout :account
|
||||
:content (~dashboard/content))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Newsletters
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defpage newsletters
|
||||
:path "/newsletters/"
|
||||
:auth :login
|
||||
:layout :account
|
||||
: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)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defpage fragment-page
|
||||
:path "/<slug>/"
|
||||
:auth :login
|
||||
:layout :account
|
||||
: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)))
|
||||
@@ -1,44 +0,0 @@
|
||||
<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">Bookings</h1>
|
||||
|
||||
{% if bookings %}
|
||||
<div class="divide-y divide-stone-100">
|
||||
{% for booking in bookings %}
|
||||
<div class="py-4 first:pt-0 last:pb-0">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-stone-800">{{ booking.name }}</p>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-stone-500">
|
||||
<span>{{ booking.start_at.strftime('%d %b %Y, %H:%M') }}</span>
|
||||
{% if booking.end_at %}
|
||||
<span>– {{ booking.end_at.strftime('%H:%M') }}</span>
|
||||
{% endif %}
|
||||
{% if booking.calendar_name %}
|
||||
<span>· {{ booking.calendar_name }}</span>
|
||||
{% endif %}
|
||||
{% if booking.cost %}
|
||||
<span>· £{{ booking.cost }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
{% if booking.state == 'confirmed' %}
|
||||
<span class="inline-flex items-center rounded-full bg-emerald-50 border border-emerald-200 px-2.5 py-0.5 text-xs font-medium text-emerald-700">confirmed</span>
|
||||
{% elif booking.state == 'provisional' %}
|
||||
<span class="inline-flex items-center rounded-full bg-amber-50 border border-amber-200 px-2.5 py-0.5 text-xs font-medium text-amber-700">provisional</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center rounded-full bg-stone-50 border border-stone-200 px-2.5 py-0.5 text-xs font-medium text-stone-600">{{ booking.state }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm text-stone-500">No bookings yet.</p>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -1 +0,0 @@
|
||||
{{ page_fragment_html | safe }}
|
||||
@@ -1,49 +0,0 @@
|
||||
<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">
|
||||
|
||||
{% if error %}
|
||||
<div class="rounded-lg border border-red-200 bg-red-50 text-red-800 px-4 py-3 text-sm">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Account header #}
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold tracking-tight">Account</h1>
|
||||
{% if g.user %}
|
||||
<p class="text-sm text-stone-500 mt-1">{{ g.user.email }}</p>
|
||||
{% if g.user.name %}
|
||||
<p class="text-sm text-stone-600">{{ g.user.name }}</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<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"></i>
|
||||
Sign out
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{# Labels #}
|
||||
{% set labels = g.user.labels if g.user is defined and g.user.labels is defined else [] %}
|
||||
{% if labels %}
|
||||
<div>
|
||||
<h2 class="text-base font-semibold tracking-tight mb-3">Labels</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% for label in labels %}
|
||||
<span class="inline-flex items-center rounded-full border border-stone-200 px-3 py-1 text-xs font-medium bg-white/60">
|
||||
{{ label.name }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,7 +0,0 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% call links.link(account_url('/newsletters/'), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
|
||||
newsletters
|
||||
{% endcall %}
|
||||
{% if account_nav_html %}
|
||||
{{ account_nav_html | safe }}
|
||||
{% endif %}
|
||||
@@ -1,17 +0,0 @@
|
||||
<div id="nl-{{ un.newsletter_id }}" class="flex items-center">
|
||||
<button
|
||||
sx-post="{{ account_url('/newsletter/' ~ un.newsletter_id ~ '/toggle/') }}"
|
||||
sx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
|
||||
sx-target="#nl-{{ un.newsletter_id }}"
|
||||
sx-swap="outerHTML"
|
||||
class="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
|
||||
{% if un.subscribed %}bg-emerald-500{% else %}bg-stone-300{% endif %}"
|
||||
role="switch"
|
||||
aria-checked="{{ 'true' if un.subscribed else 'false' }}"
|
||||
>
|
||||
<span
|
||||
class="inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform
|
||||
{% if un.subscribed %}translate-x-6{% else %}translate-x-1{% endif %}"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -1,46 +0,0 @@
|
||||
<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</h1>
|
||||
|
||||
{% if newsletter_list %}
|
||||
<div class="divide-y divide-stone-100">
|
||||
{% for item in newsletter_list %}
|
||||
<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">{{ item.newsletter.name }}</p>
|
||||
{% if item.newsletter.description %}
|
||||
<p class="text-xs text-stone-500 mt-0.5 truncate">{{ item.newsletter.description }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="ml-4 flex-shrink-0">
|
||||
{% if item.un %}
|
||||
{% with un=item.un %}
|
||||
{% include "_types/auth/_newsletter_toggle.html" %}
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
{# No subscription row yet — show an off toggle that will create one #}
|
||||
<div id="nl-{{ item.newsletter.id }}" class="flex items-center">
|
||||
<button
|
||||
sx-post="{{ account_url('/newsletter/' ~ item.newsletter.id ~ '/toggle/') }}"
|
||||
sx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
|
||||
sx-target="#nl-{{ item.newsletter.id }}"
|
||||
sx-swap="outerHTML"
|
||||
class="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"
|
||||
role="switch"
|
||||
aria-checked="false"
|
||||
>
|
||||
<span class="inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform translate-x-1"></span>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm text-stone-500">No newsletters available.</p>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,29 +0,0 @@
|
||||
{% extends 'oob_elements.html' %}
|
||||
|
||||
{# OOB elements for HTMX navigation - all elements that need updating #}
|
||||
|
||||
{# Import shared OOB macros #}
|
||||
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
|
||||
|
||||
{# Header with app title - includes cart-mini, navigation, and market-specific header #}
|
||||
|
||||
{% block oobs %}
|
||||
|
||||
{% from '_types/root/_n/macros.html' import oob_header with context %}
|
||||
{{oob_header('root-header-child', 'auth-header-child', '_types/auth/header/_header.html')}}
|
||||
|
||||
{% from '_types/root/header/_header.html' import header_row with context %}
|
||||
{{ header_row(oob=True) }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block mobile_menu %}
|
||||
{% include '_types/auth/_nav.html' %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
{% include oob.main %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
<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">Tickets</h1>
|
||||
|
||||
{% if tickets %}
|
||||
<div class="divide-y divide-stone-100">
|
||||
{% for ticket in tickets %}
|
||||
<div class="py-4 first:pt-0 last:pb-0">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<a href="{{ events_url('/tickets/' ~ ticket.code ~ '/') }}"
|
||||
class="text-sm font-medium text-stone-800 hover:text-emerald-700 transition">
|
||||
{{ ticket.entry_name }}
|
||||
</a>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-stone-500">
|
||||
<span>{{ ticket.entry_start_at.strftime('%d %b %Y, %H:%M') }}</span>
|
||||
{% if ticket.calendar_name %}
|
||||
<span>· {{ ticket.calendar_name }}</span>
|
||||
{% endif %}
|
||||
{% if ticket.ticket_type_name %}
|
||||
<span>· {{ ticket.ticket_type_name }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
{% if ticket.state == 'checked_in' %}
|
||||
<span class="inline-flex items-center rounded-full bg-blue-50 border border-blue-200 px-2.5 py-0.5 text-xs font-medium text-blue-700">checked in</span>
|
||||
{% elif ticket.state == 'confirmed' %}
|
||||
<span class="inline-flex items-center rounded-full bg-emerald-50 border border-emerald-200 px-2.5 py-0.5 text-xs font-medium text-emerald-700">confirmed</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center rounded-full bg-amber-50 border border-amber-200 px-2.5 py-0.5 text-xs font-medium text-amber-700">{{ ticket.state }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm text-stone-500">No tickets yet.</p>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,33 +0,0 @@
|
||||
{% extends "_types/root/index.html" %}
|
||||
{% block content %}
|
||||
<div class="w-full max-w-md">
|
||||
<div class="bg-white/70 dark:bg-neutral-900/70 backdrop-blur rounded-2xl shadow p-6 sm:p-8 border border-neutral-200 dark:border-neutral-800">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Check your email</h1>
|
||||
|
||||
<p class="text-base text-stone-700 dark:text-stone-300 mt-3">
|
||||
If an account exists for
|
||||
<strong class="text-stone-900 dark:text-white">{{ email }}</strong>,
|
||||
you’ll receive a link to sign in. It expires in 15 minutes.
|
||||
</p>
|
||||
|
||||
{% if email_error %}
|
||||
<div
|
||||
class="mt-4 rounded-lg border border-red-300 bg-red-50 text-red-700 text-sm px-3 py-2 flex items-start gap-2"
|
||||
role="alert"
|
||||
>
|
||||
<span class="font-medium">Heads up:</span>
|
||||
<span>{{ email_error }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<p class="mt-6 text-sm">
|
||||
<a
|
||||
href="{{ blog_url('/auth/login/') }}"
|
||||
class="text-stone-600 dark:text-stone-300 hover:underline"
|
||||
>
|
||||
← Back
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,12 +0,0 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% macro header_row(oob=False) %}
|
||||
{% call links.menu_row(id='auth-row', oob=oob) %}
|
||||
{% call links.link(account_url('/'), hx_select_search ) %}
|
||||
<i class="fa-solid fa-user"></i>
|
||||
<div>account</div>
|
||||
{% endcall %}
|
||||
{% call links.desktop_nav() %}
|
||||
{% include "_types/auth/_nav.html" %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% endmacro %}
|
||||
@@ -1,18 +0,0 @@
|
||||
{% extends "_types/root/_index.html" %}
|
||||
|
||||
|
||||
{% block root_header_child %}
|
||||
{% from '_types/root/_n/macros.html' import index_row with context %}
|
||||
{% call index_row('auth-header-child', '_types/auth/header/_header.html') %}
|
||||
{% block auth_header_child %}
|
||||
{% endblock %}
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
|
||||
{% block _main_mobile_menu %}
|
||||
{% include "_types/auth/_nav.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_types/auth/_main_panel.html' %}
|
||||
{% endblock %}
|
||||
@@ -1,18 +0,0 @@
|
||||
{% extends oob.extends %}
|
||||
|
||||
|
||||
{% block root_header_child %}
|
||||
{% from '_types/root/_n/macros.html' import index_row with context %}
|
||||
{% call index_row(oob.child_id, oob.header) %}
|
||||
{% block auth_header_child %}
|
||||
{% endblock %}
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
|
||||
{% block _main_mobile_menu %}
|
||||
{% include oob.nav %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include oob.main %}
|
||||
{% endblock %}
|
||||
@@ -1,46 +0,0 @@
|
||||
{% extends "_types/root/index.html" %}
|
||||
{% block content %}
|
||||
<div class="w-full max-w-md">
|
||||
<div class="bg-white/70 dark:bg-neutral-900/70 backdrop-blur rounded-2xl shadow p-6 sm:p-8 border border-neutral-200 dark:border-neutral-800">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Sign in</h1>
|
||||
<p class="mt-2 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Enter your email and we’ll email you a one-time sign-in link.
|
||||
</p>
|
||||
|
||||
{% if error %}
|
||||
<div class="mt-4 rounded-lg border border-red-200 bg-red-50 text-red-800 dark:border-red-900/40 dark:bg-red-950/40 dark:text-red-200 px-4 py-3 text-sm">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form
|
||||
method="post" action="{{ blog_url('/auth/start/') }}"
|
||||
class="mt-6 space-y-5"
|
||||
>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value="{{ email or '' }}"
|
||||
required
|
||||
class="mt-2 block w-full rounded-lg border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-neutral-900 dark:text-neutral-100 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-0 focus:ring-neutral-900 dark:focus:ring-neutral-200"
|
||||
autocomplete="email"
|
||||
inputmode="email"
|
||||
>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex w-full items-center justify-center rounded-lg bg-neutral-900 px-4 py-2.5 text-sm font-medium text-white hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-neutral-900 disabled:opacity-50 dark:bg-neutral-50 dark:text-neutral-900 dark:hover:bg-white"
|
||||
>
|
||||
Send link
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,19 +0,0 @@
|
||||
{% extends "_types/root/_index.html" %}
|
||||
{% block meta %}{% endblock %}
|
||||
{% block title %}Check your email — Rose Ash{% endblock %}
|
||||
{% block content %}
|
||||
<div class="py-8 max-w-md mx-auto text-center">
|
||||
<h1 class="text-2xl font-bold mb-4">Check your email</h1>
|
||||
<p class="text-stone-600 mb-2">
|
||||
We sent a sign-in link to <strong>{{ email }}</strong>.
|
||||
</p>
|
||||
<p class="text-stone-500 text-sm">
|
||||
Click the link in the email to sign in. The link expires in 15 minutes.
|
||||
</p>
|
||||
{% if email_error %}
|
||||
<div class="bg-yellow-50 border border-yellow-200 text-yellow-700 p-3 rounded mt-4">
|
||||
{{ email_error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,41 +0,0 @@
|
||||
{% extends "_types/root/_index.html" %}
|
||||
{% block meta %}{% endblock %}
|
||||
{% block title %}Authorize Device — Rose Ash{% endblock %}
|
||||
{% block content %}
|
||||
<div class="py-8 max-w-md mx-auto">
|
||||
<h1 class="text-2xl font-bold mb-6">Authorize device</h1>
|
||||
<p class="text-stone-600 mb-4">Enter the code shown in your terminal to sign in.</p>
|
||||
|
||||
{% if error %}
|
||||
<div class="bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{{ url_for('auth.device_submit') }}" class="space-y-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div>
|
||||
<label for="code" class="block text-sm font-medium mb-1">Device code</label>
|
||||
<input
|
||||
type="text"
|
||||
name="code"
|
||||
id="code"
|
||||
value="{{ code | default('') }}"
|
||||
placeholder="XXXX-XXXX"
|
||||
required
|
||||
autofocus
|
||||
maxlength="9"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
class="w-full border border-stone-300 rounded px-3 py-3 text-center text-2xl tracking-widest font-mono uppercase focus:outline-none focus:ring-2 focus:ring-stone-500"
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition"
|
||||
>
|
||||
Authorize
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,9 +0,0 @@
|
||||
{% extends "_types/root/_index.html" %}
|
||||
{% block meta %}{% endblock %}
|
||||
{% block title %}Device Authorized — Rose Ash{% endblock %}
|
||||
{% block content %}
|
||||
<div class="py-8 max-w-md mx-auto text-center">
|
||||
<h1 class="text-2xl font-bold mb-4">Device authorized</h1>
|
||||
<p class="text-stone-600">You can close this window and return to your terminal.</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,36 +0,0 @@
|
||||
{% extends "_types/root/_index.html" %}
|
||||
{% block meta %}{% endblock %}
|
||||
{% block title %}Login — Rose Ash{% endblock %}
|
||||
{% block content %}
|
||||
<div class="py-8 max-w-md mx-auto">
|
||||
<h1 class="text-2xl font-bold mb-6">Sign in</h1>
|
||||
|
||||
{% if error %}
|
||||
<div class="bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{{ url_for('auth.start_login') }}" class="space-y-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium mb-1">Email address</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
value="{{ email | default('') }}"
|
||||
required
|
||||
autofocus
|
||||
class="w-full border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition"
|
||||
>
|
||||
Send magic link
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
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))
|
||||
@@ -2,7 +2,7 @@ from alembic import context
|
||||
from shared.db.alembic_env import run_alembic
|
||||
|
||||
MODELS = [
|
||||
"shared.models.ghost_content",
|
||||
"blog.models.content",
|
||||
"shared.models.kv",
|
||||
"shared.models.menu_item",
|
||||
"shared.models.menu_node",
|
||||
@@ -13,6 +13,7 @@ MODELS = [
|
||||
|
||||
TABLES = frozenset({
|
||||
"posts", "authors", "post_authors", "tags", "post_tags",
|
||||
"post_users",
|
||||
"snippets", "tag_groups", "tag_group_tags",
|
||||
"menu_items", "menu_nodes", "kv",
|
||||
"page_configs",
|
||||
|
||||
67
blog/alembic/versions/0004_ghost_id_nullable_and_defaults.py
Normal file
67
blog/alembic/versions/0004_ghost_id_nullable_and_defaults.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Make ghost_id nullable, add defaults, create post_users M2M table.
|
||||
|
||||
Revision ID: 0004
|
||||
Revises: 0003_add_page_configs
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "blog_0004"
|
||||
down_revision = "blog_0003"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Make ghost_id nullable
|
||||
op.alter_column("posts", "ghost_id", existing_type=sa.String(64), nullable=True)
|
||||
op.alter_column("authors", "ghost_id", existing_type=sa.String(64), nullable=True)
|
||||
op.alter_column("tags", "ghost_id", existing_type=sa.String(64), nullable=True)
|
||||
|
||||
# Add server defaults for Post
|
||||
op.alter_column(
|
||||
"posts", "uuid",
|
||||
existing_type=sa.String(64),
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
)
|
||||
op.alter_column(
|
||||
"posts", "updated_at",
|
||||
existing_type=sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
)
|
||||
op.alter_column(
|
||||
"posts", "created_at",
|
||||
existing_type=sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
)
|
||||
|
||||
# Create post_users M2M table (replaces post_authors for new posts)
|
||||
op.create_table(
|
||||
"post_users",
|
||||
sa.Column("post_id", sa.Integer, sa.ForeignKey("posts.id", ondelete="CASCADE"), primary_key=True),
|
||||
sa.Column("user_id", sa.Integer, primary_key=True),
|
||||
sa.Column("sort_order", sa.Integer, nullable=False, server_default="0"),
|
||||
)
|
||||
op.create_index("ix_post_users_user_id", "post_users", ["user_id"])
|
||||
|
||||
# Backfill post_users from post_authors for posts that already have user_id.
|
||||
# This maps each post's authors to the post's user_id (primary author).
|
||||
# Multi-author mapping requires the full sync script.
|
||||
op.execute("""
|
||||
INSERT INTO post_users (post_id, user_id, sort_order)
|
||||
SELECT p.id, p.user_id, 0
|
||||
FROM posts p
|
||||
WHERE p.user_id IS NOT NULL
|
||||
AND p.deleted_at IS NULL
|
||||
ON CONFLICT DO NOTHING
|
||||
""")
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table("post_users")
|
||||
op.alter_column("posts", "created_at", existing_type=sa.DateTime(timezone=True), server_default=None)
|
||||
op.alter_column("posts", "updated_at", existing_type=sa.DateTime(timezone=True), server_default=None)
|
||||
op.alter_column("posts", "uuid", existing_type=sa.String(64), server_default=None)
|
||||
op.alter_column("tags", "ghost_id", existing_type=sa.String(64), nullable=False)
|
||||
op.alter_column("authors", "ghost_id", existing_type=sa.String(64), nullable=False)
|
||||
op.alter_column("posts", "ghost_id", existing_type=sa.String(64), nullable=False)
|
||||
20
blog/alembic/versions/0005_add_sx_content.py
Normal file
20
blog/alembic/versions/0005_add_sx_content.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""Add sx_content column to posts table.
|
||||
|
||||
Revision ID: blog_0005
|
||||
Revises: blog_0004
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "blog_0005"
|
||||
down_revision = "blog_0004"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column("posts", sa.Column("sx_content", sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("posts", "sx_content")
|
||||
30
blog/app.py
30
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,10 +15,10 @@ from bp import (
|
||||
register_admin,
|
||||
register_menu_items,
|
||||
register_snippets,
|
||||
register_fragments,
|
||||
register_data,
|
||||
register_actions,
|
||||
)
|
||||
from sxc.pages import setup_blog_pages
|
||||
|
||||
|
||||
async def blog_context() -> dict:
|
||||
@@ -80,6 +79,8 @@ async def blog_context() -> dict:
|
||||
def create_app() -> "Quart":
|
||||
from services import register_domain_services
|
||||
|
||||
setup_blog_pages()
|
||||
|
||||
app = create_base_app(
|
||||
"blog",
|
||||
context_fn=blog_context,
|
||||
@@ -105,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())
|
||||
|
||||
@@ -134,7 +137,7 @@ def create_app() -> "Quart":
|
||||
async def oembed():
|
||||
from urllib.parse import urlparse
|
||||
from quart import jsonify
|
||||
from shared.services.registry import services
|
||||
from services import blog_service
|
||||
from shared.infrastructure.urls import blog_url
|
||||
from shared.infrastructure.oembed import build_oembed_response
|
||||
|
||||
@@ -147,7 +150,7 @@ def create_app() -> "Quart":
|
||||
if not slug:
|
||||
return jsonify({"error": "could not extract slug"}), 404
|
||||
|
||||
post = await services.blog.get_post_by_slug(g.s, slug)
|
||||
post = await blog_service.get_post_by_slug(g.s, slug)
|
||||
if not post:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
|
||||
@@ -159,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,34 +23,6 @@ def register(url_prefix):
|
||||
"base_title": f"{config()['title']} settings",
|
||||
}
|
||||
|
||||
@bp.get("/")
|
||||
@require_admin
|
||||
async def home():
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_settings_page, render_settings_oob
|
||||
|
||||
tctx = await get_template_context()
|
||||
if not is_htmx_request():
|
||||
html = await render_settings_page(tctx)
|
||||
return await make_response(html)
|
||||
else:
|
||||
sx_src = await render_settings_oob(tctx)
|
||||
return sx_response(sx_src)
|
||||
|
||||
@bp.get("/cache/")
|
||||
@require_admin
|
||||
async def cache():
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_cache_page, render_cache_oob
|
||||
|
||||
tctx = await get_template_context()
|
||||
if not is_htmx_request():
|
||||
html = await render_cache_page(tctx)
|
||||
return await make_response(html)
|
||||
else:
|
||||
sx_src = await render_cache_oob(tctx)
|
||||
return sx_response(sx_src)
|
||||
|
||||
@bp.post("/cache_clear/")
|
||||
@require_admin
|
||||
async def cache_clear():
|
||||
@@ -65,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.cache"))
|
||||
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,35 +42,13 @@ async def _unassigned_tags(session):
|
||||
def register():
|
||||
bp = Blueprint("tag_groups_admin", __name__, url_prefix="/settings/tag-groups")
|
||||
|
||||
@bp.get("/")
|
||||
@require_admin
|
||||
async def index():
|
||||
groups = list(
|
||||
(await g.s.execute(
|
||||
select(TagGroup).order_by(TagGroup.sort_order, TagGroup.name)
|
||||
)).scalars()
|
||||
)
|
||||
unassigned = await _unassigned_tags(g.s)
|
||||
|
||||
ctx = {"groups": groups, "unassigned_tags": unassigned}
|
||||
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_tag_groups_page, render_tag_groups_oob
|
||||
|
||||
tctx = await get_template_context()
|
||||
tctx.update(ctx)
|
||||
if not is_htmx_request():
|
||||
return await make_response(await render_tag_groups_page(tctx))
|
||||
else:
|
||||
return sx_response(await render_tag_groups_oob(tctx))
|
||||
|
||||
@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.index"))
|
||||
return redirect(url_for("defpage_tag_groups_page"))
|
||||
|
||||
slug = _slugify(name)
|
||||
feature_image = (form.get("feature_image") or "").strip() or None
|
||||
@@ -90,55 +64,14 @@ def register():
|
||||
await g.s.flush()
|
||||
|
||||
await invalidate_tag_cache("blog")
|
||||
return redirect(url_for("blog.tag_groups_admin.index"))
|
||||
|
||||
@bp.get("/<int:id>/")
|
||||
@require_admin
|
||||
async def edit(id: int):
|
||||
tg = await g.s.get(TagGroup, id)
|
||||
if not tg:
|
||||
return redirect(url_for("blog.tag_groups_admin.index"))
|
||||
|
||||
# Assigned tag IDs for this group
|
||||
assigned_rows = list(
|
||||
(await g.s.execute(
|
||||
select(TagGroupTag.tag_id).where(TagGroupTag.tag_group_id == id)
|
||||
)).scalars()
|
||||
)
|
||||
assigned_tag_ids = set(assigned_rows)
|
||||
|
||||
# All public, non-deleted tags
|
||||
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()
|
||||
)
|
||||
|
||||
ctx = {
|
||||
"group": tg,
|
||||
"all_tags": all_tags,
|
||||
"assigned_tag_ids": assigned_tag_ids,
|
||||
}
|
||||
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_tag_group_edit_page, render_tag_group_edit_oob
|
||||
|
||||
tctx = await get_template_context()
|
||||
tctx.update(ctx)
|
||||
if not is_htmx_request():
|
||||
return await make_response(await render_tag_group_edit_page(tctx))
|
||||
else:
|
||||
return sx_response(await render_tag_group_edit_oob(tctx))
|
||||
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.index"))
|
||||
return redirect(url_for("defpage_tag_groups_page"))
|
||||
|
||||
form = await request.form
|
||||
name = (form.get("name") or "").strip()
|
||||
@@ -169,7 +102,7 @@ def register():
|
||||
await g.s.flush()
|
||||
|
||||
await invalidate_tag_cache("blog")
|
||||
return redirect(url_for("blog.tag_groups_admin.edit", id=id))
|
||||
return redirect(url_for("defpage_tag_group_edit", id=id))
|
||||
|
||||
@bp.post("/<int:id>/delete/")
|
||||
@require_admin
|
||||
@@ -179,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.index"))
|
||||
return redirect(url_for("defpage_tag_groups_page"))
|
||||
|
||||
return bp
|
||||
|
||||
445
blog/bp/blog/ghost/lexical_to_sx.py
Normal file
445
blog/bp/blog/ghost/lexical_to_sx.py
Normal file
@@ -0,0 +1,445 @@
|
||||
"""
|
||||
Lexical JSON → s-expression converter.
|
||||
|
||||
Mirrors lexical_renderer.py's registry/dispatch pattern but produces sx source
|
||||
instead of HTML. Used for backfilling existing posts and on-the-fly conversion
|
||||
when editing pre-migration posts in the SX editor.
|
||||
|
||||
Public API
|
||||
----------
|
||||
lexical_to_sx(doc) – Lexical JSON (dict or string) → sx source string
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Callable
|
||||
|
||||
import mistune
|
||||
|
||||
from shared.sx.html_to_sx import html_to_sx
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CONVERTERS: dict[str, Callable[[dict], str]] = {}
|
||||
|
||||
|
||||
def _converter(node_type: str):
|
||||
"""Decorator — register a function as the converter for *node_type*."""
|
||||
def decorator(fn: Callable[[dict], str]) -> Callable[[dict], str]:
|
||||
_CONVERTERS[node_type] = fn
|
||||
return fn
|
||||
return decorator
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def lexical_to_sx(doc: dict | str) -> str:
|
||||
"""Convert a Lexical JSON document to an sx source string."""
|
||||
if isinstance(doc, str):
|
||||
doc = json.loads(doc)
|
||||
root = doc.get("root", doc)
|
||||
children = root.get("children", [])
|
||||
parts = [_convert_node(c) for c in children]
|
||||
parts = [p for p in parts if p]
|
||||
if not parts:
|
||||
return '(<> (p ""))'
|
||||
if len(parts) == 1:
|
||||
return parts[0]
|
||||
return "(<>\n " + "\n ".join(parts) + ")"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _convert_node(node: dict) -> str:
|
||||
node_type = node.get("type", "")
|
||||
converter = _CONVERTERS.get(node_type)
|
||||
if converter:
|
||||
return converter(node)
|
||||
return ""
|
||||
|
||||
|
||||
def _convert_children(children: list[dict]) -> str:
|
||||
"""Convert children to inline sx content (for text nodes)."""
|
||||
parts = [_convert_node(c) for c in children]
|
||||
return " ".join(p for p in parts if p)
|
||||
|
||||
|
||||
def _esc(s: str) -> str:
|
||||
"""Escape a string for sx double-quoted literals."""
|
||||
return s.replace("\\", "\\\\").replace('"', '\\"')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Text format bitmask
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_FORMAT_BOLD = 1
|
||||
_FORMAT_ITALIC = 2
|
||||
_FORMAT_STRIKETHROUGH = 4
|
||||
_FORMAT_UNDERLINE = 8
|
||||
_FORMAT_CODE = 16
|
||||
_FORMAT_SUBSCRIPT = 32
|
||||
_FORMAT_SUPERSCRIPT = 64
|
||||
|
||||
_FORMAT_WRAPPERS: list[tuple[int, str]] = [
|
||||
(_FORMAT_BOLD, "strong"),
|
||||
(_FORMAT_ITALIC, "em"),
|
||||
(_FORMAT_STRIKETHROUGH, "s"),
|
||||
(_FORMAT_UNDERLINE, "u"),
|
||||
(_FORMAT_CODE, "code"),
|
||||
(_FORMAT_SUBSCRIPT, "sub"),
|
||||
(_FORMAT_SUPERSCRIPT, "sup"),
|
||||
]
|
||||
|
||||
|
||||
def _wrap_format(text_sx: str, fmt: int) -> str:
|
||||
for mask, tag in _FORMAT_WRAPPERS:
|
||||
if fmt & mask:
|
||||
text_sx = f"({tag} {text_sx})"
|
||||
return text_sx
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tier 1 — text nodes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@_converter("text")
|
||||
def _text(node: dict) -> str:
|
||||
text = node.get("text", "")
|
||||
if not text:
|
||||
return ""
|
||||
sx = f'"{_esc(text)}"'
|
||||
fmt = node.get("format", 0)
|
||||
if isinstance(fmt, int) and fmt:
|
||||
sx = _wrap_format(sx, fmt)
|
||||
return sx
|
||||
|
||||
|
||||
@_converter("linebreak")
|
||||
def _linebreak(_node: dict) -> str:
|
||||
return '"\\n"'
|
||||
|
||||
|
||||
@_converter("tab")
|
||||
def _tab(_node: dict) -> str:
|
||||
return '"\\t"'
|
||||
|
||||
|
||||
@_converter("paragraph")
|
||||
def _paragraph(node: dict) -> str:
|
||||
inner = _convert_children(node.get("children", []))
|
||||
if not inner:
|
||||
inner = '""'
|
||||
return f"(p {inner})"
|
||||
|
||||
|
||||
@_converter("extended-text")
|
||||
def _extended_text(node: dict) -> str:
|
||||
# extended-text can be block-level (with children) or inline (with text).
|
||||
# When it has a "text" field, treat it as a plain text node.
|
||||
if "text" in node:
|
||||
return _text(node)
|
||||
return _paragraph(node)
|
||||
|
||||
|
||||
@_converter("heading")
|
||||
def _heading(node: dict) -> str:
|
||||
tag = node.get("tag", "h2")
|
||||
inner = _convert_children(node.get("children", []))
|
||||
if not inner:
|
||||
inner = '""'
|
||||
return f"({tag} {inner})"
|
||||
|
||||
|
||||
@_converter("extended-heading")
|
||||
def _extended_heading(node: dict) -> str:
|
||||
if "text" in node:
|
||||
return _text(node)
|
||||
return _heading(node)
|
||||
|
||||
|
||||
@_converter("quote")
|
||||
def _quote(node: dict) -> str:
|
||||
inner = _convert_children(node.get("children", []))
|
||||
return f"(blockquote {inner})" if inner else '(blockquote "")'
|
||||
|
||||
|
||||
@_converter("extended-quote")
|
||||
def _extended_quote(node: dict) -> str:
|
||||
if "text" in node:
|
||||
return _text(node)
|
||||
return _quote(node)
|
||||
|
||||
|
||||
@_converter("link")
|
||||
def _link(node: dict) -> str:
|
||||
href = node.get("url", "")
|
||||
inner = _convert_children(node.get("children", []))
|
||||
if not inner:
|
||||
inner = f'"{_esc(href)}"'
|
||||
return f'(a :href "{_esc(href)}" {inner})'
|
||||
|
||||
|
||||
@_converter("autolink")
|
||||
def _autolink(node: dict) -> str:
|
||||
return _link(node)
|
||||
|
||||
|
||||
@_converter("at-link")
|
||||
def _at_link(node: dict) -> str:
|
||||
return _link(node)
|
||||
|
||||
|
||||
@_converter("list")
|
||||
def _list(node: dict) -> str:
|
||||
tag = "ol" if node.get("listType") == "number" else "ul"
|
||||
inner = _convert_children(node.get("children", []))
|
||||
return f"({tag} {inner})" if inner else f"({tag})"
|
||||
|
||||
|
||||
@_converter("listitem")
|
||||
def _listitem(node: dict) -> str:
|
||||
inner = _convert_children(node.get("children", []))
|
||||
return f"(li {inner})" if inner else '(li "")'
|
||||
|
||||
|
||||
@_converter("horizontalrule")
|
||||
def _horizontalrule(_node: dict) -> str:
|
||||
return "(hr)"
|
||||
|
||||
|
||||
@_converter("code")
|
||||
def _code(node: dict) -> str:
|
||||
inner = _convert_children(node.get("children", []))
|
||||
return f"(code {inner})" if inner else ""
|
||||
|
||||
|
||||
@_converter("codeblock")
|
||||
def _codeblock(node: dict) -> str:
|
||||
lang = node.get("language", "")
|
||||
code = node.get("code", "")
|
||||
lang_attr = f' :class "language-{_esc(lang)}"' if lang else ""
|
||||
return f'(pre (code{lang_attr} "{_esc(code)}"))'
|
||||
|
||||
|
||||
@_converter("code-highlight")
|
||||
def _code_highlight(node: dict) -> str:
|
||||
text = node.get("text", "")
|
||||
return f'"{_esc(text)}"' if text else ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tier 2 — common cards
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@_converter("image")
|
||||
def _image(node: dict) -> str:
|
||||
src = node.get("src", "")
|
||||
alt = node.get("alt", "")
|
||||
caption = node.get("caption", "")
|
||||
width = node.get("cardWidth", "") or node.get("width", "")
|
||||
href = node.get("href", "")
|
||||
|
||||
parts = [f':src "{_esc(src)}"']
|
||||
if alt:
|
||||
parts.append(f':alt "{_esc(alt)}"')
|
||||
if caption:
|
||||
parts.append(f":caption {html_to_sx(caption)}")
|
||||
if width:
|
||||
parts.append(f':width "{_esc(width)}"')
|
||||
if href:
|
||||
parts.append(f':href "{_esc(href)}"')
|
||||
return "(~kg_cards/kg-image " + " ".join(parts) + ")"
|
||||
|
||||
|
||||
@_converter("gallery")
|
||||
def _gallery(node: dict) -> str:
|
||||
images = node.get("images", [])
|
||||
if not images:
|
||||
return ""
|
||||
|
||||
# Group images into rows of 3 (matching lexical_renderer.py)
|
||||
rows = []
|
||||
for i in range(0, len(images), 3):
|
||||
row_imgs = images[i:i + 3]
|
||||
row_items = []
|
||||
for img in row_imgs:
|
||||
item_parts = [f'"src" "{_esc(img.get("src", ""))}"']
|
||||
if img.get("alt"):
|
||||
item_parts.append(f'"alt" "{_esc(img["alt"])}"')
|
||||
if img.get("caption"):
|
||||
item_parts.append(f'"caption" {html_to_sx(img["caption"])}')
|
||||
row_items.append("(dict " + " ".join(item_parts) + ")")
|
||||
rows.append("(list " + " ".join(row_items) + ")")
|
||||
|
||||
images_sx = "(list " + " ".join(rows) + ")"
|
||||
caption = node.get("caption", "")
|
||||
caption_attr = f" :caption {html_to_sx(caption)}" if caption else ""
|
||||
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_cards/kg-html {inner})"
|
||||
|
||||
|
||||
@_converter("embed")
|
||||
def _embed(node: dict) -> str:
|
||||
embed_html = node.get("html", "")
|
||||
caption = node.get("caption", "")
|
||||
parts = [f':html "{_esc(embed_html)}"']
|
||||
if caption:
|
||||
parts.append(f":caption {html_to_sx(caption)}")
|
||||
return "(~kg_cards/kg-embed " + " ".join(parts) + ")"
|
||||
|
||||
|
||||
@_converter("bookmark")
|
||||
def _bookmark(node: dict) -> str:
|
||||
url = node.get("url", "")
|
||||
meta = node.get("metadata", {})
|
||||
parts = [f':url "{_esc(url)}"']
|
||||
|
||||
title = meta.get("title", "") or node.get("title", "")
|
||||
if title:
|
||||
parts.append(f':title "{_esc(title)}"')
|
||||
desc = meta.get("description", "") or node.get("description", "")
|
||||
if desc:
|
||||
parts.append(f':description "{_esc(desc)}"')
|
||||
icon = meta.get("icon", "") or node.get("icon", "")
|
||||
if icon:
|
||||
parts.append(f':icon "{_esc(icon)}"')
|
||||
author = meta.get("author", "") or node.get("author", "")
|
||||
if author:
|
||||
parts.append(f':author "{_esc(author)}"')
|
||||
publisher = meta.get("publisher", "") or node.get("publisher", "")
|
||||
if publisher:
|
||||
parts.append(f':publisher "{_esc(publisher)}"')
|
||||
thumbnail = meta.get("thumbnail", "") or node.get("thumbnail", "")
|
||||
if thumbnail:
|
||||
parts.append(f':thumbnail "{_esc(thumbnail)}"')
|
||||
caption = node.get("caption", "")
|
||||
if caption:
|
||||
parts.append(f":caption {html_to_sx(caption)}")
|
||||
|
||||
return "(~kg_cards/kg-bookmark " + " ".join(parts) + ")"
|
||||
|
||||
|
||||
@_converter("callout")
|
||||
def _callout(node: dict) -> str:
|
||||
color = node.get("backgroundColor", "grey")
|
||||
emoji = node.get("calloutEmoji", "")
|
||||
inner = _convert_children(node.get("children", []))
|
||||
|
||||
parts = [f':color "{_esc(color)}"']
|
||||
if emoji:
|
||||
parts.append(f':emoji "{_esc(emoji)}"')
|
||||
if inner:
|
||||
parts.append(f':content {inner}')
|
||||
return "(~kg_cards/kg-callout " + " ".join(parts) + ")"
|
||||
|
||||
|
||||
@_converter("button")
|
||||
def _button(node: dict) -> str:
|
||||
text = node.get("buttonText", "")
|
||||
url = node.get("buttonUrl", "")
|
||||
alignment = node.get("alignment", "center")
|
||||
return f'(~kg_cards/kg-button :url "{_esc(url)}" :text "{_esc(text)}" :alignment "{_esc(alignment)}")'
|
||||
|
||||
|
||||
@_converter("toggle")
|
||||
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_cards/kg-toggle :heading "{_esc(heading)}"{content_attr})'
|
||||
|
||||
|
||||
@_converter("audio")
|
||||
def _audio(node: dict) -> str:
|
||||
src = node.get("src", "")
|
||||
title = node.get("title", "")
|
||||
duration = node.get("duration", 0)
|
||||
thumbnail = node.get("thumbnailSrc", "")
|
||||
|
||||
duration_min = int(duration) // 60
|
||||
duration_sec = int(duration) % 60
|
||||
duration_str = f"{duration_min}:{duration_sec:02d}"
|
||||
|
||||
parts = [f':src "{_esc(src)}"']
|
||||
if title:
|
||||
parts.append(f':title "{_esc(title)}"')
|
||||
parts.append(f':duration "{duration_str}"')
|
||||
if thumbnail:
|
||||
parts.append(f':thumbnail "{_esc(thumbnail)}"')
|
||||
return "(~kg_cards/kg-audio " + " ".join(parts) + ")"
|
||||
|
||||
|
||||
@_converter("video")
|
||||
def _video(node: dict) -> str:
|
||||
src = node.get("src", "")
|
||||
caption = node.get("caption", "")
|
||||
width = node.get("cardWidth", "")
|
||||
thumbnail = node.get("thumbnailSrc", "") or node.get("customThumbnailSrc", "")
|
||||
loop = node.get("loop", False)
|
||||
|
||||
parts = [f':src "{_esc(src)}"']
|
||||
if caption:
|
||||
parts.append(f":caption {html_to_sx(caption)}")
|
||||
if width:
|
||||
parts.append(f':width "{_esc(width)}"')
|
||||
if thumbnail:
|
||||
parts.append(f':thumbnail "{_esc(thumbnail)}"')
|
||||
if loop:
|
||||
parts.append(":loop true")
|
||||
return "(~kg_cards/kg-video " + " ".join(parts) + ")"
|
||||
|
||||
|
||||
@_converter("file")
|
||||
def _file(node: dict) -> str:
|
||||
src = node.get("src", "")
|
||||
filename = node.get("fileName", "")
|
||||
title = node.get("title", "") or filename
|
||||
file_size = node.get("fileSize", 0)
|
||||
caption = node.get("caption", "")
|
||||
|
||||
# Format size
|
||||
size_str = ""
|
||||
if file_size:
|
||||
kb = file_size / 1024
|
||||
if kb < 1024:
|
||||
size_str = f"{kb:.0f} KB"
|
||||
else:
|
||||
size_str = f"{kb / 1024:.1f} MB"
|
||||
|
||||
parts = [f':src "{_esc(src)}"']
|
||||
if filename:
|
||||
parts.append(f':filename "{_esc(filename)}"')
|
||||
if title:
|
||||
parts.append(f':title "{_esc(title)}"')
|
||||
if size_str:
|
||||
parts.append(f':filesize "{size_str}"')
|
||||
if caption:
|
||||
parts.append(f":caption {html_to_sx(caption)}")
|
||||
return "(~kg_cards/kg-file " + " ".join(parts) + ")"
|
||||
|
||||
|
||||
@_converter("paywall")
|
||||
def _paywall(_node: dict) -> str:
|
||||
return "(~kg_cards/kg-paywall)"
|
||||
|
||||
|
||||
@_converter("markdown")
|
||||
def _markdown(node: dict) -> str:
|
||||
md_text = node.get("markdown", "")
|
||||
rendered = mistune.html(md_text)
|
||||
inner = html_to_sx(rendered)
|
||||
return f"(~kg_cards/kg-md {inner})"
|
||||
@@ -63,6 +63,7 @@ def _post_to_public(p: Post) -> Dict[str, Any]:
|
||||
"slug": p.slug,
|
||||
"title": p.title,
|
||||
"html": p.html,
|
||||
"sx_content": p.sx_content,
|
||||
"is_page": p.is_page,
|
||||
"excerpt": p.custom_excerpt or p.excerpt,
|
||||
"custom_excerpt": p.custom_excerpt,
|
||||
|
||||
@@ -14,7 +14,6 @@ from quart import (
|
||||
url_for,
|
||||
)
|
||||
from .ghost_db import DBClient # adjust import path
|
||||
from shared.db.session import get_session
|
||||
from .filters.qs import makeqs_factory, decode
|
||||
from .services.posts_data import posts_data
|
||||
from .services.pages_data import pages_data
|
||||
@@ -22,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):
|
||||
@@ -47,39 +46,14 @@ def register(url_prefix, title):
|
||||
|
||||
@blogs_bp.before_app_serving
|
||||
async def init():
|
||||
from .ghost.ghost_sync import sync_all_content_from_ghost
|
||||
from sqlalchemy import text
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Advisory lock prevents multiple Hypercorn workers from
|
||||
# running the sync concurrently (which causes PK conflicts).
|
||||
async with get_session() as s:
|
||||
got_lock = await s.scalar(text("SELECT pg_try_advisory_lock(900001)"))
|
||||
if not got_lock:
|
||||
await s.rollback() # clean up before returning connection to pool
|
||||
return
|
||||
try:
|
||||
await sync_all_content_from_ghost(s)
|
||||
await s.commit()
|
||||
except Exception:
|
||||
logger.exception("Ghost sync failed — will retry on next deploy")
|
||||
try:
|
||||
await s.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
await s.execute(text("SELECT pg_advisory_unlock(900001)"))
|
||||
await s.commit()
|
||||
except Exception:
|
||||
pass # lock auto-releases when session closes
|
||||
# Ghost startup sync disabled (Phase 1) — blog service owns content
|
||||
# directly. The final_ghost_sync.py script was run before cutover.
|
||||
pass
|
||||
|
||||
@blogs_bp.before_request
|
||||
def route():
|
||||
async def route():
|
||||
g.makeqs_factory = makeqs_factory
|
||||
|
||||
|
||||
@blogs_bp.context_processor
|
||||
async def inject_root():
|
||||
return {
|
||||
@@ -88,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",
|
||||
@@ -144,123 +131,90 @@ 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.get("/new/")
|
||||
@require_admin
|
||||
async def new_post():
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_new_post_page, render_new_post_oob, render_editor_panel
|
||||
|
||||
tctx = await get_template_context()
|
||||
tctx["editor_html"] = render_editor_panel()
|
||||
if not is_htmx_request():
|
||||
html = await render_new_post_page(tctx)
|
||||
return await make_response(html)
|
||||
else:
|
||||
sx_src = await render_new_post_oob(tctx)
|
||||
return sx_response(sx_src)
|
||||
|
||||
@blogs_bp.post("/new/")
|
||||
@require_admin
|
||||
async def new_post_save():
|
||||
from .ghost.ghost_posts import create_post
|
||||
from .ghost.lexical_validator import validate_lexical
|
||||
from .ghost.ghost_sync import sync_single_post
|
||||
from services.post_writer import create_post as writer_create
|
||||
|
||||
form = await request.form
|
||||
title = form.get("title", "").strip() or "Untitled"
|
||||
@@ -275,74 +229,48 @@ 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 in Ghost
|
||||
ghost_post = await create_post(
|
||||
# Create directly in db_blog
|
||||
sx_content_raw = form.get("sx_content", "").strip() or None
|
||||
post = await writer_create(
|
||||
g.s,
|
||||
title=title,
|
||||
lexical_json=lexical_raw,
|
||||
status=status,
|
||||
user_id=g.user.id,
|
||||
feature_image=feature_image or None,
|
||||
custom_excerpt=custom_excerpt or None,
|
||||
feature_image_caption=feature_image_caption or None,
|
||||
sx_content=sx_content_raw,
|
||||
)
|
||||
|
||||
# Sync to local DB
|
||||
await sync_single_post(g.s, ghost_post["id"])
|
||||
await g.s.flush()
|
||||
|
||||
# Set user_id on the newly created post
|
||||
from models.ghost_content import Post
|
||||
from sqlalchemy import select
|
||||
local_post = (await g.s.execute(
|
||||
select(Post).where(Post.ghost_id == ghost_post["id"])
|
||||
)).scalar_one_or_none()
|
||||
if local_post and local_post.user_id is None:
|
||||
local_post.user_id = g.user.id
|
||||
await g.s.flush()
|
||||
|
||||
# Clear blog listing cache
|
||||
await invalidate_tag_cache("blog")
|
||||
|
||||
# Redirect to the edit page (post is likely a draft, so public detail would 404)
|
||||
return redirect(host_url(url_for("blog.post.admin.edit", slug=ghost_post["slug"])))
|
||||
# Redirect to the edit page
|
||||
return redirect(host_url(url_for("defpage_post_edit", slug=post.slug)))
|
||||
|
||||
|
||||
@blogs_bp.get("/new-page/")
|
||||
@require_admin
|
||||
async def new_page():
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_new_post_page, render_new_post_oob, render_editor_panel
|
||||
|
||||
tctx = await get_template_context()
|
||||
tctx["editor_html"] = render_editor_panel(is_page=True)
|
||||
tctx["is_page"] = True
|
||||
if not is_htmx_request():
|
||||
html = await render_new_post_page(tctx)
|
||||
return await make_response(html)
|
||||
else:
|
||||
sx_src = await render_new_post_oob(tctx)
|
||||
return sx_response(sx_src)
|
||||
|
||||
@blogs_bp.post("/new-page/")
|
||||
@require_admin
|
||||
async def new_page_save():
|
||||
from .ghost.ghost_posts import create_page
|
||||
from .ghost.lexical_validator import validate_lexical
|
||||
from .ghost.ghost_sync import sync_single_page
|
||||
from services.post_writer import create_page as writer_create_page
|
||||
|
||||
form = await request.form
|
||||
title = form.get("title", "").strip() or "Untitled"
|
||||
@@ -357,52 +285,43 @@ 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 in Ghost (as page)
|
||||
ghost_page = await create_page(
|
||||
# Create directly in db_blog
|
||||
sx_content_raw = form.get("sx_content", "").strip() or None
|
||||
page = await writer_create_page(
|
||||
g.s,
|
||||
title=title,
|
||||
lexical_json=lexical_raw,
|
||||
status=status,
|
||||
user_id=g.user.id,
|
||||
feature_image=feature_image or None,
|
||||
custom_excerpt=custom_excerpt or None,
|
||||
feature_image_caption=feature_image_caption or None,
|
||||
sx_content=sx_content_raw,
|
||||
)
|
||||
|
||||
# Sync to local DB (uses pages endpoint)
|
||||
await sync_single_page(g.s, ghost_page["id"])
|
||||
await g.s.flush()
|
||||
|
||||
# Set user_id on the newly created page
|
||||
from models.ghost_content import Post
|
||||
from sqlalchemy import select
|
||||
local_post = (await g.s.execute(
|
||||
select(Post).where(Post.ghost_id == ghost_page["id"])
|
||||
)).scalar_one_or_none()
|
||||
if local_post and local_post.user_id is None:
|
||||
local_post.user_id = g.user.id
|
||||
await g.s.flush()
|
||||
|
||||
# Clear blog listing cache
|
||||
await invalidate_tag_cache("blog")
|
||||
|
||||
# Redirect to the page admin
|
||||
return redirect(host_url(url_for("blog.post.admin.edit", slug=ghost_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,15 +1,11 @@
|
||||
# suma_browser/webhooks.py
|
||||
# Ghost webhooks — neutered (Phase 1).
|
||||
#
|
||||
# Post/page/author/tag handlers return 204 no-op.
|
||||
# Member webhook remains active (membership sync handled by account service).
|
||||
from __future__ import annotations
|
||||
import os
|
||||
from quart import Blueprint, request, abort, Response, g
|
||||
from quart import Blueprint, request, abort, Response
|
||||
|
||||
from ..ghost.ghost_sync import (
|
||||
sync_single_page,
|
||||
sync_single_post,
|
||||
sync_single_author,
|
||||
sync_single_tag,
|
||||
)
|
||||
from shared.browser.app.redis_cacher import clear_cache
|
||||
from shared.browser.app.csrf import csrf_exempt
|
||||
|
||||
ghost_webhooks = Blueprint("ghost_webhooks", __name__, url_prefix="/__ghost-webhook")
|
||||
@@ -32,6 +28,7 @@ def _extract_id(data: dict, key: str) -> str | None:
|
||||
@csrf_exempt
|
||||
@ghost_webhooks.route("/member/", methods=["POST"])
|
||||
async def webhook_member() -> Response:
|
||||
"""Member webhook still active — delegates to account service."""
|
||||
_check_secret(request)
|
||||
|
||||
data = await request.get_json(force=True, silent=True) or {}
|
||||
@@ -39,7 +36,6 @@ async def webhook_member() -> Response:
|
||||
if not ghost_id:
|
||||
abort(400, "no member id")
|
||||
|
||||
# Delegate to account service (membership data lives in db_account)
|
||||
from shared.infrastructure.actions import call_action
|
||||
try:
|
||||
await call_action(
|
||||
@@ -52,61 +48,25 @@ async def webhook_member() -> Response:
|
||||
logging.getLogger(__name__).error("Member sync via account failed: %s", e)
|
||||
return Response(status=204)
|
||||
|
||||
|
||||
# --- Neutered handlers: Ghost no longer writes content ---
|
||||
|
||||
@csrf_exempt
|
||||
@ghost_webhooks.post("/post/")
|
||||
@clear_cache(tag='blog')
|
||||
async def webhook_post() -> Response:
|
||||
_check_secret(request)
|
||||
|
||||
data = await request.get_json(force=True, silent=True) or {}
|
||||
ghost_id = _extract_id(data, "post")
|
||||
if not ghost_id:
|
||||
abort(400, "no post id")
|
||||
|
||||
await sync_single_post(g.s, ghost_id)
|
||||
|
||||
return Response(status=204)
|
||||
|
||||
@csrf_exempt
|
||||
@ghost_webhooks.post("/page/")
|
||||
@clear_cache(tag='blog')
|
||||
async def webhook_page() -> Response:
|
||||
_check_secret(request)
|
||||
|
||||
data = await request.get_json(force=True, silent=True) or {}
|
||||
ghost_id = _extract_id(data, "page")
|
||||
if not ghost_id:
|
||||
abort(400, "no page id")
|
||||
|
||||
await sync_single_page(g.s, ghost_id)
|
||||
|
||||
return Response(status=204)
|
||||
|
||||
@csrf_exempt
|
||||
@ghost_webhooks.post("/author/")
|
||||
@clear_cache(tag='blog')
|
||||
async def webhook_author() -> Response:
|
||||
_check_secret(request)
|
||||
|
||||
data = await request.get_json(force=True, silent=True) or {}
|
||||
ghost_id = _extract_id(data, "user") or _extract_id(data, "author")
|
||||
if not ghost_id:
|
||||
abort(400, "no author id")
|
||||
|
||||
await sync_single_author(g.s, ghost_id)
|
||||
|
||||
return Response(status=204)
|
||||
|
||||
@csrf_exempt
|
||||
@ghost_webhooks.post("/tag/")
|
||||
@clear_cache(tag='blog')
|
||||
async def webhook_tag() -> Response:
|
||||
_check_secret(request)
|
||||
|
||||
data = await request.get_json(force=True, silent=True) or {}
|
||||
ghost_id = _extract_id(data, "tag")
|
||||
if not ghost_id:
|
||||
abort(400, "no tag id")
|
||||
|
||||
await sync_single_tag(g.s, ghost_id)
|
||||
return Response(status=204)
|
||||
|
||||
@@ -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 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)
|
||||
|
||||
# --- post-by-slug ---
|
||||
async def _post_by_slug():
|
||||
slug = request.args.get("slug", "")
|
||||
post = await services.blog.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 services.blog.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 services.blog.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 services.blog.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,157 +0,0 @@
|
||||
"""Blog app fragment endpoints.
|
||||
|
||||
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
|
||||
by other coop apps via the fragment client.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, Response, g, render_template, request
|
||||
|
||||
from shared.infrastructure.fragments import FRAGMENT_HEADER
|
||||
from shared.services.navigation import get_navigation_tree
|
||||
|
||||
|
||||
def register():
|
||||
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
|
||||
|
||||
_handlers: dict[str, object] = {}
|
||||
|
||||
@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 = _handlers.get(fragment_type)
|
||||
if handler is None:
|
||||
return Response("", status=200, content_type="text/sx")
|
||||
result = await handler()
|
||||
return Response(result, status=200, content_type="text/sx")
|
||||
|
||||
# --- nav-tree fragment — returns sx source ---
|
||||
async def _nav_tree_handler():
|
||||
from shared.sx.helpers import sx_call, SxExpr
|
||||
from shared.infrastructure.urls import (
|
||||
blog_url, cart_url, market_url, events_url,
|
||||
federation_url, account_url, artdag_url,
|
||||
)
|
||||
|
||||
app_name = request.args.get("app_name", "")
|
||||
path = request.args.get("path", "/")
|
||||
first_seg = path.strip("/").split("/")[0]
|
||||
menu_items = list(await get_navigation_tree(g.s))
|
||||
|
||||
app_slugs = {
|
||||
"cart": cart_url("/"),
|
||||
"market": market_url("/"),
|
||||
"events": events_url("/"),
|
||||
"federation": federation_url("/"),
|
||||
"account": account_url("/"),
|
||||
"artdag": artdag_url("/"),
|
||||
}
|
||||
|
||||
nav_cls = "whitespace-nowrap flex items-center gap-2 rounded p-2 text-sm"
|
||||
|
||||
item_sxs = []
|
||||
for item in menu_items:
|
||||
href = app_slugs.get(item.slug, blog_url(f"/{item.slug}/"))
|
||||
selected = "true" if (item.slug == first_seg
|
||||
or item.slug == app_name) else "false"
|
||||
img = sx_call("blog-nav-item-image",
|
||||
src=getattr(item, "feature_image", None),
|
||||
label=getattr(item, "label", item.slug))
|
||||
item_sxs.append(sx_call(
|
||||
"blog-nav-item-link",
|
||||
href=href, hx_get=href, selected=selected, nav_cls=nav_cls,
|
||||
img=SxExpr(img), label=getattr(item, "label", item.slug),
|
||||
))
|
||||
|
||||
# artdag link
|
||||
href = artdag_url("/")
|
||||
selected = "true" if ("artdag" == first_seg
|
||||
or "artdag" == app_name) else "false"
|
||||
img = sx_call("blog-nav-item-image", src=None, label="art-dag")
|
||||
item_sxs.append(sx_call(
|
||||
"blog-nav-item-link",
|
||||
href=href, hx_get=href, selected=selected, nav_cls=nav_cls,
|
||||
img=SxExpr(img), label="art-dag",
|
||||
))
|
||||
|
||||
if not item_sxs:
|
||||
return sx_call("blog-nav-empty",
|
||||
wrapper_id="menu-items-nav-wrapper")
|
||||
|
||||
items_frag = "(<> " + " ".join(item_sxs) + ")"
|
||||
|
||||
arrow_cls = "scrolling-menu-arrow-menu-items-container"
|
||||
container_id = "menu-items-container"
|
||||
left_hs = ("on click set #" + container_id
|
||||
+ ".scrollLeft to #" + container_id + ".scrollLeft - 200")
|
||||
scroll_hs = ("on scroll "
|
||||
"set cls to '" + arrow_cls + "' "
|
||||
"set arrows to document.getElementsByClassName(cls) "
|
||||
"set show to (window.innerWidth >= 640 and "
|
||||
"my.scrollWidth > my.clientWidth) "
|
||||
"repeat for arrow in arrows "
|
||||
"if show remove .hidden from arrow add .flex to arrow "
|
||||
"else add .hidden to arrow remove .flex from arrow end "
|
||||
"end")
|
||||
right_hs = ("on click set #" + container_id
|
||||
+ ".scrollLeft to #" + container_id + ".scrollLeft + 200")
|
||||
|
||||
return sx_call("blog-nav-wrapper",
|
||||
arrow_cls=arrow_cls,
|
||||
container_id=container_id,
|
||||
left_hs=left_hs,
|
||||
scroll_hs=scroll_hs,
|
||||
right_hs=right_hs,
|
||||
items=SxExpr(items_frag))
|
||||
|
||||
_handlers["nav-tree"] = _nav_tree_handler
|
||||
|
||||
# --- link-card fragment — returns sx source ---
|
||||
def _blog_link_card_sx(post, link: str) -> str:
|
||||
from shared.sx.helpers import sx_call
|
||||
published = post.published_at.strftime("%d %b %Y") if post.published_at else None
|
||||
return sx_call("link-card",
|
||||
link=link,
|
||||
title=post.title,
|
||||
image=post.feature_image,
|
||||
icon="fas fa-file-alt",
|
||||
subtitle=post.custom_excerpt or post.excerpt,
|
||||
detail=published,
|
||||
data_app="blog")
|
||||
|
||||
async def _link_card_handler():
|
||||
from shared.services.registry import services
|
||||
from shared.infrastructure.urls import blog_url
|
||||
|
||||
slug = request.args.get("slug", "")
|
||||
keys_raw = request.args.get("keys", "")
|
||||
|
||||
# Batch mode
|
||||
if keys_raw:
|
||||
slugs = [k.strip() for k in keys_raw.split(",") if k.strip()]
|
||||
parts = []
|
||||
for s in slugs:
|
||||
parts.append(f"<!-- fragment:{s} -->")
|
||||
post = await services.blog.get_post_by_slug(g.s, s)
|
||||
if post:
|
||||
parts.append(_blog_link_card_sx(post, blog_url(f"/{post.slug}")))
|
||||
return "\n".join(parts)
|
||||
|
||||
# Single mode
|
||||
if not slug:
|
||||
return ""
|
||||
post = await services.blog.get_post_by_slug(g.s, slug)
|
||||
if not post:
|
||||
return ""
|
||||
return _blog_link_card_sx(post, blog_url(f"/{post.slug}"))
|
||||
|
||||
_handlers["link-card"] = _link_card_handler
|
||||
|
||||
bp._fragment_handlers = _handlers
|
||||
|
||||
return bp
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, render_template, 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,45 +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.get("/")
|
||||
@require_admin
|
||||
async def list_menu_items():
|
||||
"""List all menu items"""
|
||||
menu_items = await get_all_menu_items(g.s)
|
||||
|
||||
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_menu_items_page, render_menu_items_oob
|
||||
|
||||
tctx = await get_template_context()
|
||||
tctx["menu_items"] = menu_items
|
||||
if not is_htmx_request():
|
||||
html = await render_menu_items_page(tctx)
|
||||
return await make_response(html)
|
||||
else:
|
||||
sx_src = await render_menu_items_oob(tctx)
|
||||
return sx_response(sx_src)
|
||||
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"""
|
||||
html = await render_template(
|
||||
"_types/menu_items/_form.html",
|
||||
menu_item=None,
|
||||
)
|
||||
return await make_response(html)
|
||||
return sx_response(_render_menu_item_form())
|
||||
|
||||
@bp.post("/")
|
||||
@require_admin
|
||||
@@ -73,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)
|
||||
|
||||
@@ -89,11 +260,7 @@ def register():
|
||||
if not menu_item:
|
||||
return await make_response("Menu item not found", 404)
|
||||
|
||||
html = await render_template(
|
||||
"_types/menu_items/_form.html",
|
||||
menu_item=menu_item,
|
||||
)
|
||||
return await make_response(html)
|
||||
return sx_response(_render_menu_item_form(menu_item=menu_item))
|
||||
|
||||
@bp.put("/<int:item_id>/")
|
||||
@require_admin
|
||||
@@ -116,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)
|
||||
|
||||
@@ -137,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)
|
||||
|
||||
@@ -153,14 +318,7 @@ def register():
|
||||
pages, total = await search_pages(g.s, query, page, per_page)
|
||||
has_more = (page * per_page) < total
|
||||
|
||||
html = await render_template(
|
||||
"_types/menu_items/_page_search_results.html",
|
||||
pages=pages,
|
||||
query=query,
|
||||
page=page,
|
||||
has_more=has_more,
|
||||
)
|
||||
return await make_response(html)
|
||||
return sx_response(_render_page_search_results(pages, query, page, has_more))
|
||||
|
||||
@bp.post("/reorder/")
|
||||
@require_admin
|
||||
@@ -184,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)
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ from __future__ import annotations
|
||||
|
||||
|
||||
from quart import (
|
||||
render_template,
|
||||
make_response,
|
||||
Blueprint,
|
||||
g,
|
||||
@@ -11,59 +10,275 @@ 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.
|
||||
|
||||
The templates were written for Ghost Admin API responses, so we mimic
|
||||
that structure (dot-access on dicts via Jinja) from ORM columns.
|
||||
"""
|
||||
d: dict = {}
|
||||
for col in (
|
||||
"id", "slug", "title", "html", "plaintext", "lexical", "mobiledoc",
|
||||
"sx_content",
|
||||
"feature_image", "feature_image_alt", "feature_image_caption",
|
||||
"excerpt", "custom_excerpt", "visibility", "status", "featured",
|
||||
"is_page", "email_only", "canonical_url",
|
||||
"meta_title", "meta_description",
|
||||
"og_image", "og_title", "og_description",
|
||||
"twitter_image", "twitter_title", "twitter_description",
|
||||
"custom_template", "reading_time", "comment_id",
|
||||
):
|
||||
d[col] = getattr(post, col, None)
|
||||
|
||||
# Timestamps as ISO strings (templates do [:16] slicing)
|
||||
for ts in ("published_at", "updated_at", "created_at"):
|
||||
val = getattr(post, ts, None)
|
||||
d[ts] = val.isoformat() if val else ""
|
||||
|
||||
# Tags as list of dicts with .name (for Jinja map(attribute='name'))
|
||||
if hasattr(post, "tags") and post.tags:
|
||||
d["tags"] = [{"name": t.name, "slug": t.slug, "id": t.id} for t in post.tags]
|
||||
else:
|
||||
d["tags"] = []
|
||||
|
||||
# email/newsletter — not available without Ghost, set safe defaults
|
||||
d["email"] = None
|
||||
d["newsletter"] = None
|
||||
|
||||
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.get("/")
|
||||
@require_admin
|
||||
async def admin(slug: str):
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
from sqlalchemy import select
|
||||
from shared.models.page_config import PageConfig
|
||||
|
||||
# Load features for page admin (page_configs now lives in db_blog)
|
||||
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 ""
|
||||
|
||||
ctx = {
|
||||
"features": features,
|
||||
"sumup_configured": sumup_configured,
|
||||
"sumup_merchant_code": sumup_merchant_code,
|
||||
"sumup_checkout_prefix": sumup_checkout_prefix,
|
||||
}
|
||||
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_post_admin_page, render_post_admin_oob
|
||||
|
||||
tctx = await get_template_context()
|
||||
tctx.update(ctx)
|
||||
if not is_htmx_request():
|
||||
html = await render_post_admin_page(tctx)
|
||||
return await make_response(html)
|
||||
else:
|
||||
sx_src = await render_post_admin_oob(tctx)
|
||||
return sx_response(sx_src)
|
||||
|
||||
@bp.put("/features/")
|
||||
@require_admin
|
||||
async def update_features(slug: str):
|
||||
@@ -98,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/")
|
||||
@@ -138,31 +346,9 @@ 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("/data/")
|
||||
@require_admin
|
||||
async def data(slug: str):
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_post_data_page, render_post_data_oob
|
||||
|
||||
data_html = await render_template("_types/post_data/_main_panel.html")
|
||||
tctx = await get_template_context()
|
||||
tctx["data_html"] = data_html
|
||||
if not is_htmx_request():
|
||||
html = await render_post_data_page(tctx)
|
||||
return await make_response(html)
|
||||
else:
|
||||
sx_src = await render_post_data_oob(tctx)
|
||||
return sx_response(sx_src)
|
||||
|
||||
@bp.get("/entries/calendar/<int:calendar_id>/")
|
||||
@require_admin
|
||||
async def calendar_view(slug: str, calendar_id: int):
|
||||
@@ -227,64 +413,15 @@ def register():
|
||||
|
||||
# Get associated entry IDs for this post
|
||||
post_id = g.post_data["post"]["id"]
|
||||
associated_entry_ids = await get_post_entry_ids(g.s, post_id)
|
||||
associated_entry_ids = await get_post_entry_ids(post_id)
|
||||
|
||||
html = await render_template(
|
||||
"_types/post/admin/_calendar_view.html",
|
||||
calendar=calendar_obj,
|
||||
year=year,
|
||||
month=month,
|
||||
month_name=month_name,
|
||||
weekday_names=weekday_names,
|
||||
weeks=weeks,
|
||||
prev_month=prev_month,
|
||||
prev_month_year=prev_month_year,
|
||||
next_month=next_month,
|
||||
next_month_year=next_month_year,
|
||||
prev_year=prev_year,
|
||||
next_year=next_year,
|
||||
month_entries=month_entries,
|
||||
associated_entry_ids=associated_entry_ids,
|
||||
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,
|
||||
g.post_data["post"]["slug"],
|
||||
)
|
||||
return await make_response(html)
|
||||
|
||||
@bp.get("/entries/")
|
||||
@require_admin
|
||||
async def entries(slug: str):
|
||||
from ..services.entry_associations import get_post_entry_ids
|
||||
from shared.models.calendars import Calendar
|
||||
from sqlalchemy import select
|
||||
|
||||
post_id = g.post_data["post"]["id"]
|
||||
associated_entry_ids = await get_post_entry_ids(g.s, post_id)
|
||||
|
||||
# Load ALL calendars (not just this post's calendars)
|
||||
result = await g.s.execute(
|
||||
select(Calendar)
|
||||
.where(Calendar.deleted_at.is_(None))
|
||||
.order_by(Calendar.name.asc())
|
||||
)
|
||||
all_calendars = result.scalars().all()
|
||||
|
||||
# Load entries and post for each calendar
|
||||
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 render_post_entries_page, render_post_entries_oob
|
||||
|
||||
entries_html = await render_template(
|
||||
"_types/post_entries/_main_panel.html",
|
||||
all_calendars=all_calendars,
|
||||
associated_entry_ids=associated_entry_ids,
|
||||
)
|
||||
tctx = await get_template_context()
|
||||
tctx["entries_html"] = entries_html
|
||||
if not is_htmx_request():
|
||||
html = await render_post_entries_page(tctx)
|
||||
return await make_response(html)
|
||||
else:
|
||||
sx_src = await render_post_entries_oob(tctx)
|
||||
return sx_response(sx_src)
|
||||
return sx_response(html)
|
||||
|
||||
@bp.post("/entries/<int:entry_id>/toggle/")
|
||||
@require_admin
|
||||
@@ -295,7 +432,7 @@ def register():
|
||||
from quart import jsonify
|
||||
|
||||
post_id = g.post_data["post"]["id"]
|
||||
is_associated, error = await toggle_entry_association(g.s, post_id, entry_id)
|
||||
is_associated, error = await toggle_entry_association(post_id, entry_id)
|
||||
|
||||
if error:
|
||||
return jsonify({"message": error, "errors": {}}), 400
|
||||
@@ -303,7 +440,7 @@ def register():
|
||||
await g.s.flush()
|
||||
|
||||
# Return updated association status
|
||||
associated_entry_ids = await get_post_entry_ids(g.s, post_id)
|
||||
associated_entry_ids = await get_post_entry_ids(post_id)
|
||||
|
||||
# Load ALL calendars
|
||||
result = await g.s.execute(
|
||||
@@ -318,7 +455,7 @@ def register():
|
||||
await g.s.refresh(calendar, ["entries", "post"])
|
||||
|
||||
# Fetch associated entries for nav display
|
||||
associated_entries = await get_associated_entries(g.s, post_id)
|
||||
associated_entries = await get_associated_entries(post_id)
|
||||
|
||||
# Load calendars for this post (for nav display)
|
||||
calendars = (
|
||||
@@ -330,50 +467,19 @@ 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)
|
||||
|
||||
@bp.get("/settings/")
|
||||
@require_post_author
|
||||
async def settings(slug: str):
|
||||
from ...blog.ghost.ghost_posts import get_post_for_edit
|
||||
|
||||
ghost_id = g.post_data["post"]["ghost_id"]
|
||||
is_page = bool(g.post_data["post"].get("is_page"))
|
||||
ghost_post = await get_post_for_edit(ghost_id, is_page=is_page)
|
||||
save_success = request.args.get("saved") == "1"
|
||||
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_post_settings_page, render_post_settings_oob
|
||||
|
||||
settings_html = await render_template(
|
||||
"_types/post_settings/_main_panel.html",
|
||||
ghost_post=ghost_post,
|
||||
save_success=save_success,
|
||||
)
|
||||
tctx = await get_template_context()
|
||||
tctx["settings_html"] = settings_html
|
||||
if not is_htmx_request():
|
||||
html = await render_post_settings_page(tctx)
|
||||
return await make_response(html)
|
||||
else:
|
||||
sx_src = await render_post_settings_oob(tctx)
|
||||
return sx_response(sx_src)
|
||||
|
||||
@bp.post("/settings/")
|
||||
@require_post_author
|
||||
async def settings_save(slug: str):
|
||||
from ...blog.ghost.ghost_posts import update_post_settings
|
||||
from ...blog.ghost.ghost_sync import sync_single_post, sync_single_page
|
||||
from services.post_writer import update_post_settings, OptimisticLockError
|
||||
from shared.browser.app.redis_cacher import invalidate_tag_cache
|
||||
|
||||
ghost_id = g.post_data["post"]["ghost_id"]
|
||||
is_page = bool(g.post_data["post"].get("is_page"))
|
||||
post_id = g.post_data["post"]["id"]
|
||||
form = await request.form
|
||||
|
||||
updated_at = form.get("updated_at", "")
|
||||
@@ -406,89 +512,48 @@ def register():
|
||||
kwargs["featured"] = form.get("featured") == "on"
|
||||
kwargs["email_only"] = form.get("email_only") == "on"
|
||||
|
||||
# Tags — comma-separated string → list of {"name": "..."} dicts
|
||||
# Tags — comma-separated string → list of names
|
||||
tags_str = form.get("tags", "").strip()
|
||||
if tags_str:
|
||||
kwargs["tags"] = [{"name": t.strip()} for t in tags_str.split(",") if t.strip()]
|
||||
else:
|
||||
kwargs["tags"] = []
|
||||
tag_names = [t.strip() for t in tags_str.split(",") if t.strip()] if tags_str else []
|
||||
|
||||
# Update in Ghost
|
||||
await update_post_settings(
|
||||
ghost_id=ghost_id,
|
||||
updated_at=updated_at,
|
||||
is_page=is_page,
|
||||
**kwargs,
|
||||
)
|
||||
try:
|
||||
post = await update_post_settings(
|
||||
g.s,
|
||||
post_id=post_id,
|
||||
expected_updated_at=updated_at,
|
||||
tag_names=tag_names,
|
||||
**kwargs,
|
||||
)
|
||||
except OptimisticLockError:
|
||||
from urllib.parse import quote
|
||||
return redirect(
|
||||
host_url(url_for("defpage_post_settings", slug=slug))
|
||||
+ "?error=" + quote("Someone else edited this post. Please reload and try again.")
|
||||
)
|
||||
|
||||
# Sync to local DB
|
||||
if is_page:
|
||||
await sync_single_page(g.s, ghost_id)
|
||||
else:
|
||||
await sync_single_post(g.s, ghost_id)
|
||||
await g.s.flush()
|
||||
|
||||
# Clear caches
|
||||
await invalidate_tag_cache("blog")
|
||||
await invalidate_tag_cache("post.post_detail")
|
||||
|
||||
return redirect(host_url(url_for("blog.post.admin.settings", slug=slug)) + "?saved=1")
|
||||
|
||||
@bp.get("/edit/")
|
||||
@require_post_author
|
||||
async def edit(slug: str):
|
||||
from ...blog.ghost.ghost_posts import get_post_for_edit
|
||||
from shared.infrastructure.data_client import fetch_data
|
||||
|
||||
ghost_id = g.post_data["post"]["ghost_id"]
|
||||
is_page = bool(g.post_data["post"].get("is_page"))
|
||||
ghost_post = await get_post_for_edit(ghost_id, is_page=is_page)
|
||||
save_success = request.args.get("saved") == "1"
|
||||
save_error = request.args.get("error", "")
|
||||
|
||||
# Newsletters live in db_account — fetch via HTTP
|
||||
raw_newsletters = await fetch_data("account", "newsletters", required=False) or []
|
||||
# Convert dicts to objects with .name/.ghost_id attributes for template compat
|
||||
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 render_post_edit_page, render_post_edit_oob
|
||||
|
||||
edit_html = await render_template(
|
||||
"_types/post_edit/_main_panel.html",
|
||||
ghost_post=ghost_post,
|
||||
save_success=save_success,
|
||||
save_error=save_error,
|
||||
newsletters=newsletters,
|
||||
)
|
||||
tctx = await get_template_context()
|
||||
tctx["edit_html"] = edit_html
|
||||
if not is_htmx_request():
|
||||
html = await render_post_edit_page(tctx)
|
||||
return await make_response(html)
|
||||
else:
|
||||
sx_src = await render_post_edit_oob(tctx)
|
||||
return sx_response(sx_src)
|
||||
# Redirect using the (possibly new) slug
|
||||
return redirect(host_url(url_for("defpage_post_settings", slug=post.slug)) + "?saved=1")
|
||||
|
||||
@bp.post("/edit/")
|
||||
@require_post_author
|
||||
async def edit_save(slug: str):
|
||||
import json
|
||||
from ...blog.ghost.ghost_posts import update_post
|
||||
from ...blog.ghost.lexical_validator import validate_lexical
|
||||
from ...blog.ghost.ghost_sync import sync_single_post, sync_single_page
|
||||
from services.post_writer import update_post as writer_update, OptimisticLockError
|
||||
from shared.browser.app.redis_cacher import invalidate_tag_cache
|
||||
|
||||
ghost_id = g.post_data["post"]["ghost_id"]
|
||||
is_page = bool(g.post_data["post"].get("is_page"))
|
||||
post_id = g.post_data["post"]["id"]
|
||||
form = await request.form
|
||||
title = form.get("title", "").strip()
|
||||
lexical_raw = form.get("lexical", "")
|
||||
updated_at = form.get("updated_at", "")
|
||||
status = form.get("status", "draft")
|
||||
publish_mode = form.get("publish_mode", "web")
|
||||
newsletter_slug = form.get("newsletter_slug", "").strip() or None
|
||||
feature_image = form.get("feature_image", "").strip()
|
||||
custom_excerpt = form.get("custom_excerpt", "").strip()
|
||||
feature_image_caption = form.get("feature_image_caption", "").strip()
|
||||
@@ -498,82 +563,63 @@ def register():
|
||||
try:
|
||||
lexical_doc = json.loads(lexical_raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return redirect(host_url(url_for("blog.post.admin.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.edit", slug=slug)) + "?error=" + quote(reason))
|
||||
|
||||
# Update in Ghost (content save — no status change yet)
|
||||
ghost_post = await update_post(
|
||||
ghost_id=ghost_id,
|
||||
lexical_json=lexical_raw,
|
||||
title=title or None,
|
||||
updated_at=updated_at,
|
||||
feature_image=feature_image,
|
||||
custom_excerpt=custom_excerpt,
|
||||
feature_image_caption=feature_image_caption,
|
||||
is_page=is_page,
|
||||
)
|
||||
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"))
|
||||
publish_requested_msg = None
|
||||
|
||||
# Guard: if already emailed, force publish_mode to "web" to prevent re-send
|
||||
already_emailed = bool(ghost_post.get("email") and ghost_post["email"].get("status"))
|
||||
if already_emailed and publish_mode in ("email", "both"):
|
||||
publish_mode = "web"
|
||||
# Determine effective status
|
||||
effective_status: str | None = None
|
||||
current_status = g.post_data["post"].get("status", "draft")
|
||||
|
||||
if status == "published" and ghost_post.get("status") != "published" and not is_admin:
|
||||
# Non-admin requesting publish: don't send status to Ghost, set local flag
|
||||
if status == "published" and current_status != "published" and not is_admin:
|
||||
# Non-admin requesting publish: keep as draft, set local flag
|
||||
publish_requested_msg = "Publish requested — an admin will review."
|
||||
elif status and status != ghost_post.get("status"):
|
||||
# Status is changing — determine email params based on publish_mode
|
||||
email_kwargs: dict = {}
|
||||
if status == "published" and publish_mode in ("email", "both") and newsletter_slug:
|
||||
email_kwargs["newsletter_slug"] = newsletter_slug
|
||||
email_kwargs["email_segment"] = "all"
|
||||
if publish_mode == "email":
|
||||
email_kwargs["email_only"] = True
|
||||
elif status and status != current_status:
|
||||
effective_status = status
|
||||
|
||||
from ...blog.ghost.ghost_posts import update_post as _up
|
||||
ghost_post = await _up(
|
||||
ghost_id=ghost_id,
|
||||
sx_content_raw = form.get("sx_content", "").strip() or None
|
||||
# Build optional kwargs — only pass sx_content if the form field was present
|
||||
extra_kw: dict = {}
|
||||
if "sx_content" in form:
|
||||
extra_kw["sx_content"] = sx_content_raw
|
||||
try:
|
||||
post = await writer_update(
|
||||
g.s,
|
||||
post_id=post_id,
|
||||
lexical_json=lexical_raw,
|
||||
title=None,
|
||||
updated_at=ghost_post["updated_at"],
|
||||
status=status,
|
||||
is_page=is_page,
|
||||
**email_kwargs,
|
||||
title=title or None,
|
||||
expected_updated_at=updated_at,
|
||||
feature_image=feature_image or None,
|
||||
custom_excerpt=custom_excerpt or None,
|
||||
feature_image_caption=feature_image_caption or None,
|
||||
status=effective_status,
|
||||
**extra_kw,
|
||||
)
|
||||
except OptimisticLockError:
|
||||
return redirect(
|
||||
host_url(url_for("defpage_post_edit", slug=slug))
|
||||
+ "?error=" + quote("Someone else edited this post. Please reload and try again.")
|
||||
)
|
||||
|
||||
# Sync to local DB
|
||||
if is_page:
|
||||
await sync_single_page(g.s, ghost_id)
|
||||
else:
|
||||
await sync_single_post(g.s, ghost_id)
|
||||
# Handle publish_requested flag
|
||||
if publish_requested_msg:
|
||||
post.publish_requested = True
|
||||
elif status == "published" and is_admin:
|
||||
post.publish_requested = False
|
||||
await g.s.flush()
|
||||
|
||||
# Handle publish_requested flag on the local post
|
||||
from models.ghost_content import Post
|
||||
from sqlalchemy import select as sa_select
|
||||
local_post = (await g.s.execute(
|
||||
sa_select(Post).where(Post.ghost_id == ghost_id)
|
||||
)).scalar_one_or_none()
|
||||
if local_post:
|
||||
if publish_requested_msg:
|
||||
local_post.publish_requested = True
|
||||
elif status == "published" and is_admin:
|
||||
local_post.publish_requested = False
|
||||
await g.s.flush()
|
||||
|
||||
# Clear caches
|
||||
await invalidate_tag_cache("blog")
|
||||
await invalidate_tag_cache("post.post_detail")
|
||||
|
||||
# Redirect to GET to avoid resubmit warning on refresh (PRG pattern)
|
||||
redirect_url = host_url(url_for("blog.post.admin.edit", slug=slug)) + "?saved=1"
|
||||
# Redirect to GET (PRG pattern) — use post.slug in case it changed
|
||||
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)
|
||||
@@ -598,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
|
||||
@@ -624,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
|
||||
@@ -644,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):
|
||||
|
||||
@@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.contracts.dtos import MarketPlaceDTO
|
||||
from shared.infrastructure.actions import call_action, ActionError
|
||||
from shared.services.registry import services
|
||||
from services import blog_service
|
||||
|
||||
|
||||
class MarketError(ValueError):
|
||||
@@ -33,7 +33,7 @@ async def create_market(sess: AsyncSession, post_id: int, name: str) -> MarketPl
|
||||
raise MarketError("Market name must not be empty.")
|
||||
slug = slugify(name)
|
||||
|
||||
post = await services.blog.get_post_by_id(sess, post_id)
|
||||
post = await blog_service.get_post_by_id(sess, post_id)
|
||||
if not post:
|
||||
raise MarketError(f"Post {post_id} does not exist.")
|
||||
|
||||
@@ -57,7 +57,7 @@ async def create_market(sess: AsyncSession, post_id: int, name: str) -> MarketPl
|
||||
|
||||
|
||||
async def soft_delete_market(sess: AsyncSession, post_slug: str, market_slug: str) -> bool:
|
||||
post = await services.blog.get_post_by_slug(sess, post_slug)
|
||||
post = await blog_service.get_post_by_slug(sess, post_slug)
|
||||
if not post:
|
||||
return False
|
||||
|
||||
|
||||
@@ -1,57 +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.get("/")
|
||||
@require_login
|
||||
async def list_snippets():
|
||||
"""List snippets visible to the current user."""
|
||||
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 render_snippets_page, render_snippets_oob
|
||||
|
||||
tctx = await get_template_context()
|
||||
tctx["snippets"] = snippets
|
||||
tctx["is_admin"] = is_admin
|
||||
if not is_htmx_request():
|
||||
html = await render_snippets_page(tctx)
|
||||
return await make_response(html)
|
||||
else:
|
||||
sx_src = await render_snippets_oob(tctx)
|
||||
return sx_response(sx_src)
|
||||
|
||||
@bp.delete("/<int:snippet_id>/")
|
||||
@require_login
|
||||
async def delete_snippet(snippet_id: int):
|
||||
@@ -67,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
|
||||
@@ -91,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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from .ghost_content import Post, Author, Tag, PostAuthor, PostTag
|
||||
from .content import Post, Author, Tag, PostAuthor, PostTag, PostUser
|
||||
from .snippet import Snippet
|
||||
from .tag_group import TagGroup, TagGroupTag
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ class Tag(Base):
|
||||
__tablename__ = "tags"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
ghost_id: Mapped[str] = mapped_column(String(64), index=True, unique=True, nullable=False)
|
||||
ghost_id: Mapped[Optional[str]] = mapped_column(String(64), index=True, unique=True, nullable=True)
|
||||
|
||||
slug: Mapped[str] = mapped_column(String(191), index=True, nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
@@ -50,8 +50,8 @@ class Post(Base):
|
||||
__tablename__ = "posts"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
ghost_id: Mapped[str] = mapped_column(String(64), index=True, unique=True, nullable=False)
|
||||
uuid: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
ghost_id: Mapped[Optional[str]] = mapped_column(String(64), index=True, unique=True, nullable=True)
|
||||
uuid: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, server_default=func.gen_random_uuid())
|
||||
slug: Mapped[str] = mapped_column(String(191), index=True, nullable=False)
|
||||
|
||||
title: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
@@ -60,6 +60,7 @@ class Post(Base):
|
||||
plaintext: Mapped[Optional[str]] = mapped_column(Text())
|
||||
mobiledoc: Mapped[Optional[str]] = mapped_column(Text())
|
||||
lexical: Mapped[Optional[str]] = mapped_column(Text())
|
||||
sx_content: Mapped[Optional[str]] = mapped_column(Text())
|
||||
|
||||
feature_image: Mapped[Optional[str]] = mapped_column(Text())
|
||||
feature_image_alt: Mapped[Optional[str]] = mapped_column(Text())
|
||||
@@ -89,8 +90,8 @@ class Post(Base):
|
||||
comment_id: Mapped[Optional[str]] = mapped_column(String(191))
|
||||
|
||||
published_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
user_id: Mapped[Optional[int]] = mapped_column(
|
||||
@@ -136,7 +137,7 @@ class Author(Base):
|
||||
__tablename__ = "authors"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
ghost_id: Mapped[str] = mapped_column(String(64), index=True, unique=True, nullable=False)
|
||||
ghost_id: Mapped[Optional[str]] = mapped_column(String(64), index=True, unique=True, nullable=True)
|
||||
|
||||
slug: Mapped[str] = mapped_column(String(191), index=True, nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
@@ -192,3 +193,15 @@ class PostTag(Base):
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
|
||||
|
||||
class PostUser(Base):
|
||||
"""Multi-author M2M: links posts to users (cross-DB, no FK on user_id)."""
|
||||
__tablename__ = "post_users"
|
||||
|
||||
post_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("posts.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, primary_key=True, index=True,
|
||||
)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
@@ -1,3 +1,3 @@
|
||||
from shared.models.ghost_content import ( # noqa: F401
|
||||
Tag, Post, Author, PostAuthor, PostTag,
|
||||
from .content import ( # noqa: F401
|
||||
Tag, Post, Author, PostAuthor, PostTag, PostUser,
|
||||
)
|
||||
|
||||
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) ",")))))
|
||||
68
blog/scripts/backfill_sx_content.py
Normal file
68
blog/scripts/backfill_sx_content.py
Normal file
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Backfill sx_content from lexical JSON for all posts that have lexical but no sx_content.
|
||||
|
||||
Usage:
|
||||
python -m blog.scripts.backfill_sx_content [--dry-run]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
async def backfill(dry_run: bool = False) -> int:
|
||||
from shared.db.session import get_session
|
||||
from models.ghost_content import Post
|
||||
from bp.blog.ghost.lexical_to_sx import lexical_to_sx
|
||||
|
||||
converted = 0
|
||||
errors = 0
|
||||
|
||||
async with get_session() as sess:
|
||||
stmt = select(Post).where(
|
||||
and_(
|
||||
Post.lexical.isnot(None),
|
||||
Post.lexical != "",
|
||||
(Post.sx_content.is_(None)) | (Post.sx_content == ""),
|
||||
)
|
||||
)
|
||||
result = await sess.execute(stmt)
|
||||
posts = result.scalars().all()
|
||||
|
||||
print(f"Found {len(posts)} posts to convert")
|
||||
|
||||
for post in posts:
|
||||
try:
|
||||
sx = lexical_to_sx(post.lexical)
|
||||
if dry_run:
|
||||
print(f" [DRY RUN] {post.slug}: {len(sx)} chars")
|
||||
else:
|
||||
post.sx_content = sx
|
||||
print(f" Converted: {post.slug} ({len(sx)} chars)")
|
||||
converted += 1
|
||||
except Exception as e:
|
||||
print(f" ERROR: {post.slug}: {e}", file=sys.stderr)
|
||||
errors += 1
|
||||
|
||||
if not dry_run:
|
||||
await sess.commit()
|
||||
|
||||
print(f"\nDone: {converted} converted, {errors} errors")
|
||||
return converted
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Backfill sx_content from lexical JSON")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Don't write to database")
|
||||
args = parser.parse_args()
|
||||
|
||||
asyncio.run(backfill(dry_run=args.dry_run))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
469
blog/scripts/final_ghost_sync.py
Normal file
469
blog/scripts/final_ghost_sync.py
Normal file
@@ -0,0 +1,469 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Final Ghost → db_blog sync with HTML verification + author→user migration.
|
||||
|
||||
Run once before cutting over to native writes (Phase 1).
|
||||
|
||||
Usage:
|
||||
cd blog && python -m scripts.final_ghost_sync
|
||||
|
||||
Requires GHOST_ADMIN_API_URL, GHOST_ADMIN_API_KEY, DATABASE_URL,
|
||||
and DATABASE_URL_ACCOUNT env vars.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import difflib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select, func, delete
|
||||
|
||||
# Ensure project root is importable
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "shared"))
|
||||
|
||||
from shared.db.base import Base # noqa: E402
|
||||
from shared.db.session import get_session, get_account_session, _engine # noqa: E402
|
||||
from shared.infrastructure.ghost_admin_token import make_ghost_admin_jwt # noqa: E402
|
||||
from blog.models.content import Post, Author, Tag, PostUser, PostAuthor # noqa: E402
|
||||
from shared.models.user import User # noqa: E402
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
)
|
||||
log = logging.getLogger("final_ghost_sync")
|
||||
|
||||
GHOST_ADMIN_API_URL = os.environ["GHOST_ADMIN_API_URL"]
|
||||
|
||||
|
||||
def _auth_header() -> dict[str, str]:
|
||||
return {"Authorization": f"Ghost {make_ghost_admin_jwt()}"}
|
||||
|
||||
|
||||
def _slugify(name: str) -> str:
|
||||
s = name.strip().lower()
|
||||
s = re.sub(r"[^\w\s-]", "", s)
|
||||
s = re.sub(r"[\s_]+", "-", s)
|
||||
return s.strip("-")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ghost API fetch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _fetch_all(endpoint: str) -> list[dict]:
|
||||
"""Fetch all items from a Ghost Admin API endpoint."""
|
||||
url = (
|
||||
f"{GHOST_ADMIN_API_URL}/{endpoint}/"
|
||||
"?include=authors,tags&limit=all"
|
||||
"&formats=html,plaintext,mobiledoc,lexical"
|
||||
)
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
resp = await client.get(url, headers=_auth_header())
|
||||
resp.raise_for_status()
|
||||
key = endpoint # "posts" or "pages"
|
||||
return resp.json().get(key, [])
|
||||
|
||||
|
||||
async def fetch_all_ghost_content() -> list[dict]:
|
||||
"""Fetch all posts + pages from Ghost."""
|
||||
posts, pages = await asyncio.gather(
|
||||
_fetch_all("posts"),
|
||||
_fetch_all("pages"),
|
||||
)
|
||||
for p in pages:
|
||||
p["page"] = True
|
||||
return posts + pages
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Author → User migration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def migrate_authors_to_users(author_bucket: dict[str, dict]) -> dict[str, int]:
|
||||
"""Ensure every Ghost author has a corresponding User in db_account.
|
||||
|
||||
Returns mapping of ghost_author_id → user_id.
|
||||
"""
|
||||
ghost_id_to_user_id: dict[str, int] = {}
|
||||
|
||||
async with get_account_session() as sess:
|
||||
async with sess.begin():
|
||||
for ghost_author_id, ga in author_bucket.items():
|
||||
email = (ga.get("email") or "").strip().lower()
|
||||
if not email:
|
||||
log.warning(
|
||||
"Author %s (%s) has no email — skipping user creation",
|
||||
ghost_author_id, ga.get("name"),
|
||||
)
|
||||
continue
|
||||
|
||||
# Find existing user by email
|
||||
user = (await sess.execute(
|
||||
select(User).where(User.email == email)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
if user is None:
|
||||
# Auto-create user for this Ghost author
|
||||
user = User(
|
||||
email=email,
|
||||
name=ga.get("name"),
|
||||
slug=ga.get("slug") or _slugify(ga.get("name") or email.split("@")[0]),
|
||||
bio=ga.get("bio"),
|
||||
profile_image=ga.get("profile_image"),
|
||||
cover_image=ga.get("cover_image"),
|
||||
website=ga.get("website"),
|
||||
location=ga.get("location"),
|
||||
facebook=ga.get("facebook"),
|
||||
twitter=ga.get("twitter"),
|
||||
is_admin=True, # Ghost authors are admins
|
||||
)
|
||||
sess.add(user)
|
||||
await sess.flush()
|
||||
log.info("Created user %d for author %s (%s)", user.id, ga.get("name"), email)
|
||||
else:
|
||||
# Update profile fields from Ghost author (fill in blanks)
|
||||
if not user.slug:
|
||||
user.slug = ga.get("slug") or _slugify(ga.get("name") or email.split("@")[0])
|
||||
if not user.name and ga.get("name"):
|
||||
user.name = ga["name"]
|
||||
if not user.bio and ga.get("bio"):
|
||||
user.bio = ga["bio"]
|
||||
if not user.profile_image and ga.get("profile_image"):
|
||||
user.profile_image = ga["profile_image"]
|
||||
if not user.cover_image and ga.get("cover_image"):
|
||||
user.cover_image = ga["cover_image"]
|
||||
if not user.website and ga.get("website"):
|
||||
user.website = ga["website"]
|
||||
if not user.location and ga.get("location"):
|
||||
user.location = ga["location"]
|
||||
if not user.facebook and ga.get("facebook"):
|
||||
user.facebook = ga["facebook"]
|
||||
if not user.twitter and ga.get("twitter"):
|
||||
user.twitter = ga["twitter"]
|
||||
await sess.flush()
|
||||
log.info("Updated user %d profile from author %s", user.id, ga.get("name"))
|
||||
|
||||
ghost_id_to_user_id[ghost_author_id] = user.id
|
||||
|
||||
return ghost_id_to_user_id
|
||||
|
||||
|
||||
async def populate_post_users(
|
||||
data: list[dict],
|
||||
ghost_author_to_user: dict[str, int],
|
||||
) -> int:
|
||||
"""Populate post_users M2M and set user_id on all posts from author mapping.
|
||||
|
||||
Returns number of post_users rows created.
|
||||
"""
|
||||
rows_created = 0
|
||||
|
||||
async with get_session() as sess:
|
||||
async with sess.begin():
|
||||
for gp in data:
|
||||
ghost_post_id = gp["id"]
|
||||
post = (await sess.execute(
|
||||
select(Post).where(Post.ghost_id == ghost_post_id)
|
||||
)).scalar_one_or_none()
|
||||
if not post:
|
||||
continue
|
||||
|
||||
# Set primary user_id from primary author
|
||||
pa = gp.get("primary_author")
|
||||
if pa and pa["id"] in ghost_author_to_user:
|
||||
post.user_id = ghost_author_to_user[pa["id"]]
|
||||
|
||||
# Build post_users from all authors
|
||||
await sess.execute(
|
||||
delete(PostUser).where(PostUser.post_id == post.id)
|
||||
)
|
||||
seen_user_ids: set[int] = set()
|
||||
for idx, a in enumerate(gp.get("authors") or []):
|
||||
uid = ghost_author_to_user.get(a["id"])
|
||||
if uid and uid not in seen_user_ids:
|
||||
seen_user_ids.add(uid)
|
||||
sess.add(PostUser(post_id=post.id, user_id=uid, sort_order=idx))
|
||||
rows_created += 1
|
||||
|
||||
await sess.flush()
|
||||
|
||||
return rows_created
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Content sync (reuse ghost_sync upsert logic)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def run_sync() -> dict:
|
||||
"""Run full Ghost content sync and author→user migration."""
|
||||
from bp.blog.ghost.ghost_sync import (
|
||||
_upsert_author,
|
||||
_upsert_tag,
|
||||
_upsert_post,
|
||||
)
|
||||
|
||||
log.info("Fetching all content from Ghost...")
|
||||
data = await fetch_all_ghost_content()
|
||||
log.info("Received %d posts/pages from Ghost", len(data))
|
||||
|
||||
# Collect authors and tags
|
||||
author_bucket: dict[str, dict] = {}
|
||||
tag_bucket: dict[str, dict] = {}
|
||||
|
||||
for p in data:
|
||||
for a in p.get("authors") or []:
|
||||
author_bucket[a["id"]] = a
|
||||
if p.get("primary_author"):
|
||||
author_bucket[p["primary_author"]["id"]] = p["primary_author"]
|
||||
for t in p.get("tags") or []:
|
||||
tag_bucket[t["id"]] = t
|
||||
if p.get("primary_tag"):
|
||||
tag_bucket[p["primary_tag"]["id"]] = p["primary_tag"]
|
||||
|
||||
# Step 1: Upsert content into db_blog (existing Ghost sync logic)
|
||||
async with get_session() as sess:
|
||||
async with sess.begin():
|
||||
author_map: dict[str, Author] = {}
|
||||
for ga in author_bucket.values():
|
||||
a = await _upsert_author(sess, ga)
|
||||
author_map[ga["id"]] = a
|
||||
log.info("Upserted %d authors (legacy table)", len(author_map))
|
||||
|
||||
tag_map: dict[str, Tag] = {}
|
||||
for gt in tag_bucket.values():
|
||||
t = await _upsert_tag(sess, gt)
|
||||
tag_map[gt["id"]] = t
|
||||
log.info("Upserted %d tags", len(tag_map))
|
||||
|
||||
for gp in data:
|
||||
await _upsert_post(sess, gp, author_map, tag_map)
|
||||
log.info("Upserted %d posts/pages", len(data))
|
||||
|
||||
# Step 2: Migrate authors → users in db_account
|
||||
log.info("")
|
||||
log.info("--- Migrating Ghost authors → Users ---")
|
||||
ghost_author_to_user = await migrate_authors_to_users(author_bucket)
|
||||
log.info("Mapped %d Ghost authors to User records", len(ghost_author_to_user))
|
||||
|
||||
# Step 3: Populate post_users M2M and set user_id
|
||||
log.info("")
|
||||
log.info("--- Populating post_users M2M ---")
|
||||
pu_rows = await populate_post_users(data, ghost_author_to_user)
|
||||
log.info("Created %d post_users rows", pu_rows)
|
||||
|
||||
n_posts = sum(1 for p in data if not p.get("page"))
|
||||
n_pages = sum(1 for p in data if p.get("page"))
|
||||
|
||||
return {
|
||||
"posts": n_posts,
|
||||
"pages": n_pages,
|
||||
"authors": len(author_bucket),
|
||||
"tags": len(tag_bucket),
|
||||
"users_mapped": len(ghost_author_to_user),
|
||||
"post_users_rows": pu_rows,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTML rendering verification
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _normalize_html(html: str | None) -> str:
|
||||
if not html:
|
||||
return ""
|
||||
return re.sub(r"\s+", " ", html.strip())
|
||||
|
||||
|
||||
async def verify_html_rendering() -> dict:
|
||||
"""Re-render all posts from lexical and compare with stored HTML."""
|
||||
from bp.blog.ghost.lexical_renderer import render_lexical
|
||||
import json
|
||||
|
||||
diffs_found = 0
|
||||
posts_checked = 0
|
||||
posts_no_lexical = 0
|
||||
|
||||
async with get_session() as sess:
|
||||
result = await sess.execute(
|
||||
select(Post).where(
|
||||
Post.deleted_at.is_(None),
|
||||
Post.status == "published",
|
||||
)
|
||||
)
|
||||
posts = result.scalars().all()
|
||||
|
||||
for post in posts:
|
||||
if not post.lexical:
|
||||
posts_no_lexical += 1
|
||||
continue
|
||||
|
||||
posts_checked += 1
|
||||
try:
|
||||
rendered = render_lexical(json.loads(post.lexical))
|
||||
except Exception as e:
|
||||
log.error(
|
||||
"Render failed for post %d (%s): %s",
|
||||
post.id, post.slug, e,
|
||||
)
|
||||
diffs_found += 1
|
||||
continue
|
||||
|
||||
ghost_html = _normalize_html(post.html)
|
||||
our_html = _normalize_html(rendered)
|
||||
|
||||
if ghost_html != our_html:
|
||||
diffs_found += 1
|
||||
diff = difflib.unified_diff(
|
||||
ghost_html.splitlines(keepends=True),
|
||||
our_html.splitlines(keepends=True),
|
||||
fromfile=f"ghost/{post.slug}",
|
||||
tofile=f"rendered/{post.slug}",
|
||||
n=2,
|
||||
)
|
||||
diff_text = "".join(diff)
|
||||
if len(diff_text) > 2000:
|
||||
diff_text = diff_text[:2000] + "\n... (truncated)"
|
||||
log.warning(
|
||||
"HTML diff for post %d (%s):\n%s",
|
||||
post.id, post.slug, diff_text,
|
||||
)
|
||||
|
||||
return {
|
||||
"checked": posts_checked,
|
||||
"no_lexical": posts_no_lexical,
|
||||
"diffs": diffs_found,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Verification queries
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def run_verification() -> dict:
|
||||
async with get_session() as sess:
|
||||
total_posts = await sess.scalar(
|
||||
select(func.count(Post.id)).where(Post.deleted_at.is_(None))
|
||||
)
|
||||
null_html = await sess.scalar(
|
||||
select(func.count(Post.id)).where(
|
||||
Post.deleted_at.is_(None),
|
||||
Post.status == "published",
|
||||
Post.html.is_(None),
|
||||
)
|
||||
)
|
||||
null_lexical = await sess.scalar(
|
||||
select(func.count(Post.id)).where(
|
||||
Post.deleted_at.is_(None),
|
||||
Post.status == "published",
|
||||
Post.lexical.is_(None),
|
||||
)
|
||||
)
|
||||
# Posts with user_id set
|
||||
has_user = await sess.scalar(
|
||||
select(func.count(Post.id)).where(
|
||||
Post.deleted_at.is_(None),
|
||||
Post.user_id.isnot(None),
|
||||
)
|
||||
)
|
||||
no_user = await sess.scalar(
|
||||
select(func.count(Post.id)).where(
|
||||
Post.deleted_at.is_(None),
|
||||
Post.user_id.is_(None),
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"total_posts": total_posts,
|
||||
"published_null_html": null_html,
|
||||
"published_null_lexical": null_lexical,
|
||||
"posts_with_user": has_user,
|
||||
"posts_without_user": no_user,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def main():
|
||||
log.info("=" * 60)
|
||||
log.info("Final Ghost Sync — Cutover Preparation")
|
||||
log.info("=" * 60)
|
||||
|
||||
# Step 1: Full sync + author→user migration
|
||||
log.info("")
|
||||
log.info("--- Step 1: Full sync from Ghost + author→user migration ---")
|
||||
stats = await run_sync()
|
||||
log.info(
|
||||
"Sync complete: %d posts, %d pages, %d authors, %d tags, %d users mapped",
|
||||
stats["posts"], stats["pages"], stats["authors"], stats["tags"],
|
||||
stats["users_mapped"],
|
||||
)
|
||||
|
||||
# Step 2: Verification queries
|
||||
log.info("")
|
||||
log.info("--- Step 2: Verification queries ---")
|
||||
vq = await run_verification()
|
||||
log.info("Total non-deleted posts/pages: %d", vq["total_posts"])
|
||||
log.info("Published with NULL html: %d", vq["published_null_html"])
|
||||
log.info("Published with NULL lexical: %d", vq["published_null_lexical"])
|
||||
log.info("Posts with user_id: %d", vq["posts_with_user"])
|
||||
log.info("Posts WITHOUT user_id: %d", vq["posts_without_user"])
|
||||
|
||||
if vq["published_null_html"] > 0:
|
||||
log.warning("WARN: Some published posts have no HTML!")
|
||||
if vq["published_null_lexical"] > 0:
|
||||
log.warning("WARN: Some published posts have no Lexical JSON!")
|
||||
if vq["posts_without_user"] > 0:
|
||||
log.warning("WARN: Some posts have no user_id — authors may lack email!")
|
||||
|
||||
# Step 3: HTML rendering verification
|
||||
log.info("")
|
||||
log.info("--- Step 3: HTML rendering verification ---")
|
||||
html_stats = await verify_html_rendering()
|
||||
log.info(
|
||||
"Checked %d posts, %d with diffs, %d without lexical",
|
||||
html_stats["checked"], html_stats["diffs"], html_stats["no_lexical"],
|
||||
)
|
||||
|
||||
if html_stats["diffs"] > 0:
|
||||
log.warning(
|
||||
"WARN: %d posts have HTML rendering differences — "
|
||||
"review diffs above before cutover",
|
||||
html_stats["diffs"],
|
||||
)
|
||||
|
||||
# Summary
|
||||
log.info("")
|
||||
log.info("=" * 60)
|
||||
log.info("SUMMARY")
|
||||
log.info(" Posts synced: %d", stats["posts"])
|
||||
log.info(" Pages synced: %d", stats["pages"])
|
||||
log.info(" Authors synced: %d", stats["authors"])
|
||||
log.info(" Tags synced: %d", stats["tags"])
|
||||
log.info(" Users mapped: %d", stats["users_mapped"])
|
||||
log.info(" post_users rows: %d", stats["post_users_rows"])
|
||||
log.info(" HTML diffs: %d", html_stats["diffs"])
|
||||
log.info(" Published null HTML: %d", vq["published_null_html"])
|
||||
log.info(" Published null lex: %d", vq["published_null_lexical"])
|
||||
log.info(" Posts without user: %d", vq["posts_without_user"])
|
||||
log.info("=" * 60)
|
||||
|
||||
if (html_stats["diffs"] == 0
|
||||
and vq["published_null_html"] == 0
|
||||
and vq["posts_without_user"] == 0):
|
||||
log.info("All checks passed — safe to proceed with cutover.")
|
||||
else:
|
||||
log.warning("Review warnings above before proceeding.")
|
||||
|
||||
await _engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
89
blog/scripts/migrate_sx_html.py
Normal file
89
blog/scripts/migrate_sx_html.py
Normal file
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
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_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.
|
||||
|
||||
Usage:
|
||||
cd blog && python3 scripts/migrate_sx_html.py [--dry-run]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
from sqlalchemy import select, and_
|
||||
|
||||
|
||||
async def migrate(dry_run: bool = False) -> int:
|
||||
from shared.db.session import get_session
|
||||
from models.ghost_content import Post
|
||||
from bp.blog.ghost.lexical_to_sx import lexical_to_sx
|
||||
|
||||
converted = 0
|
||||
skipped = 0
|
||||
errors = 0
|
||||
|
||||
async with get_session() as sess:
|
||||
# All posts with lexical content (whether or not sx_content exists)
|
||||
stmt = select(Post).where(
|
||||
and_(
|
||||
Post.lexical.isnot(None),
|
||||
Post.lexical != "",
|
||||
)
|
||||
)
|
||||
result = await sess.execute(stmt)
|
||||
posts = result.scalars().all()
|
||||
|
||||
print(f"Found {len(posts)} posts with lexical content")
|
||||
|
||||
for post in posts:
|
||||
try:
|
||||
new_sx = lexical_to_sx(post.lexical)
|
||||
if post.sx_content == new_sx:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
if dry_run:
|
||||
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_cards/kg-html")
|
||||
if old_has_raw:
|
||||
markers.append("raw-caption")
|
||||
tag = f" [{', '.join(markers)}]" if markers else ""
|
||||
print(f" [DRY RUN] {post.slug}: {len(new_sx)} chars{tag}")
|
||||
else:
|
||||
post.sx_content = new_sx
|
||||
print(f" Converted: {post.slug} ({len(new_sx)} chars)")
|
||||
converted += 1
|
||||
except Exception as e:
|
||||
print(f" ERROR: {post.slug}: {e}", file=sys.stderr)
|
||||
errors += 1
|
||||
|
||||
if not dry_run and converted:
|
||||
await sess.commit()
|
||||
|
||||
print(f"\nDone: {converted} converted, {skipped} unchanged, {errors} errors")
|
||||
return converted
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
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")
|
||||
args = parser.parse_args()
|
||||
|
||||
asyncio.run(migrate(dry_run=args.dry_run))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,6 +1,69 @@
|
||||
"""Blog app service registration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.contracts.dtos import PostDTO
|
||||
|
||||
from models.content import Post
|
||||
|
||||
|
||||
def _post_to_dto(post: Post) -> PostDTO:
|
||||
return PostDTO(
|
||||
id=post.id,
|
||||
slug=post.slug,
|
||||
title=post.title,
|
||||
status=post.status,
|
||||
visibility=post.visibility,
|
||||
is_page=post.is_page,
|
||||
feature_image=post.feature_image,
|
||||
html=post.html,
|
||||
sx_content=post.sx_content,
|
||||
excerpt=post.excerpt,
|
||||
custom_excerpt=post.custom_excerpt,
|
||||
published_at=post.published_at,
|
||||
)
|
||||
|
||||
|
||||
class SqlBlogService:
|
||||
async def get_post_by_slug(self, session: AsyncSession, slug: str) -> PostDTO | None:
|
||||
post = (
|
||||
await session.execute(select(Post).where(Post.slug == slug))
|
||||
).scalar_one_or_none()
|
||||
return _post_to_dto(post) if post else None
|
||||
|
||||
async def get_post_by_id(self, session: AsyncSession, id: int) -> PostDTO | None:
|
||||
post = (
|
||||
await session.execute(select(Post).where(Post.id == id))
|
||||
).scalar_one_or_none()
|
||||
return _post_to_dto(post) if post else None
|
||||
|
||||
async def get_posts_by_ids(self, session: AsyncSession, ids: list[int]) -> list[PostDTO]:
|
||||
if not ids:
|
||||
return []
|
||||
result = await session.execute(select(Post).where(Post.id.in_(ids)))
|
||||
return [_post_to_dto(p) for p in result.scalars().all()]
|
||||
|
||||
async def search_posts(
|
||||
self, session: AsyncSession, query: str, page: int = 1, per_page: int = 10,
|
||||
) -> tuple[list[PostDTO], int]:
|
||||
if query:
|
||||
count_stmt = select(func.count(Post.id)).where(Post.title.ilike(f"%{query}%"))
|
||||
posts_stmt = select(Post).where(Post.title.ilike(f"%{query}%")).order_by(Post.title)
|
||||
else:
|
||||
count_stmt = select(func.count(Post.id))
|
||||
posts_stmt = select(Post).order_by(Post.published_at.desc().nullslast())
|
||||
|
||||
total = (await session.execute(count_stmt)).scalar() or 0
|
||||
offset = (page - 1) * per_page
|
||||
result = await session.execute(posts_stmt.limit(per_page).offset(offset))
|
||||
return [_post_to_dto(p) for p in result.scalars().all()], total
|
||||
|
||||
|
||||
# Module-level singleton — import this in blog code.
|
||||
blog_service = SqlBlogService()
|
||||
|
||||
|
||||
def register_domain_services() -> None:
|
||||
"""Register services for the blog app.
|
||||
@@ -9,11 +72,15 @@ def register_domain_services() -> None:
|
||||
Cross-app calls go over HTTP via call_action() / fetch_data().
|
||||
"""
|
||||
from shared.services.registry import services
|
||||
from shared.services.blog_impl import SqlBlogService
|
||||
services.register("blog", blog_service)
|
||||
|
||||
services.blog = SqlBlogService()
|
||||
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
|
||||
465
blog/services/post_writer.py
Normal file
465
blog/services/post_writer.py
Normal file
@@ -0,0 +1,465 @@
|
||||
"""Native post/page CRUD — replaces Ghost Admin API writes.
|
||||
|
||||
All operations go directly to db_blog. Ghost is never called.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
import nh3
|
||||
from sqlalchemy import select, delete, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.ghost_content import Post, Tag, PostTag, PostUser
|
||||
from shared.browser.app.utils import utcnow
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _slugify(name: str) -> str:
|
||||
s = name.strip().lower()
|
||||
s = re.sub(r"[^\w\s-]", "", s)
|
||||
s = re.sub(r"[\s_]+", "-", s)
|
||||
return s.strip("-")
|
||||
|
||||
|
||||
def _reading_time(plaintext: str | None) -> int:
|
||||
"""Estimate reading time in minutes (word count / 265, min 1)."""
|
||||
if not plaintext:
|
||||
return 0
|
||||
words = len(plaintext.split())
|
||||
return max(1, round(words / 265))
|
||||
|
||||
|
||||
def _extract_plaintext(html: str) -> str:
|
||||
"""Strip HTML tags to get plaintext."""
|
||||
text = re.sub(r"<[^>]+>", "", html)
|
||||
text = re.sub(r"\s+", " ", text).strip()
|
||||
return text
|
||||
|
||||
|
||||
def _sanitize_html(html: str | None) -> str | None:
|
||||
if not html:
|
||||
return html
|
||||
return nh3.clean(
|
||||
html,
|
||||
tags={
|
||||
"a", "abbr", "acronym", "b", "blockquote", "br", "code",
|
||||
"div", "em", "figcaption", "figure", "h1", "h2", "h3",
|
||||
"h4", "h5", "h6", "hr", "i", "img", "li", "ol", "p",
|
||||
"pre", "span", "strong", "sub", "sup", "table", "tbody",
|
||||
"td", "th", "thead", "tr", "ul", "video", "source",
|
||||
"picture", "iframe", "audio",
|
||||
},
|
||||
attributes={
|
||||
"*": {"class", "id", "style"},
|
||||
"a": {"href", "title", "target"},
|
||||
"img": {"src", "alt", "title", "width", "height", "loading"},
|
||||
"video": {"src", "controls", "width", "height", "poster"},
|
||||
"audio": {"src", "controls"},
|
||||
"source": {"src", "type"},
|
||||
"iframe": {"src", "width", "height", "frameborder", "allowfullscreen"},
|
||||
"td": {"colspan", "rowspan"},
|
||||
"th": {"colspan", "rowspan"},
|
||||
},
|
||||
link_rel="noopener noreferrer",
|
||||
url_schemes={"http", "https", "mailto"},
|
||||
)
|
||||
|
||||
|
||||
def _render_and_extract(lexical_json: str) -> tuple[str, str, int]:
|
||||
"""Render HTML from Lexical JSON, extract plaintext, compute reading time.
|
||||
|
||||
Returns (html, plaintext, reading_time).
|
||||
"""
|
||||
from bp.blog.ghost.lexical_renderer import render_lexical
|
||||
|
||||
doc = json.loads(lexical_json) if isinstance(lexical_json, str) else lexical_json
|
||||
html = render_lexical(doc)
|
||||
html = _sanitize_html(html)
|
||||
plaintext = _extract_plaintext(html or "")
|
||||
rt = _reading_time(plaintext)
|
||||
return html, plaintext, rt
|
||||
|
||||
|
||||
async def _ensure_slug_unique(sess: AsyncSession, slug: str, exclude_post_id: int | None = None) -> str:
|
||||
"""Append -2, -3, etc. if slug already taken."""
|
||||
base_slug = slug
|
||||
counter = 1
|
||||
while True:
|
||||
q = select(Post.id).where(Post.slug == slug, Post.deleted_at.is_(None))
|
||||
if exclude_post_id:
|
||||
q = q.where(Post.id != exclude_post_id)
|
||||
existing = await sess.scalar(q)
|
||||
if existing is None:
|
||||
return slug
|
||||
counter += 1
|
||||
slug = f"{base_slug}-{counter}"
|
||||
|
||||
|
||||
async def _upsert_tags_by_name(sess: AsyncSession, tag_names: list[str]) -> list[Tag]:
|
||||
"""Find or create tags by name. Returns Tag objects in order."""
|
||||
tags: list[Tag] = []
|
||||
for name in tag_names:
|
||||
name = name.strip()
|
||||
if not name:
|
||||
continue
|
||||
tag = (await sess.execute(
|
||||
select(Tag).where(Tag.name == name, Tag.deleted_at.is_(None))
|
||||
)).scalar_one_or_none()
|
||||
if tag is None:
|
||||
tag = Tag(
|
||||
name=name,
|
||||
slug=_slugify(name),
|
||||
visibility="public",
|
||||
)
|
||||
sess.add(tag)
|
||||
await sess.flush()
|
||||
tags.append(tag)
|
||||
return tags
|
||||
|
||||
|
||||
async def _rebuild_post_tags(sess: AsyncSession, post_id: int, tags: list[Tag]) -> None:
|
||||
"""Replace all post_tags for a post."""
|
||||
await sess.execute(delete(PostTag).where(PostTag.post_id == post_id))
|
||||
seen: set[int] = set()
|
||||
for idx, tag in enumerate(tags):
|
||||
if tag.id not in seen:
|
||||
seen.add(tag.id)
|
||||
sess.add(PostTag(post_id=post_id, tag_id=tag.id, sort_order=idx))
|
||||
await sess.flush()
|
||||
|
||||
|
||||
async def _rebuild_post_users(sess: AsyncSession, post_id: int, user_ids: list[int]) -> None:
|
||||
"""Replace all post_users for a post."""
|
||||
await sess.execute(delete(PostUser).where(PostUser.post_id == post_id))
|
||||
seen: set[int] = set()
|
||||
for idx, uid in enumerate(user_ids):
|
||||
if uid not in seen:
|
||||
seen.add(uid)
|
||||
sess.add(PostUser(post_id=post_id, user_id=uid, sort_order=idx))
|
||||
await sess.flush()
|
||||
|
||||
|
||||
async def _fire_ap_publish(
|
||||
sess: AsyncSession,
|
||||
post: Post,
|
||||
old_status: str | None,
|
||||
tag_objs: list[Tag],
|
||||
) -> None:
|
||||
"""Fire AP federation activity on status transitions."""
|
||||
if post.is_page or not post.user_id:
|
||||
return
|
||||
|
||||
from bp.blog.ghost.ghost_sync import _build_ap_post_data
|
||||
from shared.services.federation_publish import try_publish
|
||||
from shared.infrastructure.urls import app_url
|
||||
|
||||
post_url = app_url("blog", f"/{post.slug}/")
|
||||
|
||||
if post.status == "published":
|
||||
activity_type = "Create" if old_status != "published" else "Update"
|
||||
await try_publish(
|
||||
sess,
|
||||
user_id=post.user_id,
|
||||
activity_type=activity_type,
|
||||
object_type="Note",
|
||||
object_data=_build_ap_post_data(post, post_url, tag_objs),
|
||||
source_type="Post",
|
||||
source_id=post.id,
|
||||
)
|
||||
elif old_status == "published" and post.status != "published":
|
||||
await try_publish(
|
||||
sess,
|
||||
user_id=post.user_id,
|
||||
activity_type="Delete",
|
||||
object_type="Tombstone",
|
||||
object_data={
|
||||
"id": post_url,
|
||||
"formerType": "Note",
|
||||
},
|
||||
source_type="Post",
|
||||
source_id=post.id,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def create_post(
|
||||
sess: AsyncSession,
|
||||
*,
|
||||
title: str,
|
||||
lexical_json: str,
|
||||
status: str = "draft",
|
||||
user_id: int,
|
||||
feature_image: str | None = None,
|
||||
custom_excerpt: str | None = None,
|
||||
feature_image_caption: str | None = None,
|
||||
tag_names: list[str] | None = None,
|
||||
is_page: bool = False,
|
||||
sx_content: str | None = None,
|
||||
) -> Post:
|
||||
"""Create a new post or page directly in db_blog."""
|
||||
html, plaintext, reading_time = _render_and_extract(lexical_json)
|
||||
slug = await _ensure_slug_unique(sess, _slugify(title or "untitled"))
|
||||
|
||||
now = utcnow()
|
||||
post = Post(
|
||||
title=title or "Untitled",
|
||||
slug=slug,
|
||||
lexical=lexical_json if isinstance(lexical_json, str) else json.dumps(lexical_json),
|
||||
sx_content=sx_content,
|
||||
html=html,
|
||||
plaintext=plaintext,
|
||||
reading_time=reading_time,
|
||||
status=status,
|
||||
is_page=is_page,
|
||||
feature_image=feature_image,
|
||||
feature_image_caption=_sanitize_html(feature_image_caption),
|
||||
custom_excerpt=custom_excerpt,
|
||||
user_id=user_id,
|
||||
visibility="public",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
published_at=now if status == "published" else None,
|
||||
)
|
||||
sess.add(post)
|
||||
await sess.flush()
|
||||
|
||||
# Tags
|
||||
if tag_names:
|
||||
tags = await _upsert_tags_by_name(sess, tag_names)
|
||||
await _rebuild_post_tags(sess, post.id, tags)
|
||||
if tags:
|
||||
post.primary_tag_id = tags[0].id
|
||||
|
||||
# Post users (author)
|
||||
await _rebuild_post_users(sess, post.id, [user_id])
|
||||
|
||||
# PageConfig for pages
|
||||
if is_page:
|
||||
from shared.models.page_config import PageConfig
|
||||
existing = (await sess.execute(
|
||||
select(PageConfig).where(
|
||||
PageConfig.container_type == "page",
|
||||
PageConfig.container_id == post.id,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if existing is None:
|
||||
sess.add(PageConfig(
|
||||
container_type="page",
|
||||
container_id=post.id,
|
||||
features={},
|
||||
))
|
||||
|
||||
await sess.flush()
|
||||
|
||||
# AP federation
|
||||
if status == "published":
|
||||
tag_objs = (await _upsert_tags_by_name(sess, tag_names)) if tag_names else []
|
||||
await _fire_ap_publish(sess, post, None, tag_objs)
|
||||
|
||||
return post
|
||||
|
||||
|
||||
async def create_page(
|
||||
sess: AsyncSession,
|
||||
*,
|
||||
title: str,
|
||||
lexical_json: str,
|
||||
status: str = "draft",
|
||||
user_id: int,
|
||||
feature_image: str | None = None,
|
||||
custom_excerpt: str | None = None,
|
||||
feature_image_caption: str | None = None,
|
||||
tag_names: list[str] | None = None,
|
||||
sx_content: str | None = None,
|
||||
) -> Post:
|
||||
"""Create a new page. Convenience wrapper around create_post."""
|
||||
return await create_post(
|
||||
sess,
|
||||
title=title,
|
||||
lexical_json=lexical_json,
|
||||
status=status,
|
||||
user_id=user_id,
|
||||
feature_image=feature_image,
|
||||
custom_excerpt=custom_excerpt,
|
||||
feature_image_caption=feature_image_caption,
|
||||
tag_names=tag_names,
|
||||
is_page=True,
|
||||
sx_content=sx_content,
|
||||
)
|
||||
|
||||
|
||||
async def update_post(
|
||||
sess: AsyncSession,
|
||||
*,
|
||||
post_id: int,
|
||||
lexical_json: str,
|
||||
title: str | None = None,
|
||||
expected_updated_at: datetime | str,
|
||||
feature_image: str | None = ..., # type: ignore[assignment]
|
||||
custom_excerpt: str | None = ..., # type: ignore[assignment]
|
||||
feature_image_caption: str | None = ..., # type: ignore[assignment]
|
||||
status: str | None = None,
|
||||
sx_content: str | None = ..., # type: ignore[assignment]
|
||||
) -> Post:
|
||||
"""Update post content. Optimistic lock via expected_updated_at.
|
||||
|
||||
Fields set to ... (sentinel) are left unchanged. None clears the field.
|
||||
Raises ValueError on optimistic lock conflict (409).
|
||||
"""
|
||||
_SENTINEL = ...
|
||||
|
||||
post = await sess.get(Post, post_id)
|
||||
if post is None:
|
||||
raise ValueError(f"Post {post_id} not found")
|
||||
|
||||
# Optimistic lock
|
||||
if isinstance(expected_updated_at, str):
|
||||
expected_updated_at = datetime.fromisoformat(
|
||||
expected_updated_at.replace("Z", "+00:00")
|
||||
)
|
||||
if post.updated_at and abs((post.updated_at - expected_updated_at).total_seconds()) > 1:
|
||||
raise OptimisticLockError(
|
||||
f"Post was modified at {post.updated_at}, expected {expected_updated_at}"
|
||||
)
|
||||
|
||||
old_status = post.status
|
||||
|
||||
# Render content
|
||||
html, plaintext, reading_time = _render_and_extract(lexical_json)
|
||||
post.lexical = lexical_json if isinstance(lexical_json, str) else json.dumps(lexical_json)
|
||||
post.html = html
|
||||
post.plaintext = plaintext
|
||||
post.reading_time = reading_time
|
||||
|
||||
if title is not None:
|
||||
post.title = title
|
||||
|
||||
if sx_content is not _SENTINEL:
|
||||
post.sx_content = sx_content
|
||||
|
||||
if feature_image is not _SENTINEL:
|
||||
post.feature_image = feature_image
|
||||
if custom_excerpt is not _SENTINEL:
|
||||
post.custom_excerpt = custom_excerpt
|
||||
if feature_image_caption is not _SENTINEL:
|
||||
post.feature_image_caption = _sanitize_html(feature_image_caption)
|
||||
|
||||
if status is not None:
|
||||
post.status = status
|
||||
if status == "published" and not post.published_at:
|
||||
post.published_at = utcnow()
|
||||
|
||||
post.updated_at = utcnow()
|
||||
await sess.flush()
|
||||
|
||||
# AP federation on status change
|
||||
tags = list(post.tags) if hasattr(post, "tags") and post.tags else []
|
||||
await _fire_ap_publish(sess, post, old_status, tags)
|
||||
|
||||
return post
|
||||
|
||||
|
||||
_SETTINGS_FIELDS = (
|
||||
"slug", "published_at", "featured", "visibility", "email_only",
|
||||
"custom_template", "meta_title", "meta_description", "canonical_url",
|
||||
"og_image", "og_title", "og_description",
|
||||
"twitter_image", "twitter_title", "twitter_description",
|
||||
"feature_image_alt",
|
||||
)
|
||||
|
||||
|
||||
async def update_post_settings(
|
||||
sess: AsyncSession,
|
||||
*,
|
||||
post_id: int,
|
||||
expected_updated_at: datetime | str,
|
||||
tag_names: list[str] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Post:
|
||||
"""Update post settings (slug, tags, SEO, social, etc.).
|
||||
|
||||
Optimistic lock via expected_updated_at.
|
||||
"""
|
||||
post = await sess.get(Post, post_id)
|
||||
if post is None:
|
||||
raise ValueError(f"Post {post_id} not found")
|
||||
|
||||
# Optimistic lock
|
||||
if isinstance(expected_updated_at, str):
|
||||
expected_updated_at = datetime.fromisoformat(
|
||||
expected_updated_at.replace("Z", "+00:00")
|
||||
)
|
||||
if post.updated_at and abs((post.updated_at - expected_updated_at).total_seconds()) > 1:
|
||||
raise OptimisticLockError(
|
||||
f"Post was modified at {post.updated_at}, expected {expected_updated_at}"
|
||||
)
|
||||
|
||||
old_status = post.status
|
||||
|
||||
for field in _SETTINGS_FIELDS:
|
||||
val = kwargs.get(field)
|
||||
if val is not None:
|
||||
if field == "slug":
|
||||
val = await _ensure_slug_unique(sess, val, exclude_post_id=post.id)
|
||||
if field == "featured":
|
||||
val = bool(val)
|
||||
if field == "email_only":
|
||||
val = bool(val)
|
||||
if field == "published_at":
|
||||
if isinstance(val, str):
|
||||
val = datetime.fromisoformat(val.replace("Z", "+00:00"))
|
||||
setattr(post, field, val)
|
||||
|
||||
# Tags
|
||||
if tag_names is not None:
|
||||
tags = await _upsert_tags_by_name(sess, tag_names)
|
||||
await _rebuild_post_tags(sess, post.id, tags)
|
||||
post.primary_tag_id = tags[0].id if tags else None
|
||||
|
||||
post.updated_at = utcnow()
|
||||
await sess.flush()
|
||||
|
||||
# AP federation if visibility/status changed
|
||||
tags_obj = list(post.tags) if hasattr(post, "tags") and post.tags else []
|
||||
await _fire_ap_publish(sess, post, old_status, tags_obj)
|
||||
|
||||
return post
|
||||
|
||||
|
||||
async def delete_post(sess: AsyncSession, post_id: int) -> None:
|
||||
"""Soft-delete a post via deleted_at."""
|
||||
post = await sess.get(Post, post_id)
|
||||
if post is None:
|
||||
return
|
||||
|
||||
old_status = post.status
|
||||
post.deleted_at = utcnow()
|
||||
post.status = "deleted"
|
||||
await sess.flush()
|
||||
|
||||
# Fire AP Delete if was published
|
||||
if old_status == "published" and post.user_id:
|
||||
tags = list(post.tags) if hasattr(post, "tags") and post.tags else []
|
||||
await _fire_ap_publish(sess, post, old_status, tags)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exceptions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class OptimisticLockError(Exception):
|
||||
"""Raised when optimistic lock check fails (stale updated_at)."""
|
||||
pass
|
||||
533
blog/sx/admin.sx
533
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,37 +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-snippets-empty ()
|
||||
(div :class "bg-white rounded-lg shadow"
|
||||
(div :class "p-8 text-center text-stone-400"
|
||||
(i :class "fa fa-puzzle-piece text-4xl mb-2")
|
||||
(p "No snippets yet. Create one from the blog editor."))))
|
||||
|
||||
(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-delete-button (&key confirm-text delete-url hx-headers)
|
||||
(button :type "button" :data-confirm "" :data-confirm-title "Delete snippet?"
|
||||
:data-confirm-text confirm-text :data-confirm-icon "warning"
|
||||
:data-confirm-confirm-text "Yes, delete" :data-confirm-cancel-text "Cancel"
|
||||
:data-confirm-event "confirmed"
|
||||
:sx-delete delete-url :sx-trigger "confirmed" :sx-target "#snippets-list" :sx-swap "innerHTML"
|
||||
:sx-headers hx-headers
|
||||
:class "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800 flex-shrink-0"
|
||||
(i :class "fa fa-trash") " Delete"))
|
||||
|
||||
(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)
|
||||
@@ -46,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"
|
||||
@@ -58,17 +42,7 @@
|
||||
(div :id "menu-item-form" :class "mb-6")
|
||||
(div :id "menu-items-list" list)))
|
||||
|
||||
(defcomp ~blog-menu-items-empty ()
|
||||
(div :class "bg-white rounded-lg shadow"
|
||||
(div :class "p-8 text-center text-stone-400"
|
||||
(i :class "fa fa-inbox text-4xl mb-2")
|
||||
(p "No menu items yet. Add one to get started!"))))
|
||||
|
||||
(defcomp ~blog-menu-item-image (&key src label)
|
||||
(if src (img :src src :alt label :class "w-12 h-12 rounded-full object-cover flex-shrink-0")
|
||||
(div :class "w-12 h-12 rounded-full bg-stone-200 flex-shrink-0")))
|
||||
|
||||
(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
|
||||
@@ -80,21 +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")
|
||||
(button :type "button" :data-confirm "" :data-confirm-title "Delete menu item?"
|
||||
:data-confirm-text confirm-text :data-confirm-icon "warning"
|
||||
:data-confirm-confirm-text "Yes, delete" :data-confirm-cancel-text "Cancel"
|
||||
:data-confirm-event "confirmed"
|
||||
:sx-delete delete-url :sx-trigger "confirmed" :sx-target "#menu-items-list" :sx-swap "innerHTML"
|
||||
:sx-headers hx-headers
|
||||
:class "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800"
|
||||
(i :class "fa fa-trash") " Delete"))))
|
||||
(~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")
|
||||
@@ -105,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"
|
||||
@@ -120,35 +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-tag-groups-empty ()
|
||||
(p :class "text-stone-500 text-sm" "No tag groups yet."))
|
||||
|
||||
(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"
|
||||
@@ -167,12 +133,461 @@
|
||||
(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 ~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; }
|
||||
.sx-list, .json-obj, .json-arr { display: block; }
|
||||
.sx-paren { color: #64748b; }
|
||||
.sx-sym { color: #0369a1; }
|
||||
.sx-kw { color: #7c3aed; }
|
||||
.sx-str { color: #15803d; }
|
||||
.sx-num { color: #c2410c; }
|
||||
.sx-bool { color: #b91c1c; font-weight: 600; }
|
||||
.json-brace, .json-bracket { color: #64748b; }
|
||||
.json-key { color: #7c3aed; }
|
||||
.json-str { color: #15803d; }
|
||||
.json-num { color: #c2410c; }
|
||||
.json-lit { color: #b91c1c; font-weight: 600; }
|
||||
.json-field { display: block; }
|
||||
")
|
||||
sections))
|
||||
|
||||
(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,40 +1,76 @@
|
||||
;; 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)
|
||||
(defcomp ~detail/main (&key draft chrome feature-image html-content sx-content)
|
||||
(<> (article :class "relative"
|
||||
draft
|
||||
chrome
|
||||
(when feature-image (div :class "mb-3 flex justify-center"
|
||||
(img :src feature-image :alt "" :class "rounded-lg w-full md:w-3/4 object-cover")))
|
||||
(when html-content (div :class "blog-content p-2" (~rich-text :html html-content))))
|
||||
(if sx-content
|
||||
(div :class "blog-content p-2" sx-content)
|
||||
(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)
|
||||
@@ -50,11 +86,8 @@
|
||||
(meta :name "twitter:description" :content desc)
|
||||
(when image (meta :name "twitter:image" :content image))))
|
||||
|
||||
(defcomp ~blog-home-main (&key html-content)
|
||||
(article :class "relative" (div :class "blog-content p-2" (~rich-text :html html-content))))
|
||||
|
||||
(defcomp ~blog-admin-empty ()
|
||||
(div :class "pb-8"))
|
||||
|
||||
(defcomp ~blog-settings-empty ()
|
||||
(div :class "max-w-2xl mx-auto px-4 py-6"))
|
||||
(defcomp ~detail/home-main (&key html-content sx-content)
|
||||
(article :class "relative"
|
||||
(if sx-content
|
||||
(div :class "blog-content p-2" sx-content)
|
||||
(div :class "blog-content p-2" (~rich-text :html html-content)))))
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
;; 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 "")
|
||||
(input :type "hidden" :id "sx-content-input" :name "sx_content" :value "")
|
||||
(input :type "hidden" :id "feature-image-input" :name "feature_image" :value "")
|
||||
(input :type "hidden" :id "feature-image-caption-input" :name "feature_image_caption" :value "")
|
||||
(div :id "feature-image-container" :class "relative mt-[16px] mb-[24px] group"
|
||||
@@ -34,21 +35,316 @@
|
||||
: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")
|
||||
(div :id "lexical-editor" :class "relative w-full bg-transparent")
|
||||
;; Editor tabs: SX (primary) and Koenig (legacy)
|
||||
(div :class "flex gap-[4px] mb-[8px] border-b border-stone-200"
|
||||
(button :type "button" :id "editor-tab-sx"
|
||||
:class "px-[12px] py-[6px] text-[13px] font-medium text-stone-700 border-b-2 border-stone-700 cursor-pointer bg-transparent"
|
||||
: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 "px-[12px] py-[6px] text-[13px] font-medium text-stone-400 border-b-2 border-transparent cursor-pointer bg-transparent hover:text-stone-600"
|
||||
: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")
|
||||
(div :id "lexical-editor" :class "relative w-full bg-transparent" :style "display:none")
|
||||
(div :class "flex items-center gap-[16px] mt-[32px] pt-[16px] border-t border-stone-200"
|
||||
(select :name "status"
|
||||
:class "text-[14px] rounded-[4px] border border-stone-200 px-[8px] py-[6px] bg-white text-stone-600"
|
||||
(option :value "draft" :selected t "Draft")
|
||||
(option :value "draft" :selected true "Draft")
|
||||
(option :value "published" "Published"))
|
||||
(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 init-js)
|
||||
(<> (script :src js-src) (script 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 ~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; }"
|
||||
".sx-blocks-container { display: flex; flex-direction: column; min-height: 300px; cursor: text; padding-left: 48px; }"
|
||||
|
||||
;; Block base
|
||||
".sx-block { position: relative; margin: 1px 0; }"
|
||||
".sx-block-content { padding: 2px 0; }"
|
||||
".sx-editable { outline: none; min-height: 1.6em; }"
|
||||
".sx-editable:empty:before { content: attr(data-placeholder); color: #d6d3d1; pointer-events: none; }"
|
||||
|
||||
;; Text block styles
|
||||
".sx-heading { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-weight: 700; }"
|
||||
".sx-block[data-sx-tag='h2'] .sx-heading { font-size: 2em; line-height: 1.25; margin: 0.5em 0 0.25em; }"
|
||||
".sx-block[data-sx-tag='h3'] .sx-heading { font-size: 1.5em; line-height: 1.3; margin: 0.4em 0 0.2em; }"
|
||||
".sx-quote { border-left: 3px solid #d6d3d1; padding-left: 16px; font-style: italic; color: #57534e; }"
|
||||
|
||||
;; HR
|
||||
".sx-block-hr { padding: 12px 0; }"
|
||||
".sx-hr { border: none; border-top: 1px solid #e7e5e4; }"
|
||||
|
||||
;; Code blocks
|
||||
".sx-block-code { background: #1c1917; border-radius: 6px; padding: 16px; margin: 8px 0; }"
|
||||
".sx-code-header { margin-bottom: 8px; }"
|
||||
".sx-code-lang { background: transparent; border: none; color: #a8a29e; font-size: 12px; outline: none; width: 120px; font-family: monospace; }"
|
||||
".sx-code-textarea { width: 100%; background: transparent; border: none; color: #fafaf9; font-family: 'SF Mono', 'Fira Code', 'Fira Mono', monospace; font-size: 14px; line-height: 1.5; resize: none; outline: none; min-height: 60px; tab-size: 2; }"
|
||||
|
||||
;; List blocks
|
||||
".sx-list-content { padding-left: 0; }"
|
||||
".sx-list-item { padding: 2px 0 2px 24px; position: relative; outline: none; min-height: 1.6em; }"
|
||||
".sx-list-item:empty:before { content: attr(data-placeholder); color: #d6d3d1; pointer-events: none; }"
|
||||
".sx-list-item:before { content: '\\2022'; position: absolute; left: 6px; color: #78716c; }"
|
||||
".sx-block[data-sx-tag='ol'] { counter-reset: sx-list; }"
|
||||
".sx-block[data-sx-tag='ol'] .sx-list-item { counter-increment: sx-list; }"
|
||||
".sx-block[data-sx-tag='ol'] .sx-list-item:before { content: counter(sx-list) '.'; font-size: 14px; }"
|
||||
|
||||
;; Card blocks
|
||||
".sx-block-card { margin: 12px 0; border-radius: 4px; position: relative; transition: box-shadow 0.15s; }"
|
||||
".sx-block-card:hover { box-shadow: 0 0 0 1px #d6d3d1; }"
|
||||
".sx-block-card.sx-card-editing { box-shadow: 0 0 0 2px #3b82f6; }"
|
||||
".sx-card-preview { cursor: pointer; }"
|
||||
".sx-card-preview img { max-width: 100%; }"
|
||||
".sx-card-fallback { padding: 24px; text-align: center; color: #a8a29e; font-style: italic; background: #fafaf9; border-radius: 4px; }"
|
||||
".sx-card-caption { padding: 8px 0; font-size: 14px; color: #78716c; text-align: center; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }"
|
||||
".sx-card-caption:empty:before { content: attr(data-placeholder); color: #d6d3d1; }"
|
||||
|
||||
;; Card toolbar
|
||||
".sx-card-toolbar { position: absolute; top: -36px; right: 0; z-index: 10; display: none; gap: 4px; }"
|
||||
".sx-block-card:hover .sx-card-toolbar { display: flex; }"
|
||||
".sx-card-editing .sx-card-toolbar { display: flex; }"
|
||||
".sx-card-tool-btn { width: 28px; height: 28px; border-radius: 4px; border: 1px solid #d6d3d1; background: white; color: #78716c; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 12px; transition: all 0.1s; }"
|
||||
".sx-card-tool-btn:hover { background: #fafaf9; color: #dc2626; border-color: #dc2626; }"
|
||||
|
||||
;; Card edit panel
|
||||
".sx-card-edit { padding: 16px; background: #fafaf9; border-radius: 4px; }"
|
||||
|
||||
;; Plus button (Koenig-style floating)
|
||||
".sx-plus-container { position: absolute; z-index: 40; display: flex; align-items: center; }"
|
||||
".sx-plus-btn { width: 28px; height: 28px; border-radius: 50%; border: 1px solid #d6d3d1; background: white; color: #a8a29e; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.15s; padding: 0; }"
|
||||
".sx-plus-btn:hover { border-color: #1c1917; color: #1c1917; }"
|
||||
".sx-plus-btn-open { transform: rotate(45deg); border-color: #1c1917; color: #1c1917; }"
|
||||
".sx-plus-btn svg { width: 14px; height: 14px; }"
|
||||
|
||||
;; Plus menu
|
||||
".sx-plus-menu { position: absolute; top: 36px; left: -8px; z-index: 50; background: white; border: 1px solid #e7e5e4; border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.12); padding: 8px; width: 320px; max-height: 420px; overflow-y: auto; }"
|
||||
".sx-plus-menu-section { margin-bottom: 4px; }"
|
||||
".sx-plus-menu-heading { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #a8a29e; padding: 6px 8px 2px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }"
|
||||
".sx-plus-menu-item { display: flex; align-items: center; gap: 10px; width: 100%; padding: 6px 8px; border: none; background: none; cursor: pointer; font-size: 14px; color: #1c1917; border-radius: 4px; text-align: left; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }"
|
||||
".sx-plus-menu-item:hover { background: #f5f5f4; }"
|
||||
".sx-plus-menu-icon { width: 24px; text-align: center; font-size: 14px; color: #78716c; flex-shrink: 0; }"
|
||||
".sx-plus-menu-label { font-weight: 500; white-space: nowrap; }"
|
||||
".sx-plus-menu-desc { color: #a8a29e; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }"
|
||||
|
||||
;; Slash command menu
|
||||
".sx-slash-menu { position: absolute; z-index: 50; background: white; border: 1px solid #e7e5e4; border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.12); padding: 4px; width: 320px; max-height: 300px; overflow-y: auto; }"
|
||||
".sx-slash-menu-item { display: flex; align-items: center; gap: 10px; width: 100%; padding: 8px 10px; border: none; background: none; cursor: pointer; font-size: 14px; color: #1c1917; border-radius: 4px; text-align: left; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }"
|
||||
".sx-slash-menu-item:hover { background: #f5f5f4; }"
|
||||
".sx-slash-menu-icon { width: 24px; text-align: center; font-size: 14px; color: #78716c; flex-shrink: 0; }"
|
||||
".sx-slash-menu-label { font-weight: 500; white-space: nowrap; }"
|
||||
".sx-slash-menu-desc { color: #a8a29e; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }"
|
||||
|
||||
;; Format toolbar
|
||||
".sx-format-bar { position: absolute; z-index: 100; display: flex; gap: 1px; background: #1c1917; border-radius: 6px; padding: 2px; box-shadow: 0 4px 12px rgba(0,0,0,0.3); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }"
|
||||
".sx-format-btn { border: none; background: none; color: #e7e5e4; cursor: pointer; padding: 6px 10px; border-radius: 4px; font-size: 14px; line-height: 1; }"
|
||||
".sx-format-btn:hover { background: #44403c; color: white; }"
|
||||
".sx-fmt-bold { font-weight: 700; }"
|
||||
".sx-fmt-italic { font-style: italic; }"
|
||||
|
||||
;; Card edit UI elements
|
||||
".sx-edit-controls { display: flex; flex-direction: column; gap: 8px; }"
|
||||
".sx-edit-row { display: flex; align-items: center; gap: 8px; }"
|
||||
".sx-edit-label { font-size: 12px; font-weight: 600; color: #78716c; min-width: 80px; text-transform: uppercase; letter-spacing: 0.03em; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }"
|
||||
".sx-edit-input { flex: 1; padding: 6px 10px; border: 1px solid #d6d3d1; border-radius: 4px; font-size: 14px; outline: none; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }"
|
||||
".sx-edit-input:focus { border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.15); }"
|
||||
".sx-edit-select { padding: 6px 10px; border: 1px solid #d6d3d1; border-radius: 4px; font-size: 14px; outline: none; background: white; }"
|
||||
".sx-edit-btn { padding: 8px 16px; border: 1px solid #d6d3d1; border-radius: 6px; font-size: 14px; cursor: pointer; background: white; color: #1c1917; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; transition: all 0.1s; }"
|
||||
".sx-edit-btn:hover { background: #f5f5f4; border-color: #a8a29e; }"
|
||||
".sx-edit-btn-sm { padding: 4px 12px; font-size: 12px; }"
|
||||
".sx-edit-info { font-size: 12px; color: #a8a29e; }"
|
||||
".sx-edit-status { font-size: 13px; color: #78716c; margin-top: 4px; }"
|
||||
".sx-edit-url-input { font-family: monospace; }"
|
||||
".sx-edit-url-display { font-size: 12px; color: #78716c; font-family: monospace; margin: 4px 0; word-break: break-all; }"
|
||||
".sx-edit-checkbox-label { display: flex; align-items: center; gap: 6px; font-size: 14px; color: #44403c; cursor: pointer; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }"
|
||||
".sx-edit-img-preview { max-width: 100%; max-height: 300px; object-fit: contain; border-radius: 4px; margin-bottom: 12px; }"
|
||||
".sx-edit-embed-preview { margin-bottom: 8px; }"
|
||||
".sx-edit-embed-preview iframe { max-width: 100%; }"
|
||||
".sx-edit-audio-player { width: 100%; margin-bottom: 12px; }"
|
||||
".sx-edit-video-player { width: 100%; max-height: 300px; margin-bottom: 12px; border-radius: 4px; }"
|
||||
".sx-edit-html-textarea { width: 100%; min-height: 120px; font-family: 'SF Mono', 'Fira Code', monospace; font-size: 13px; padding: 12px; border: 1px solid #d6d3d1; border-radius: 4px; resize: vertical; outline: none; background: #fafaf9; line-height: 1.5; tab-size: 2; }"
|
||||
".sx-edit-html-textarea:focus { border-color: #3b82f6; }"
|
||||
".sx-edit-callout-content { padding: 8px 12px; background: white; border: 1px solid #d6d3d1; border-radius: 4px; min-height: 40px; }"
|
||||
".sx-edit-toggle-content { padding: 8px 12px; background: white; border: 1px solid #d6d3d1; border-radius: 4px; min-height: 40px; margin-top: 4px; }"
|
||||
".sx-edit-emoji-input { width: 60px; text-align: center; font-size: 20px; padding: 4px; margin-bottom: 8px; }"
|
||||
|
||||
;; Color picker
|
||||
".sx-edit-color-row { display: flex; gap: 6px; margin-bottom: 8px; }"
|
||||
".sx-edit-color-swatch { width: 28px; height: 28px; border-radius: 50%; border: 2px solid transparent; cursor: pointer; transition: all 0.1s; }"
|
||||
".sx-edit-color-swatch:hover { transform: scale(1.15); }"
|
||||
".sx-edit-color-swatch.active { border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.3); }"
|
||||
|
||||
;; Gallery grid
|
||||
".sx-edit-gallery-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 12px; }"
|
||||
".sx-edit-gallery-thumb { position: relative; aspect-ratio: 1; overflow: hidden; border-radius: 4px; }"
|
||||
".sx-edit-gallery-thumb img { width: 100%; height: 100%; object-fit: cover; }"
|
||||
".sx-edit-gallery-remove { position: absolute; top: 4px; right: 4px; width: 20px; height: 20px; border-radius: 50%; background: rgba(0,0,0,0.6); color: white; border: none; cursor: pointer; font-size: 14px; line-height: 1; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity 0.1s; }"
|
||||
".sx-edit-gallery-thumb:hover .sx-edit-gallery-remove { opacity: 1; }"
|
||||
|
||||
;; Upload area
|
||||
".sx-upload-area { padding: 40px 24px; border: 2px dashed #d6d3d1; border-radius: 8px; text-align: center; cursor: pointer; transition: all 0.15s; }"
|
||||
".sx-upload-area:hover, .sx-upload-dragover { border-color: #3b82f6; background: rgba(59,130,246,0.03); }"
|
||||
".sx-upload-icon { font-size: 32px; color: #a8a29e; margin-bottom: 8px; }"
|
||||
".sx-upload-msg { font-size: 14px; color: #78716c; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }"
|
||||
".sx-upload-progress { font-size: 13px; color: #3b82f6; margin-top: 8px; }"
|
||||
|
||||
;; 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)))))))
|
||||
|
||||
32
blog/sx/handlers/link-card.sx
Normal file
32
blog/sx/handlers/link-card.sx
Normal file
@@ -0,0 +1,32 @@
|
||||
;; 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).
|
||||
|
||||
(defhandler link-card (&key slug keys)
|
||||
(if keys
|
||||
(let ((slugs (split keys ",")))
|
||||
(<> (map (fn (s)
|
||||
(let ((post (query "blog" "post-by-slug" :slug (trim s))))
|
||||
(when post
|
||||
(<> (str "<!-- fragment:" (trim s) " -->")
|
||||
(~shared:fragments/link-card
|
||||
:link (app-url "blog" (str "/" (get post "slug") "/"))
|
||||
:title (get post "title")
|
||||
:image (get post "feature_image")
|
||||
:icon "fas fa-file-alt"
|
||||
:subtitle (or (get post "custom_excerpt") (get post "excerpt"))
|
||||
:detail (get post "published_at_display")
|
||||
:data-app "blog"))))) slugs)))
|
||||
(when slug
|
||||
(let ((post (query "blog" "post-by-slug" :slug slug)))
|
||||
(when post
|
||||
(~shared:fragments/link-card
|
||||
:link (app-url "blog" (str "/" (get post "slug") "/"))
|
||||
:title (get post "title")
|
||||
:image (get post "feature_image")
|
||||
:icon "fas fa-file-alt"
|
||||
:subtitle (or (get post "custom_excerpt") (get post "excerpt"))
|
||||
:detail (get post "published_at_display")
|
||||
:data-app "blog"))))))
|
||||
81
blog/sx/handlers/nav-tree.sx
Normal file
81
blog/sx/handlers/nav-tree.sx
Normal file
@@ -0,0 +1,81 @@
|
||||
;; 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.
|
||||
|
||||
(defhandler nav-tree (&key app_name path)
|
||||
(let ((app (or app_name ""))
|
||||
(cur-path (or path "/"))
|
||||
(first-seg (first (filter (fn (s) (not (empty? s)))
|
||||
(split (trim cur-path) "/"))))
|
||||
(items (nav-tree))
|
||||
(nav-cls "whitespace-nowrap flex items-center gap-2 rounded p-2 text-sm")
|
||||
|
||||
;; App slug → URL mapping
|
||||
(app-slugs (dict
|
||||
:cart (app-url "cart" "/")
|
||||
:market (app-url "market" "/")
|
||||
:events (app-url "events" "/")
|
||||
:federation (app-url "federation" "/")
|
||||
:account (app-url "account" "/")
|
||||
:artdag (app-url "artdag" "/"))))
|
||||
|
||||
(let ((item-sxs
|
||||
(<>
|
||||
;; Nav items from DB
|
||||
(map (fn (item)
|
||||
(let ((item-slug (or (get item "slug") ""))
|
||||
(href (or (get app-slugs item-slug)
|
||||
(app-url "blog" (str "/" item-slug "/"))))
|
||||
(selected (or (= item-slug (or first-seg ""))
|
||||
(= item-slug app))))
|
||||
(~shared:nav/blog-nav-item-link
|
||||
:href href
|
||||
:hx-get href
|
||||
:selected (if selected "true" "false")
|
||||
:nav-cls nav-cls
|
||||
: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
|
||||
(~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 (~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")))
|
||||
|
||||
;; Scroll wrapper IDs + hyperscript
|
||||
(arrow-cls "scrolling-menu-arrow-menu-items-container")
|
||||
(cid "menu-items-container")
|
||||
(left-hs (str "on click set #" cid ".scrollLeft to #" cid ".scrollLeft - 200"))
|
||||
(scroll-hs (str "on scroll "
|
||||
"set cls to '" arrow-cls "' "
|
||||
"set arrows to document.getElementsByClassName(cls) "
|
||||
"set show to (window.innerWidth >= 640 and "
|
||||
"my.scrollWidth > my.clientWidth) "
|
||||
"repeat for arrow in arrows "
|
||||
"if show remove .hidden from arrow add .flex to arrow "
|
||||
"else add .hidden to arrow remove .flex from arrow end "
|
||||
"end"))
|
||||
(right-hs (str "on click set #" cid ".scrollLeft to #" cid ".scrollLeft + 200")))
|
||||
|
||||
(if (empty? 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 cid
|
||||
:arrow-cls arrow-cls
|
||||
:left-hs left-hs
|
||||
:scroll-hs scroll-hs
|
||||
:right-hs right-hs
|
||||
:items item-sxs
|
||||
:oob true)))))
|
||||
@@ -1,24 +1,21 @@
|
||||
;; Blog header components
|
||||
|
||||
(defcomp ~blog-header-label ()
|
||||
(div))
|
||||
|
||||
(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)))
|
||||
|
||||
278
blog/sx/index.sx
278
blog/sx/index.sx
@@ -1,56 +1,9 @@
|
||||
;; Blog index components
|
||||
|
||||
(defcomp ~blog-end-of-results ()
|
||||
(div :class "col-span-full mt-4 text-center text-xs text-stone-400" "End of results"))
|
||||
|
||||
(defcomp ~blog-sentinel-mobile (&key id next-url hyperscript)
|
||||
(div :id id :class "block md:hidden h-[60vh] opacity-0 pointer-events-none js-mobile-sentinel"
|
||||
:sx-get next-url :sx-trigger "intersect once delay:250ms, sentinelmobile:retry"
|
||||
:sx-swap "outerHTML" :_ hyperscript
|
||||
:role "status" :aria-live "polite" :aria-hidden "true"
|
||||
(div :class "js-loading hidden flex justify-center py-8"
|
||||
(div :class "animate-spin h-8 w-8 border-4 border-stone-300 border-t-stone-600 rounded-full"))
|
||||
(div :class "js-neterr hidden text-center py-8 text-stone-400"
|
||||
(i :class "fa fa-exclamation-triangle text-2xl")
|
||||
(p :class "mt-2" "Loading failed \u2014 retrying\u2026"))))
|
||||
|
||||
(defcomp ~blog-sentinel-desktop (&key id next-url hyperscript)
|
||||
(div :id id :class "hidden md:block h-4 opacity-0 pointer-events-none"
|
||||
:sx-get next-url :sx-trigger "intersect once delay:250ms, sentinel:retry"
|
||||
:sx-swap "outerHTML" :_ hyperscript
|
||||
:role "status" :aria-live "polite" :aria-hidden "true"
|
||||
(div :class "js-loading hidden flex justify-center py-2"
|
||||
(div :class "animate-spin h-6 w-6 border-2 border-stone-300 border-t-stone-600 rounded-full"))
|
||||
(div :class "js-neterr hidden text-center py-2 text-stone-400 text-sm" "Retry\u2026")))
|
||||
|
||||
(defcomp ~blog-page-sentinel (&key id next-url)
|
||||
(div :id id :class "h-4 opacity-0 pointer-events-none"
|
||||
:sx-get next-url :sx-trigger "intersect once delay:250ms" :sx-swap "outerHTML"))
|
||||
|
||||
(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-list-svg ()
|
||||
(svg :xmlns "http://www.w3.org/2000/svg" :class "h-5 w-5" :fill "none" :viewBox "0 0 24 24"
|
||||
:stroke "currentColor" :stroke-width "2"
|
||||
(path :stroke-linecap "round" :stroke-linejoin "round" :d "M4 6h16M4 12h16M4 18h16")))
|
||||
|
||||
(defcomp ~blog-tile-svg ()
|
||||
(svg :xmlns "http://www.w3.org/2000/svg" :class "h-5 w-5" :fill "none" :viewBox "0 0 24 24"
|
||||
:stroke "currentColor" :stroke-width "2"
|
||||
(path :stroke-linecap "round" :stroke-linejoin "round"
|
||||
:d "M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z")))
|
||||
|
||||
(defcomp ~blog-view-toggle (&key list-href tile-href hx-select list-cls tile-cls list-svg tile-svg)
|
||||
(div :class "hidden md:flex justify-end px-3 pt-3 gap-1"
|
||||
(a :href list-href :sx-get list-href :sx-target "#main-panel" :sx-select hx-select
|
||||
:sx-swap "outerHTML" :sx-push-url "true" :class (str "p-1.5 rounded " list-cls) :title "List view"
|
||||
:_ "on click js localStorage.removeItem('blog_view') end" list-svg)
|
||||
(a :href tile-href :sx-get tile-href :sx-target "#main-panel" :sx-select hx-select
|
||||
:sx-swap "outerHTML" :sx-push-url "true" :class (str "p-1.5 rounded " tile-cls) :title "Tile view"
|
||||
:_ "on click js localStorage.setItem('blog_view','tile') end" tile-svg)))
|
||||
|
||||
(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"
|
||||
@@ -59,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))))
|
||||
|
||||
153
blog/sx/kg_cards.sx
Normal file
153
blog/sx/kg_cards.sx
Normal file
@@ -0,0 +1,153 @@
|
||||
;; KG card components — Ghost/Koenig-compatible card rendering
|
||||
;; Produces same HTML structure as lexical_renderer.py so cards.css works unchanged.
|
||||
;; Used by both display pipeline and block editor.
|
||||
|
||||
;; @css kg-card kg-image-card kg-width-wide kg-width-full kg-gallery-card kg-gallery-container kg-gallery-row kg-gallery-image kg-embed-card kg-bookmark-card kg-bookmark-container kg-bookmark-content kg-bookmark-title kg-bookmark-description kg-bookmark-metadata kg-bookmark-icon kg-bookmark-author kg-bookmark-publisher kg-bookmark-thumbnail kg-callout-card kg-callout-emoji kg-callout-text kg-button-card kg-btn kg-btn-accent kg-toggle-card kg-toggle-heading kg-toggle-heading-text kg-toggle-card-icon kg-toggle-content kg-audio-card kg-audio-thumbnail kg-audio-player-container kg-audio-title kg-audio-player kg-audio-play-icon kg-audio-current-time kg-audio-time kg-audio-seek-slider kg-audio-playback-rate kg-audio-unmute-icon kg-audio-volume-slider kg-video-card kg-video-container kg-file-card kg-file-card-container kg-file-card-contents kg-file-card-title kg-file-card-filesize kg-file-card-icon kg-file-card-caption kg-align-center kg-align-left kg-callout-card-grey kg-callout-card-white kg-callout-card-blue kg-callout-card-green kg-callout-card-yellow kg-callout-card-red kg-callout-card-pink kg-callout-card-purple kg-callout-card-accent kg-html-card kg-md-card placeholder
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Image card
|
||||
;; ---------------------------------------------------------------------------
|
||||
(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" "")))
|
||||
(if href
|
||||
(a :href href (img :src src :alt (or alt "") :loading "lazy"))
|
||||
(img :src src :alt (or alt "") :loading "lazy"))
|
||||
(when caption (figcaption caption))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Gallery card
|
||||
;; ---------------------------------------------------------------------------
|
||||
(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)
|
||||
(div :class "kg-gallery-row"
|
||||
(map (lambda (img-data)
|
||||
(figure :class "kg-gallery-image"
|
||||
(img :src (get img-data "src") :alt (or (get img-data "alt") "") :loading "lazy")
|
||||
(when (get img-data "caption") (figcaption (get img-data "caption")))))
|
||||
row)))
|
||||
images))
|
||||
(when caption (figcaption caption))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; 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_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_cards/kg-md (&rest children)
|
||||
(div :class "kg-card kg-md-card" children))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Embed card
|
||||
;; ---------------------------------------------------------------------------
|
||||
(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))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Bookmark card
|
||||
;; ---------------------------------------------------------------------------
|
||||
(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"
|
||||
(div :class "kg-bookmark-title" (or title ""))
|
||||
(div :class "kg-bookmark-description" (or description ""))
|
||||
(when (or icon author publisher)
|
||||
(span :class "kg-bookmark-metadata"
|
||||
(when icon (img :class "kg-bookmark-icon" :src icon :alt ""))
|
||||
(when author (span :class "kg-bookmark-author" author))
|
||||
(when publisher (span :class "kg-bookmark-publisher" publisher)))))
|
||||
(when thumbnail
|
||||
(div :class "kg-bookmark-thumbnail"
|
||||
(img :src thumbnail :alt ""))))
|
||||
(when caption (figcaption caption))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Callout card
|
||||
;; ---------------------------------------------------------------------------
|
||||
(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 ""))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Button card
|
||||
;; ---------------------------------------------------------------------------
|
||||
(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_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 ""))
|
||||
(button :class "kg-toggle-card-icon"
|
||||
(~rich-text :html "<svg viewBox=\"0 0 14 14\"><path d=\"M7 0a.5.5 0 0 1 .5.5v6h6a.5.5 0 1 1 0 1h-6v6a.5.5 0 1 1-1 0v-6h-6a.5.5 0 0 1 0-1h6v-6A.5.5 0 0 1 7 0Z\" fill=\"currentColor\"/></svg>")))
|
||||
(div :class "kg-toggle-content" (or content ""))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Audio card
|
||||
;; ---------------------------------------------------------------------------
|
||||
(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")
|
||||
(div :class "kg-audio-thumbnail placeholder"
|
||||
(~rich-text :html "<svg viewBox=\"0 0 24 24\"><path d=\"M2 12C2 6.48 6.48 2 12 2s10 4.48 10 10-4.48 10-10 10S2 17.52 2 12zm7.5 5.25L16 12 9.5 6.75v10.5z\" fill=\"currentColor\"/></svg>")))
|
||||
(div :class "kg-audio-player-container"
|
||||
(div :class "kg-audio-title" (or title ""))
|
||||
(div :class "kg-audio-player"
|
||||
(button :class "kg-audio-play-icon"
|
||||
(~rich-text :html "<svg viewBox=\"0 0 24 24\"><path d=\"M8 5v14l11-7z\" fill=\"currentColor\"/></svg>"))
|
||||
(div :class "kg-audio-current-time" "0:00")
|
||||
(div :class "kg-audio-time" (str "/ " (or duration "0:00")))
|
||||
(input :type "range" :class "kg-audio-seek-slider" :max "100" :value "0")
|
||||
(button :class "kg-audio-playback-rate" "1×")
|
||||
(button :class "kg-audio-unmute-icon"
|
||||
(~rich-text :html "<svg viewBox=\"0 0 24 24\"><path d=\"M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z\" fill=\"currentColor\"/></svg>"))
|
||||
(input :type "range" :class "kg-audio-volume-slider" :max "100" :value "100")))
|
||||
(audio :src src :preload "metadata")))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Video card
|
||||
;; ---------------------------------------------------------------------------
|
||||
(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" "")))
|
||||
(div :class "kg-video-container"
|
||||
(video :src src :controls true :preload "metadata"
|
||||
:poster (or thumbnail nil) :loop (or loop nil)))
|
||||
(when caption (figcaption caption))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; File card
|
||||
;; ---------------------------------------------------------------------------
|
||||
(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"
|
||||
(div :class "kg-file-card-title" (or title filename ""))
|
||||
(when filesize (div :class "kg-file-card-filesize" filesize)))
|
||||
(div :class "kg-file-card-icon"
|
||||
(~rich-text :html "<svg viewBox=\"0 0 24 24\"><path d=\"M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z\" fill=\"currentColor\"/></svg>")))
|
||||
(when caption (div :class "kg-file-card-caption" caption))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Paywall marker
|
||||
;; ---------------------------------------------------------------------------
|
||||
(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))
|
||||
60
blog/sx/menu_items.sx
Normal file
60
blog/sx/menu_items.sx
Normal file
@@ -0,0 +1,60 @@
|
||||
;; Menu item form and page search components
|
||||
|
||||
(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 "")
|
||||
(if feature-image
|
||||
(img :src feature-image :alt title :class "w-10 h-10 rounded-full object-cover flex-shrink-0")
|
||||
(div :class "w-10 h-10 rounded-full bg-stone-200 flex-shrink-0"))
|
||||
(div :class "flex-1 min-w-0"
|
||||
(div :class "font-medium truncate" title)
|
||||
(div :class "text-xs text-stone-500 truncate" slug))))
|
||||
|
||||
(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 ~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 ~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,67 +0,0 @@
|
||||
;; Blog navigation components
|
||||
|
||||
(defcomp ~blog-nav-empty (&key wrapper-id)
|
||||
(div :id wrapper-id :sx-swap-oob "outerHTML"))
|
||||
|
||||
(defcomp ~blog-nav-item-image (&key src label)
|
||||
(if src (img :src src :alt label :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-nav-item-link (&key href hx-get selected nav-cls img label)
|
||||
(div (a :href href :sx-get hx-get :sx-target "#main-panel"
|
||||
:sx-swap "outerHTML" :sx-push-url "true"
|
||||
:aria-selected selected :class nav-cls
|
||||
img (span label))))
|
||||
|
||||
(defcomp ~blog-nav-item-plain (&key href selected nav-cls img label)
|
||||
(div (a :href href :aria-selected selected :class nav-cls
|
||||
img (span label))))
|
||||
|
||||
(defcomp ~blog-nav-wrapper (&key arrow-cls container-id left-hs scroll-hs right-hs items)
|
||||
(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 "menu-items-nav-wrapper" :sx-swap-oob "outerHTML"
|
||||
(button :class (str arrow-cls " hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded")
|
||||
:aria-label "Scroll left"
|
||||
:_ left-hs (i :class "fa fa-chevron-left"))
|
||||
(div :id container-id
|
||||
:class "overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none"
|
||||
:style "scroll-behavior: smooth;" :_ scroll-hs
|
||||
(div :class "flex flex-col sm:flex-row gap-1" items))
|
||||
(style ".scrollbar-hide::-webkit-scrollbar { display: none; } .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }")
|
||||
(button :class (str arrow-cls " hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded")
|
||||
:aria-label "Scroll right"
|
||||
:_ right-hs (i :class "fa fa-chevron-right"))))
|
||||
|
||||
;; Nav entries
|
||||
|
||||
(defcomp ~blog-nav-entries-empty ()
|
||||
(div :id "entries-calendars-nav-wrapper" :sx-swap-oob "true"))
|
||||
|
||||
(defcomp ~blog-nav-entry-item (&key href nav-cls name date-str)
|
||||
(a :href href :class nav-cls
|
||||
(div :class "w-8 h-8 rounded bg-stone-200 flex-shrink-0")
|
||||
(div :class "flex-1 min-w-0"
|
||||
(div :class "font-medium truncate" name)
|
||||
(div :class "text-xs text-stone-600 truncate" date-str))))
|
||||
|
||||
(defcomp ~blog-nav-calendar-item (&key href nav-cls name)
|
||||
(a :href href :class nav-cls
|
||||
(i :class "fa fa-calendar" :aria-hidden "true")
|
||||
(div name)))
|
||||
|
||||
(defcomp ~blog-nav-entries-wrapper (&key scroll-hs items)
|
||||
(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" :sx-swap-oob "true"
|
||||
(button :class "entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
|
||||
:aria-label "Scroll left"
|
||||
:_ "on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200"
|
||||
(i :class "fa fa-chevron-left"))
|
||||
(div :id "associated-items-container"
|
||||
:class "overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none"
|
||||
:style "scroll-behavior: smooth;" :_ scroll-hs
|
||||
(div :class "flex flex-col sm:flex-row gap-1" items))
|
||||
(style ".scrollbar-hide::-webkit-scrollbar { display: none; } .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }")
|
||||
(button :class "entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
|
||||
:aria-label "Scroll right"
|
||||
:_ "on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200"
|
||||
(i :class "fa fa-chevron-right"))))
|
||||
@@ -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,55 +18,33 @@
|
||||
(i :class "fa fa-shopping-bag text-green-600 mr-1")
|
||||
" Market \u2014 enable product catalog on this page"))))
|
||||
|
||||
(defcomp ~blog-sumup-connected ()
|
||||
(span :class "ml-2 text-xs text-green-600" (i :class "fa fa-check-circle") " Connected"))
|
||||
|
||||
(defcomp ~blog-sumup-key-hint ()
|
||||
(p :class "text-xs text-stone-400 mt-0.5" "Key is set. Leave blank to keep current key."))
|
||||
|
||||
(defcomp ~blog-sumup-form (&key sumup-url merchant-code placeholder key-hint checkout-prefix connected)
|
||||
(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"
|
||||
(h4 :class "text-sm font-medium text-stone-700"
|
||||
(i :class "fa fa-credit-card text-purple-600 mr-1") " SumUp Payment")
|
||||
(p :class "text-xs text-stone-400 mt-1 mb-3"
|
||||
"Configure per-page SumUp credentials. Leave blank to use the global merchant account.")
|
||||
(form :sx-put sumup-url :sx-target "#features-panel" :sx-swap "outerHTML" :class "space-y-3"
|
||||
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "Merchant Code")
|
||||
(input :type "text" :name "merchant_code" :value merchant-code :placeholder "e.g. ME4J6100"
|
||||
:class "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"))
|
||||
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "API Key")
|
||||
(input :type "password" :name "api_key" :value "" :placeholder placeholder
|
||||
:class "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500")
|
||||
key-hint)
|
||||
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "Checkout Reference Prefix")
|
||||
(input :type "text" :name "checkout_prefix" :value checkout-prefix :placeholder "e.g. ROSE-"
|
||||
:class "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"))
|
||||
(button :type "submit"
|
||||
:class "px-4 py-1.5 text-sm font-medium text-white bg-purple-600 rounded hover:bg-purple-700 focus:ring-2 focus:ring-purple-500"
|
||||
"Save SumUp Settings")
|
||||
connected)))
|
||||
(~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
|
||||
@@ -76,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?"
|
||||
@@ -100,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
20
blog/sxc/pages/__init__.py
Normal file
20
blog/sxc/pages/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""Blog defpage setup — registers layouts, page helpers, and loads .sx pages."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
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")
|
||||
154
blog/sxc/pages/blog.sx
Normal file
154
blog/sxc/pages/blog.sx
Normal file
@@ -0,0 +1,154 @@
|
||||
; 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 ---
|
||||
|
||||
(defpage new-post
|
||||
:path "/new/"
|
||||
:auth :admin
|
||||
:layout :blog
|
||||
: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
|
||||
: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 (absolute paths under /<slug>/admin/) ---
|
||||
|
||||
(defpage post-admin
|
||||
:path "/<slug>/admin/"
|
||||
:auth :admin
|
||||
:layout (:post-admin :selected "admin")
|
||||
:data (post-admin-data slug)
|
||||
:content (~admin/placeholder))
|
||||
|
||||
(defpage post-data
|
||||
:path "/<slug>/admin/data/"
|
||||
:auth :admin
|
||||
:layout (:post-admin :selected "data")
|
||||
:data (post-data-data slug)
|
||||
:content (~admin/data-table-content :tablename tablename :model-data model-data))
|
||||
|
||||
(defpage post-preview
|
||||
:path "/<slug>/admin/preview/"
|
||||
:auth :admin
|
||||
:layout (:post-admin :selected "preview")
|
||||
: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 "/<slug>/admin/entries/"
|
||||
:auth :admin
|
||||
:layout (:post-admin :selected "entries")
|
||||
: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 "/<slug>/admin/settings/"
|
||||
:auth :post_author
|
||||
:layout (:post-admin :selected "settings")
|
||||
: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 "/<slug>/admin/edit/"
|
||||
:auth :post_author
|
||||
:layout (:post-admin :selected "edit")
|
||||
: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 (absolute paths) ---
|
||||
|
||||
(defpage settings-home
|
||||
:path "/settings/"
|
||||
:auth :admin
|
||||
:layout :blog-settings
|
||||
:content (div :class "max-w-2xl mx-auto px-4 py-6"))
|
||||
|
||||
(defpage cache-page
|
||||
:path "/settings/cache/"
|
||||
:auth :admin
|
||||
:layout :blog-cache
|
||||
:data (service "blog-page" "cache-data")
|
||||
:content (~admin/cache-panel :clear-url clear-url :csrf csrf))
|
||||
|
||||
; --- Snippets ---
|
||||
|
||||
(defpage snippets-page
|
||||
:path "/settings/snippets/"
|
||||
:auth :login
|
||||
:layout :blog-snippets
|
||||
: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 "/settings/menu_items/"
|
||||
:auth :admin
|
||||
:layout :blog-menu-items
|
||||
: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 "/settings/tag-groups/"
|
||||
:auth :admin
|
||||
:layout :blog-tag-groups
|
||||
: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 "/settings/tag-groups/<int:id>/"
|
||||
:auth :admin
|
||||
:layout :blog-tag-group-edit
|
||||
: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)
|
||||
@@ -1,33 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head><meta charset="utf-8"></head>
|
||||
<body style="margin:0;padding:0;background:#f5f5f4;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f5f5f4;padding:40px 0;">
|
||||
<tr><td align="center">
|
||||
<table width="480" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:12px;border:1px solid #e7e5e4;padding:40px;">
|
||||
<tr><td>
|
||||
<h1 style="margin:0 0 8px;font-size:20px;font-weight:600;color:#1c1917;">{{ site_name }}</h1>
|
||||
<p style="margin:0 0 24px;font-size:15px;color:#57534e;">Sign in to your account</p>
|
||||
<p style="margin:0 0 24px;font-size:15px;line-height:1.5;color:#44403c;">
|
||||
Click the button below to sign in. This link will expire in 15 minutes.
|
||||
</p>
|
||||
<table cellpadding="0" cellspacing="0" style="margin:0 0 24px;"><tr><td style="border-radius:8px;background:#1c1917;">
|
||||
<a href="{{ link_url }}" target="_blank"
|
||||
style="display:inline-block;padding:12px 32px;font-size:15px;font-weight:500;color:#ffffff;text-decoration:none;border-radius:8px;">
|
||||
Sign in
|
||||
</a>
|
||||
</td></tr></table>
|
||||
<p style="margin:0 0 8px;font-size:13px;color:#78716c;">Or copy and paste this link into your browser:</p>
|
||||
<p style="margin:0 0 24px;font-size:13px;word-break:break-all;">
|
||||
<a href="{{ link_url }}" style="color:#1c1917;">{{ link_url }}</a>
|
||||
</p>
|
||||
<hr style="border:none;border-top:1px solid #e7e5e4;margin:24px 0;">
|
||||
<p style="margin:0;font-size:12px;color:#a8a29e;">
|
||||
If you did not request this email, you can safely ignore it.
|
||||
</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,8 +0,0 @@
|
||||
Hello,
|
||||
|
||||
Click this link to sign in:
|
||||
{{ link_url }}
|
||||
|
||||
This link will expire in 15 minutes.
|
||||
|
||||
If you did not request this, you can ignore this email.
|
||||
@@ -1,64 +0,0 @@
|
||||
{# New Post/Page + Drafts toggle — shown in aside (desktop + mobile) #}
|
||||
<div class="flex flex-wrap gap-2 px-4 py-3">
|
||||
{% if has_access('blog.new_post') %}
|
||||
{% set new_href = url_for('blog.new_post')|host %}
|
||||
<a
|
||||
href="{{ new_href }}"
|
||||
sx-get="{{ new_href }}"
|
||||
sx-target="#main-panel"
|
||||
sx-select="{{hx_select_search}}"
|
||||
sx-swap="outerHTML"
|
||||
sx-push-url="true"
|
||||
class="px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"
|
||||
title="New Post"
|
||||
>
|
||||
<i class="fa fa-plus mr-1"></i> New Post
|
||||
</a>
|
||||
{% set new_page_href = url_for('blog.new_page')|host %}
|
||||
<a
|
||||
href="{{ new_page_href }}"
|
||||
sx-get="{{ new_page_href }}"
|
||||
sx-target="#main-panel"
|
||||
sx-select="{{hx_select_search}}"
|
||||
sx-swap="outerHTML"
|
||||
sx-push-url="true"
|
||||
class="px-3 py-1 rounded bg-blue-600 text-white text-sm hover:bg-blue-700 transition-colors"
|
||||
title="New Page"
|
||||
>
|
||||
<i class="fa fa-plus mr-1"></i> New Page
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if g.user and (draft_count or drafts) %}
|
||||
{% if drafts %}
|
||||
{% set drafts_off_href = (current_local_href ~ {'drafts': None}|qs)|host %}
|
||||
<a
|
||||
href="{{ drafts_off_href }}"
|
||||
sx-get="{{ drafts_off_href }}"
|
||||
sx-target="#main-panel"
|
||||
sx-select="{{hx_select_search}}"
|
||||
sx-swap="outerHTML"
|
||||
sx-push-url="true"
|
||||
class="px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"
|
||||
title="Hide Drafts"
|
||||
>
|
||||
<i class="fa fa-file-text-o mr-1"></i> 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 }}</span>
|
||||
</a>
|
||||
{% else %}
|
||||
{% set drafts_on_href = (current_local_href ~ {'drafts': '1'}|qs)|host %}
|
||||
<a
|
||||
href="{{ drafts_on_href }}"
|
||||
sx-get="{{ drafts_on_href }}"
|
||||
sx-target="#main-panel"
|
||||
sx-select="{{hx_select_search}}"
|
||||
sx-swap="outerHTML"
|
||||
sx-push-url="true"
|
||||
class="px-3 py-1 rounded bg-amber-600 text-white text-sm hover:bg-amber-700 transition-colors"
|
||||
title="Show Drafts"
|
||||
>
|
||||
<i class="fa fa-file-text-o mr-1"></i> 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 }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user