Compare commits
740 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 37b75effb4 | |||
| a28b96b424 | |||
| 5b4b0b6908 | |||
| b0d090d85f | |||
| ff681a8cf2 | |||
| f0a3289d9e | |||
| dfb0b5d1ba | |||
| 142eda070c | |||
| 9f1dd4e3b8 | |||
| 65041c0cca | |||
| 1557959eb0 | |||
| 98f21a4b05 | |||
| ae13d1b239 | |||
| 59cced35be | |||
| 9b947aeb89 | |||
| 24b40d1284 | |||
| fd34c0d6ec | |||
| debb31ce4c | |||
| fcf9e61172 | |||
| bdc451fb8e | |||
| d372bed860 | |||
| 37ebad91c6 | |||
| ccef4a3f4d | |||
| bbf937c671 | |||
| 12094901a4 | |||
| 8a3b6ab912 | |||
| 6ee5689545 | |||
| 795263c93d | |||
| 5cbed956f6 | |||
| aa5c67d9f5 | |||
| 6b733bc9cf | |||
| 1900d73490 | |||
| 940bf3f103 | |||
| 15392b2841 | |||
| 1de8c37186 | |||
| e2512e3699 | |||
| 5a14fec92d | |||
| b53f58956a | |||
| 0ba26c36ca | |||
| 810c7455fe | |||
| b6a340c7e6 | |||
| 03a4f20124 | |||
| 2a06e93e90 | |||
| 5c05a2fc7d | |||
| 2f3368a7e3 | |||
| 682d79b42d | |||
| 51aa706856 | |||
| 66919b509a | |||
| eed658ccc4 | |||
| d37e4e768b | |||
| a9973a8a07 | |||
| f3418e51ac | |||
| 0eec03d5c7 | |||
| d2b8706b26 | |||
| 73ce4264ae | |||
| c544e14171 | |||
| 1326958098 | |||
| 017decf59f | |||
| ec872eb76d | |||
| 8324e0e3fe | |||
| 15836de95f | |||
| ead13bf17a | |||
| dd0d7e77b3 | |||
| 847d54bff6 | |||
| 49270d800f | |||
| cb287585f2 | |||
| efda0a1cab | |||
| bd558ab34f | |||
| 3e75333b6a | |||
| 1806d0bdfe | |||
| 19619cb57c | |||
| 772cbc62ba | |||
| 98265883ac | |||
| e774dd3ac5 | |||
| eea6431760 | |||
| a9db8d1877 | |||
| 8c5baaba98 | |||
| 8425688160 | |||
| 31cb7f7411 | |||
| fbc3acb2c6 | |||
| 1c34b07eb0 | |||
| dfe217ccd3 | |||
| 3baccf988b | |||
| 59596fcc96 | |||
| ed3e56fb43 | |||
| 4b1e8ae59d | |||
| 0e2d730ec9 | |||
| 247bcf5d44 | |||
| a6b29c38ad | |||
| b914046994 | |||
| 61bed6236c | |||
| 3afffdec03 | |||
| 631a0f86b7 | |||
| 2ec631166b | |||
| 628272e762 | |||
| c543ea3401 | |||
| 19837567cd | |||
| 41405f3f18 | |||
| 0ec55acfc5 | |||
| 93807f7672 | |||
| c9de0867e5 | |||
| 85a72258f5 | |||
| df9947303c | |||
| afd2aa6029 | |||
| cfb66c23dc | |||
| 5e4b06aa86 | |||
| ada2625813 | |||
| c90490b64b | |||
| 26764c5ab2 | |||
| 4c349e00d3 | |||
| 7e19b2fe7f | |||
| 580b49a0a1 | |||
| 4ee72a2113 | |||
| d53d0f6b77 | |||
| b940823d91 | |||
| ad7aad0b2c | |||
| da27a2f5ce | |||
| 267c149eec | |||
| c5407afd48 | |||
| e96946e246 | |||
| 8eea117321 | |||
| e6c80ab7ee | |||
| 8f8e49f749 | |||
| 7fe64c37eb | |||
| 6ac424f378 | |||
| cd285030a5 | |||
| 84748e90d9 | |||
| c2495232cc | |||
| 418781f574 | |||
| c72ddc6b39 | |||
| 1d97791237 | |||
| 25bca76dad | |||
| 278359855c | |||
| afd2174f0e | |||
| 1aacd8a192 | |||
| 2cf03e71d2 | |||
| 9098661d0f | |||
| ed45b2a957 | |||
| 8b058aaa97 | |||
| 0652511716 | |||
| 7f470070d9 | |||
| 03043fc8ba | |||
| a7756ba0ca | |||
| 46fe7bc588 | |||
| 26c753b835 | |||
| dbfeac5e20 | |||
| 3e9b8b531f | |||
| 4cec47b083 | |||
| 3ebf99637d | |||
| 837e3d0a4d | |||
| 4e29c8ecfc | |||
| 70500a4332 | |||
| 2dbbf65e43 | |||
| 6d652e292f | |||
| 026455f4ba | |||
| 39116d33a5 | |||
| 3980aeedd0 | |||
| 82565f2482 | |||
| 0331b9b2a0 | |||
| 672f18329f | |||
| c9702780ec | |||
| a064cd4e37 | |||
| a6d47887d6 | |||
| d19822e5dc | |||
| 109dcd9997 | |||
| c1471a3584 | |||
| f60f5669fc | |||
| 0c53234a14 | |||
| 79ddbf2f21 | |||
| c3871c96ee | |||
| 161e43f58a | |||
| 6884078d03 | |||
| d73c32d8f6 | |||
| e953e6a9ac | |||
| 62565d95a8 | |||
| f17a957d6f | |||
| c1e3d059c9 | |||
| ef0656cf4f | |||
| 7bfd9d003e | |||
| 5f6319c310 | |||
| fe6fa0324f | |||
| 940e1860b7 | |||
| 3244ac7644 | |||
| 4708f27912 | |||
| 5c4bbaa9ab | |||
| 3e93188c1d | |||
| 7f1ba07500 | |||
| f77c3c60d4 | |||
| fa68a9083b | |||
| 336ad848fc | |||
| 6a18644549 | |||
| cc87b93ae4 | |||
| d1f0bb6c56 | |||
| de14683e09 | |||
| 19dbdbeea7 | |||
| d4fb2d7527 | |||
| f4302c8aad | |||
| dd63a8d959 | |||
| 29e09f2ed4 | |||
| c1db0c1410 | |||
| 23f798e1d1 | |||
| b6840078e2 | |||
| bac3c9e641 | |||
| ee12b7d57e | |||
| cf03c49d03 | |||
| 6e1f0836c2 | |||
| 050924458f | |||
| 947c1b9db4 | |||
| 5297572c29 | |||
| ca2bdc3267 | |||
| fe6fc33165 | |||
| f35629cc96 | |||
| 30ecc89ba6 | |||
| 6f51302700 | |||
| af15f46845 | |||
| f5c9628035 | |||
| 57f6badde5 | |||
| c8ae201e62 | |||
| 28eef2a39f | |||
| c3b8ff96de | |||
| 3d76ae9ce5 | |||
| e2d28e285c | |||
| 5ded22aef7 | |||
| d564323c00 | |||
| d29c3b5530 | |||
| 666936f45c | |||
| 15fe572205 | |||
| 8ca1159231 | |||
| d4e9a0aff5 | |||
| cbb8d59639 | |||
| 8784f607cb | |||
| 7758487c9f | |||
| 65707617dd | |||
| 56c91f21d9 | |||
| d14251da72 | |||
| e6f5189448 | |||
| 617946893f | |||
| 355ead1e94 | |||
| 953f8550d7 | |||
| cef01107cb | |||
| 1a93fc611d | |||
| 9f84dd2929 | |||
| 1f487cec33 | |||
| d550972848 | |||
| 5d4c6cae17 | |||
| ac5084e1ee | |||
| c3140aab7e | |||
| b9a596a3b5 | |||
| f19deb1897 | |||
| 115ad2eb87 | |||
| 135678bd12 | |||
| 7bf9295337 | |||
| 51dcaffe0e | |||
| 5469a17a3f | |||
| 2e89669c9b | |||
| c916267200 | |||
| d0dc2fc3d1 | |||
| 88cd754fb5 | |||
| 25b906bee6 | |||
| 762783b8ab | |||
| bf5e458e28 | |||
| 07c7a6935a | |||
| a415d3c691 | |||
| 321f65a802 | |||
| 4c909392e5 | |||
| 6eb9e1cc72 | |||
| 1ab3d2494f | |||
| 8877587309 | |||
| c75c488d3b | |||
| a712d495d7 | |||
| 51e52d76c4 | |||
| cc2d65c18f | |||
| 45ac6dd8c0 | |||
| 589cb18f8c | |||
| c83f78595f | |||
| 373bb3b6a0 | |||
| 77c48bb9d0 | |||
| b26800aa4e | |||
| 902d5fd99e | |||
| 38c27ac5bb | |||
| 1cf92c9a2f | |||
| eef03510af | |||
| 243e9c486a | |||
| 54f271935a | |||
| 945a947824 | |||
| 6b566d721f | |||
| cd50239fd0 | |||
| a4263ba9d4 | |||
| f2aa5cf328 | |||
| 38d47d65f0 | |||
| c6d2fc2f11 | |||
| 5374935022 | |||
| 1c6f4e816d | |||
| 8d99ceba04 | |||
| 71c411a350 | |||
| 5941cc26d8 | |||
| cc38347363 | |||
| 7afedfbb02 | |||
| 8c817cad2b | |||
| ca50fd2808 | |||
| 6101bc2221 | |||
| c677e04dc6 | |||
| 7ff6ce6da2 | |||
| 9076fbcc0b | |||
| 26059556ac | |||
| 8cc0134059 | |||
| 32447b53a7 | |||
| ad4f2dbb72 | |||
| 82ee05f902 | |||
| 0d819b719c | |||
| 77c5af01c9 | |||
| bceaf49bfa | |||
| 69535562d6 | |||
| 3f09e5571b | |||
| 45cf571b21 | |||
| 3bd8ab901e | |||
| 4333522bd6 | |||
| 102ab274c7 | |||
| e53e8e4331 | |||
| 24975513b6 | |||
| 0f65d5d68e | |||
| 7b18e7fb32 | |||
| 1eb8cf9b19 | |||
| ec3afbbd3c | |||
| 2f277c43d9 | |||
| e7c9c0f52f | |||
| 2e206da879 | |||
| ae9291b8d6 | |||
| 87c02652b5 | |||
| 4a5e314aea | |||
| 8a28a97b81 | |||
| 3bcc1cd9e6 | |||
| 5717e67b6d | |||
| 9969079bdd | |||
| 97da66616b | |||
| 83b6dad9e3 | |||
| 2189a5c652 | |||
| 9d05233fe1 | |||
| 6c3ab332c0 | |||
| 9cfbd00728 | |||
| 96acfd9f8e | |||
| 1c118206b3 | |||
| f1233ef16d | |||
| 439ec5f7b9 | |||
| f50a9840f7 | |||
| e7ebf2d831 | |||
| 7e19a6580e | |||
| 957b9d1ffe | |||
| eafc997f01 | |||
| 7b8369f569 | |||
| dd9a91b5d7 | |||
| 8a5e8cc003 | |||
| 55af529c76 | |||
| 5b71c90831 | |||
| 49992490df | |||
| 18a6edd4b6 | |||
| ca55ecbf5f | |||
| 197ea6fbff | |||
| 139f13e0aa | |||
| 3b13e1c292 | |||
| afc56d9fca | |||
| f5988d3af6 | |||
| a2527b8541 | |||
| f4e64811c1 | |||
| 90fed8fb5f | |||
| e9e639b654 | |||
| eea77f30da | |||
| ee47a8bcc7 | |||
| 6f8d1559d9 | |||
| ceb21a7c1f | |||
| 66a4b6b823 | |||
| b79e32fc20 | |||
| fa1eca6867 | |||
| 8ef217c49b | |||
| e7cd93c766 | |||
| c5ccfccf3e | |||
| 414fba39c7 | |||
| 8946152012 | |||
| 1b304567c8 | |||
| b9d3c63fdc | |||
| 82114ea285 | |||
| 9e57f7a567 | |||
| d85a837f4d | |||
| 9f8891395a | |||
| fe899f5287 | |||
| b0b4c27f00 | |||
| b698818791 | |||
| 57bf7e6944 | |||
| 20c82f2fed | |||
| 3cf5a689d6 | |||
| f09f2bf03e | |||
| 2b6f8da0f9 | |||
| a6dd7ab09c | |||
| 244cb37470 | |||
| 0b31804d21 | |||
| ec3bf2d176 | |||
| 3f63a6dcd7 | |||
| 9995571573 | |||
| ef4ab5fc80 | |||
| de73a60a23 | |||
| 0536df82e7 | |||
| 0e2a2818fd | |||
| 0dcd9f3543 | |||
| b38e0c1a08 | |||
| bc775f67d3 | |||
| 34a00dce8f | |||
| 5bdc17a073 | |||
| 6dfe140d13 | |||
| 34639a044d | |||
| 441ffcad76 | |||
| 8eafe38737 | |||
| ca48bceded | |||
| 0dcdfcbd52 | |||
| 70fac26709 | |||
| 9955fc77b7 | |||
| cf415d2b76 | |||
| 7768aebe65 | |||
| eed4f293bc | |||
| 2c521b67b7 | |||
| 7a76c3b86c | |||
| f246e1e438 | |||
| e8e1ca9fae | |||
| f4f1889dce | |||
| 2d2178b172 | |||
| f26d898088 | |||
| a1b00a0f0d | |||
| b065c5350a | |||
| 0d6b131cd0 | |||
| f1a3c91f17 | |||
| 5afa28e523 | |||
| eb95535afa | |||
| 05450e2b7e | |||
| ce1449d21f | |||
| 82fcfd07c5 | |||
| fa1562bdce | |||
| 449a0aa2f7 | |||
| d755dcf68a | |||
| f01ee37280 | |||
| 4f2c06f817 | |||
| 2e033d8ad4 | |||
| 3d5925b603 | |||
| b1e2bfdeec | |||
| b91b742dd8 | |||
| bc85460ea3 | |||
| ff179a5d0d | |||
| c3047ac4d6 | |||
| 3ca1ab44a6 | |||
| 0142d35901 | |||
| 9b54bb700b | |||
| 8f7dbbf3c9 | |||
| d475c8f64f | |||
| c8e6974dc7 | |||
| ee2b625ed5 | |||
| 7ec2879f73 | |||
| cc6a1c6dc7 | |||
| b66a373764 | |||
| 5800b1a624 | |||
| f03d6a173a | |||
| afae09dd6e | |||
| 2041168ca1 | |||
| 4f0d353956 | |||
| 82e16c4a3f | |||
| c082051d44 | |||
| 82a9609f0c | |||
| 637a77af2d | |||
| 34fa756291 | |||
| 70d9eeab78 | |||
| 2da72f1246 | |||
| 83e88745fa | |||
| 188f121cda | |||
| fb8562db7a | |||
| cab9659709 | |||
| a3cf805f0b | |||
| 2256e37e75 | |||
| 6b2cbb0206 | |||
| da1a1e9641 | |||
| ab752d7c93 | |||
| 903b6d204f | |||
| c6542128f9 | |||
| 7182431ce7 | |||
| 340e4bcc67 | |||
| 970872111b | |||
| d932f1edb5 | |||
| d60e3a29d8 | |||
| b32c79b9e2 | |||
| 7bac5ab6aa | |||
| d547725289 | |||
| 155d6424bb | |||
| fea1a10287 | |||
| b466a0c0d8 | |||
| 168d465245 | |||
| c9c15970f8 | |||
| b775cfcbb6 | |||
| f7c27f9d50 | |||
| 10c9b802eb | |||
| 8b6fe7dd47 | |||
| 180ab6dee7 | |||
| 2e9bbeeece | |||
| 166b3be251 | |||
| 53b66eaf96 | |||
| eef8ef2042 | |||
| 4eef953bbb | |||
| 89f08c4852 | |||
| b9c6e9a7ba | |||
| 134bbcc99c | |||
| 8835789a05 | |||
| c35512e8d4 | |||
| 51b5bbbdcd | |||
| c4406f54de | |||
| 2564d2fbfb | |||
| 0fcdf32b2e | |||
| cf84ebcc2b | |||
| c9417777bb | |||
| fa5e2dc09a | |||
| d69613259c | |||
| 4bec52ee55 | |||
| f3d36fbd95 | |||
| 8ae4267edf | |||
| 172e150e2b | |||
| 67c8d01533 | |||
| 0659f55dc7 | |||
| 17917e2997 | |||
| 71c9061e33 | |||
| ffb7b42466 | |||
| 14a67787a9 | |||
| 2da50e2c71 | |||
| a2799155e6 | |||
| 3b7f2b60a2 | |||
| 0ce2a0673a | |||
| 47db59fd7b | |||
| 493f28830c | |||
| 552572040c | |||
| 89f1affc88 | |||
| 9ead5a2236 | |||
| ad5bb374c0 | |||
| 14dd32334e | |||
| c174ff0515 | |||
| c4d511fd09 | |||
| 6f28a09aa4 | |||
| 05ee3deae4 | |||
| 90de8252e2 | |||
| 5fb2fa4189 | |||
| e08d09213b | |||
| f24332719f | |||
| b87f6ba7c9 | |||
| 337f1acb7a | |||
| 7e3612fd38 | |||
| 64ea7dc147 | |||
| f46ef0d219 | |||
| d44fa1d5fa | |||
| fb6b25f3ed | |||
| a08d4adb4f | |||
| d48a0e5d31 | |||
| e02e125806 | |||
| cf63623fd6 | |||
| debe8f72c2 | |||
| 24822a007d | |||
| 31f7adec35 | |||
| 1cdc7613e6 | |||
| ec290bb5a4 | |||
| 9b26faf71a | |||
| c279797ba9 | |||
| 68af1da97e | |||
| 77e16b51c2 | |||
| 37ccdfbdda | |||
| c8b327e457 | |||
| 7ec5af740a | |||
| 64a7683bfb | |||
| 033527af39 | |||
| 0c2074d7aa | |||
| e12c1ea01e | |||
| d656027a20 | |||
| 777ebcbb02 | |||
| f0bca08be3 | |||
| 63c814c244 | |||
| 9354c1399d | |||
| d713a738ee | |||
| 7b6bf964cb | |||
| 0222778aab | |||
| 75b5944d12 | |||
| 242bfac96b | |||
| 1aa635783b | |||
| 061c8bad3b | |||
| 74b534a2a7 | |||
| a0687ebb6e | |||
| cc407f38fb | |||
| 5f64d58edf | |||
| b1942fdcb2 | |||
| 373e19f447 | |||
| e8c845f67a | |||
| a2f51f0d81 | |||
| bd6e656d73 | |||
| 3f785d3d9e | |||
| 56f206b7d9 | |||
| e8e7024b7c | |||
| 88bf0a57e2 | |||
| 1cc14b74a6 | |||
| f7c329b3de | |||
| 52a77ad1ae | |||
| c315760a43 | |||
| bd358c581f | |||
| fafd9e3121 | |||
| 079b9a20f0 | |||
| 09fe7d2f06 | |||
| 589fa52505 | |||
| ce999104af | |||
| fcb2bce35e | |||
| 26927537a6 | |||
| 17a378bb40 | |||
| 206e6e9e7d | |||
| f3d9c3fd80 | |||
| df767ff3b2 | |||
| 1d61867153 | |||
| 364a6c3193 | |||
| 4d9d385537 | |||
| e3dc3b5439 | |||
| c33a5a499c | |||
| c3446e4f53 | |||
| f9c40ce8f2 | |||
| 032c56468e | |||
| 0d304974ee | |||
| 95be17a2db | |||
| 2a45def43d | |||
| 4727631709 | |||
| 2e635e16ed | |||
| b8b1432f20 | |||
| 7c2de6fa7f | |||
| 9b196ec3be | |||
| ee19b13461 | |||
| 8673a79bb1 | |||
| a624329e21 | |||
| ae5f30ff1e | |||
| 332e7a21ab | |||
| 0c0ee34ede | |||
| 9bf8415a43 | |||
| 360deb7257 | |||
| 638b83d26b | |||
| 9172fdd535 | |||
| 9773cf3013 | |||
| 39f8b0db4f | |||
| ae9cfb444c | |||
| 4ba72ad3a8 | |||
| 6c1f9dfbf7 | |||
| 4554644f87 | |||
| eb7a03328e | |||
| bee374c458 | |||
| 80d1ed6a05 | |||
| 82a12de20f | |||
| 01a671f262 | |||
| 2b9f3758dc | |||
| f4aace299c | |||
| 05737af18e | |||
| c83ea713c3 | |||
| 91bd239587 | |||
| cccf4f4bb3 | |||
| 9d3bb4f622 | |||
| 58528edafe | |||
| f1167e964c | |||
| ac99e3ced1 | |||
| edaf8611b2 | |||
| 6551e3fecb | |||
| 5bfac5af0e | |||
| fb168da21e | |||
| a9457fc83b | |||
| c360df31c5 | |||
| aeb4d2098c | |||
| 7ff1900f80 | |||
| 232f1650df | |||
| 6ca5d5bba8 | |||
| 98686bebda | |||
| b0b7cc859e | |||
| 1fcf222b68 | |||
| 2f483b0d15 | |||
| 597f7489ff | |||
| bb6548370e | |||
| 6498ba70ce | |||
| eeeecc6d40 | |||
| c80b51afb2 | |||
| b42986fbe1 | |||
| 3bc8919731 | |||
| 246a7375c6 | |||
| 773a3e5f5e | |||
| 0d520e54d9 | |||
| bc15b64f6e | |||
| 995c19de43 | |||
| e342b20cf0 | |||
| 368f5d228f | |||
| d75775bade | |||
| 4e28e2b3bc | |||
| 84d6ebb872 | |||
| 76daa1c804 | |||
| 9d4f6cfc61 | |||
| f4e3895b7a | |||
| bfdbe269f7 | |||
| 3badab67e5 | |||
| 4388d22807 | |||
| 5bcf4f0330 | |||
| d0adfc07ac | |||
| fe8509165c | |||
| f090d4d24f | |||
| ae7faf4ddf | |||
| 4b9eaec582 | |||
| 09d20e005b | |||
| 4919f7ea72 | |||
| 1ac6691c10 | |||
| c552d3fd15 | |||
| 2f02f6d53f | |||
| c7015ee278 | |||
| b12b854912 | |||
| eaed8617e5 | |||
| ee9a9dd063 | |||
| 5d34619c67 | |||
| f57ac344e2 | |||
| 0ff2b04b09 | |||
| a09cc57212 | |||
| 3a6022be8a | |||
| 3fb18d1390 | |||
| 2310043891 | |||
| 878ddee92b | |||
| 63775f04b9 | |||
| 036a875bb8 | |||
| db1eff7a99 | |||
| f1c785445f | |||
| d9b4652f51 | |||
| 1370643172 | |||
| 8a57a44f02 | |||
| 913252329a | |||
| 8380a045d3 | |||
| a8d8e26e20 | |||
| 512f77589b | |||
| 3c361daf3d | |||
| e8b75b9c5f | |||
| a2f63832cd | |||
| 52329f2a78 | |||
| 1c0c06fa2e | |||
| 9f0b0afaaf | |||
| fba7d3474b | |||
| 2351a2bd90 | |||
| 648131ebee | |||
| af0886a486 |
4
.env.example
Normal file
@@ -0,0 +1,4 @@
|
||||
# Kopiere diese Datei zu .env und passe die Werte an
|
||||
# .env wird NICHT ins Git eingecheckt
|
||||
|
||||
JWT_SECRET=ersetze-mich-mit-einem-langen-zufaelligen-string
|
||||
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
.env
|
||||
*.db
|
||||
*.db-bak
|
||||
dist/
|
||||
17
Dockerfile
@@ -4,15 +4,30 @@ WORKDIR /build
|
||||
COPY frontend/package.json ./
|
||||
RUN npm install
|
||||
COPY frontend/ ./
|
||||
# version.txt VOR dem Build generieren – Unix-Timestamp (Sekunden)
|
||||
# Dieser Wert wird von vite.config.js als __BUILD_TIME__ ins Bundle gebacken
|
||||
# UND vom Backend als /api/build-time ausgeliefert → exakt derselbe Wert
|
||||
RUN date -u +"%s" > ./version.txt
|
||||
# Build-Version in sw.js einsetzen damit der Browser bei jedem Deploy einen neuen SW erkennt
|
||||
RUN BUILD_V=$(cat ./version.txt) && sed -i "s/__BUILD_VERSION__/${BUILD_V}/g" ./public/sw.js
|
||||
RUN npm run build
|
||||
|
||||
# ── Stage 2: Express liefert API + React in einem Container ──────────────────
|
||||
FROM node:20-alpine
|
||||
# Puppeteer/Chromium für KöPi Web-Scraping
|
||||
RUN apk add --no-cache tzdata python3 make g++ chromium nss freetype harfbuzz ca-certificates ttf-freefont
|
||||
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
|
||||
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
|
||||
# Zeitzone fest auf Europe/Berlin setzen, damit Server und Browser (DE) immer
|
||||
# dieselbe lokale Zeit berechnen – inkl. automatischer Sommer-/Winterzeit-Umstellung
|
||||
ENV TZ=Europe/Berlin
|
||||
RUN cp /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||
WORKDIR /app
|
||||
COPY backend/package.json ./
|
||||
RUN npm install --production
|
||||
COPY backend/ ./
|
||||
COPY version.txt ./version.txt
|
||||
# version.txt aus dem Build-Stage kopieren (selber Wert wie im JS-Bundle)
|
||||
COPY --from=builder /build/version.txt ./version.txt
|
||||
COPY --from=builder /build/dist ./public
|
||||
EXPOSE 4000
|
||||
CMD ["node", "src/index.js"]
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
"better-sqlite3": "^9.4.3",
|
||||
"express": "^4.18.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^1.4.5-lts.1"
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"ws": "^8.17.1",
|
||||
"puppeteer-core": "^22.0.0",
|
||||
"mqtt": "^5.10.1"
|
||||
}
|
||||
}
|
||||
36
backend/src/crypto-helper.js
Normal file
@@ -0,0 +1,36 @@
|
||||
// ── Verschlüsselungs-Helper für sensible gespeicherte Zugangsdaten ──────────
|
||||
// Nutzt AES-256-GCM. Der Schlüssel wird aus JWT_SECRET abgeleitet (kein
|
||||
// zusätzlicher Env-Var nötig) — ausreichend für den Zweck hier (Zugangsdaten
|
||||
// liegen nicht mehr im Klartext in der SQLite-DB), ersetzt aber kein
|
||||
// dediziertes Secret-Management für hochsensible Fälle.
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
const KEY = crypto.createHash('sha256')
|
||||
.update(process.env.JWT_SECRET || 'dickendock-fallback-key-bitte-JWT_SECRET-setzen')
|
||||
.digest();
|
||||
|
||||
function encrypt(text) {
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', KEY, iv);
|
||||
const enc = Buffer.concat([cipher.update(String(text), 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return Buffer.concat([iv, tag, enc]).toString('base64');
|
||||
}
|
||||
|
||||
function decrypt(b64) {
|
||||
if (!b64) return null;
|
||||
try {
|
||||
const buf = Buffer.from(b64, 'base64');
|
||||
const iv = buf.subarray(0, 12);
|
||||
const tag = buf.subarray(12, 28);
|
||||
const enc = buf.subarray(28);
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', KEY, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(enc), decipher.final()]).toString('utf8');
|
||||
} catch {
|
||||
return null; // falsches/verändertes Secret oder korrupte Daten
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { encrypt, decrypt };
|
||||
@@ -40,6 +40,73 @@ db.exec(`
|
||||
);
|
||||
|
||||
-- Tool: 3D-Kalkulator
|
||||
CREATE TABLE IF NOT EXISTS calendar_feeds (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
color TEXT NOT NULL DEFAULT '#4ecdc4',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS folders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
parent_id INTEGER REFERENCES folders(id) ON DELETE CASCADE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS folder_shares (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
folder_id INTEGER NOT NULL REFERENCES folders(id) ON DELETE CASCADE,
|
||||
shared_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
shared_with INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(folder_id, shared_with)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
filename TEXT NOT NULL,
|
||||
originalname TEXT NOT NULL,
|
||||
mimetype TEXT NOT NULL DEFAULT 'application/octet-stream',
|
||||
size INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS file_shares (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
|
||||
shared_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
shared_with INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(file_id, shared_with)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS admin_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
bemerkung TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'warteliste',
|
||||
custom_price REAL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS order_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
|
||||
calculation_id INTEGER REFERENCES calculations(id) ON DELETE SET NULL,
|
||||
calc_name TEXT NOT NULL,
|
||||
preis_freundschaft REAL NOT NULL,
|
||||
preis_normal REAL NOT NULL,
|
||||
preis_auftrag REAL NOT NULL,
|
||||
stueckzahl INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS calculations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
@@ -62,10 +129,509 @@ db.exec(`
|
||||
`);
|
||||
|
||||
// Standard-Admin beim ersten Start anlegen
|
||||
if (!db.prepare('SELECT id FROM users WHERE username = ?').get('admin')) {
|
||||
db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)')
|
||||
.run('admin', bcrypt.hashSync('admin123', 12), 'admin');
|
||||
console.log('✅ Admin angelegt: admin / admin123 → Bitte Passwort sofort ändern!');
|
||||
// Default admin settings
|
||||
const settingDefaults = [
|
||||
['file_max_size_mb', '50'],
|
||||
['file_max_count', '20'],
|
||||
['file_allowed_ext', '.pdf,.jpg,.jpeg,.png,.gif,.zip,.txt,.docx,.xlsx,.mp4,.stl,.3mf'],
|
||||
];
|
||||
for (const [key, value] of settingDefaults) {
|
||||
if (!db.prepare('SELECT key FROM admin_settings WHERE key=?').get(key))
|
||||
db.prepare('INSERT INTO admin_settings (key,value) VALUES (?,?)').run(key, value);
|
||||
}
|
||||
|
||||
if (db.prepare('SELECT COUNT(*) c FROM users').get().c === 0) {
|
||||
db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)')
|
||||
.run('admin', bcrypt.hashSync('admin123', 12), 'admin');
|
||||
console.log('✅ Erster Start: Admin angelegt: admin / admin123 → Bitte Passwort sofort ändern!');
|
||||
}
|
||||
|
||||
// ── Migrationen (werden bei jedem Start geprüft) ─────────────────────────────
|
||||
const cols = db.prepare("PRAGMA table_info(calculations)").all().map(r => r.name);
|
||||
if (!cols.includes('image')) db.exec("ALTER TABLE calculations ADD COLUMN image TEXT");
|
||||
if (!cols.includes('bemerkung')) db.exec("ALTER TABLE calculations ADD COLUMN bemerkung TEXT NOT NULL DEFAULT ''");
|
||||
|
||||
// Migrationen orders
|
||||
const ordCols = db.prepare("PRAGMA table_info(orders)").all().map(r => r.name);
|
||||
if (!ordCols.includes('bezahlt')) db.exec("ALTER TABLE orders ADD COLUMN bezahlt INTEGER NOT NULL DEFAULT 0");
|
||||
if (!ordCols.includes('bezahlt_am')) db.exec("ALTER TABLE orders ADD COLUMN bezahlt_am DATETIME");
|
||||
if (!ordCols.includes('abgeholt')) db.exec("ALTER TABLE orders ADD COLUMN abgeholt INTEGER NOT NULL DEFAULT 0");
|
||||
if (!ordCols.includes('abgeholt_am'))db.exec("ALTER TABLE orders ADD COLUMN abgeholt_am DATETIME");
|
||||
|
||||
// Migrationen order_items
|
||||
// files migrations
|
||||
const fileCols = db.prepare("PRAGMA table_info(files)").all().map(r => r.name);
|
||||
if (!fileCols.includes('folder_id')) db.exec("ALTER TABLE files ADD COLUMN folder_id INTEGER REFERENCES folders(id) ON DELETE SET NULL");
|
||||
|
||||
// folder_shares migrations (only if table exists)
|
||||
const fsTableExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='folder_shares'").get();
|
||||
if (fsTableExists) {
|
||||
const fsCols = db.prepare("PRAGMA table_info(folder_shares)").all().map(r => r.name);
|
||||
if (!fsCols.includes('created_at')) db.exec("ALTER TABLE folder_shares ADD COLUMN created_at DATETIME DEFAULT NULL");
|
||||
}
|
||||
|
||||
const oiCols = db.prepare("PRAGMA table_info(order_items)").all().map(r => r.name);
|
||||
if (!oiCols.includes('stunden')) db.exec("ALTER TABLE order_items ADD COLUMN stunden REAL NOT NULL DEFAULT 0");
|
||||
if (!oiCols.includes('custom_price')) db.exec("ALTER TABLE order_items ADD COLUMN custom_price REAL");
|
||||
if (!oiCols.includes('status')) db.exec("ALTER TABLE order_items ADD COLUMN status TEXT NOT NULL DEFAULT 'warteliste'");
|
||||
if (!oiCols.includes('qty_warteliste')) db.exec("ALTER TABLE order_items ADD COLUMN qty_warteliste INTEGER NOT NULL DEFAULT 0");
|
||||
if (!oiCols.includes('qty_in_arbeit')) db.exec("ALTER TABLE order_items ADD COLUMN qty_in_arbeit INTEGER NOT NULL DEFAULT 0");
|
||||
if (!oiCols.includes('qty_fertig')) db.exec("ALTER TABLE order_items ADD COLUMN qty_fertig INTEGER NOT NULL DEFAULT 0");
|
||||
// Bestehende Einträge: alle Stücke auf Warteliste setzen falls noch keine Qtys gesetzt
|
||||
db.exec("UPDATE order_items SET qty_warteliste=stueckzahl WHERE qty_warteliste=0 AND qty_in_arbeit=0 AND qty_fertig=0");
|
||||
|
||||
// Migration: file_shares – Passwort und Zugriffszeit
|
||||
const fShareCols = db.prepare("PRAGMA table_info(file_shares)").all().map(r => r.name);
|
||||
if (fShareCols.length && !fShareCols.includes('password_hash'))
|
||||
db.exec("ALTER TABLE file_shares ADD COLUMN password_hash TEXT DEFAULT NULL");
|
||||
if (fShareCols.length && !fShareCols.includes('accessed_at'))
|
||||
db.exec("ALTER TABLE file_shares ADD COLUMN accessed_at DATETIME DEFAULT NULL");
|
||||
|
||||
// ── Nachrichten-Migrationen ───────────────────────────────────────────────────
|
||||
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='messages'").get()) {
|
||||
db.exec(`
|
||||
CREATE TABLE messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sender_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
recipient_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
encrypted_content TEXT NOT NULL,
|
||||
iv TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
read_by_recipient INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
`);
|
||||
}
|
||||
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='user_keys'").get()) {
|
||||
db.exec(`
|
||||
CREATE TABLE user_keys (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
public_key TEXT NOT NULL,
|
||||
updated_at DATETIME DEFAULT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='pushover_settings'").get()) {
|
||||
db.exec(`
|
||||
CREATE TABLE pushover_settings (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
user_key TEXT NOT NULL,
|
||||
app_token TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
// Migration: Soft-Delete-Spalten entfernen (nur Nachrichten übernehmen die noch sichtbar waren)
|
||||
const msgCols = db.prepare("PRAGMA table_info(messages)").all().map(r => r.name);
|
||||
if (msgCols.includes('deleted_by_sender')) {
|
||||
db.exec(`
|
||||
CREATE TABLE messages_v2 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sender_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
recipient_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
encrypted_content TEXT NOT NULL,
|
||||
iv TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT NULL,
|
||||
read_by_recipient INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
INSERT INTO messages_v2 (id, sender_id, recipient_id, encrypted_content, iv, created_at, read_by_recipient)
|
||||
SELECT id, sender_id, recipient_id, encrypted_content, iv, created_at,
|
||||
COALESCE(read_by_recipient, 0)
|
||||
FROM messages
|
||||
WHERE deleted_by_sender = 0 AND deleted_by_recipient = 0;
|
||||
DROP TABLE messages;
|
||||
ALTER TABLE messages_v2 RENAME TO messages;
|
||||
`);
|
||||
console.log('✅ Migration: messages → Hard-Delete (Soft-Delete-Spalten entfernt)');
|
||||
}
|
||||
// Falls read_by_recipient noch fehlt (sehr alte Installation)
|
||||
const msgCols2 = db.prepare("PRAGMA table_info(messages)").all().map(r => r.name);
|
||||
if (msgCols2.length && !msgCols2.includes('read_by_recipient')) {
|
||||
db.exec("ALTER TABLE messages ADD COLUMN read_by_recipient INTEGER NOT NULL DEFAULT 0");
|
||||
}
|
||||
|
||||
// User-Preferences (JSON-Blob)
|
||||
const userCols2 = db.prepare("PRAGMA table_info(users)").all().map(r => r.name);
|
||||
if (!userCols2.includes('preferences'))
|
||||
db.exec("ALTER TABLE users ADD COLUMN preferences TEXT DEFAULT NULL");
|
||||
if (!userCols2.includes('last_active_at'))
|
||||
db.exec("ALTER TABLE users ADD COLUMN last_active_at DATETIME DEFAULT NULL");
|
||||
if (!userCols2.includes('hidden'))
|
||||
db.exec("ALTER TABLE users ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0");
|
||||
if (!userCols2.includes('chat_active_at'))
|
||||
db.exec("ALTER TABLE users ADD COLUMN chat_active_at DATETIME DEFAULT NULL");
|
||||
if (!userCols2.includes('hidden_tools'))
|
||||
db.exec("ALTER TABLE users ADD COLUMN hidden_tools TEXT NOT NULL DEFAULT '[]'");
|
||||
|
||||
// ── Login-Sicherheit ──────────────────────────────────────────────────────────
|
||||
// Spalten für Account-Lockout in users-Tabelle
|
||||
const userCols = db.prepare("PRAGMA table_info(users)").all().map(r => r.name);
|
||||
if (!userCols.includes('failed_attempts'))
|
||||
db.exec("ALTER TABLE users ADD COLUMN failed_attempts INTEGER NOT NULL DEFAULT 0");
|
||||
if (!userCols.includes('locked_until'))
|
||||
db.exec("ALTER TABLE users ADD COLUMN locked_until DATETIME DEFAULT NULL");
|
||||
if (!userCols.includes('last_failed_at'))
|
||||
db.exec("ALTER TABLE users ADD COLUMN last_failed_at DATETIME DEFAULT NULL");
|
||||
|
||||
// Login-Fehlversuche Log
|
||||
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='login_attempts'").get()) {
|
||||
db.exec(`
|
||||
CREATE TABLE login_attempts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL,
|
||||
ip TEXT,
|
||||
success INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
// Default-Einstellungen für Login-Schutz
|
||||
const loginDefaults = [
|
||||
['login_max_attempts', '5'],
|
||||
['login_lockout_minutes', '30'],
|
||||
];
|
||||
for (const [k, v] of loginDefaults) {
|
||||
if (!db.prepare('SELECT value FROM admin_settings WHERE key=?').get(k))
|
||||
db.prepare('INSERT INTO admin_settings (key, value) VALUES (?, ?)').run(k, v);
|
||||
}
|
||||
|
||||
// ── Ideen-Board ───────────────────────────────────────────────────────────────
|
||||
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='board_items'").get()) {
|
||||
db.exec(`
|
||||
CREATE TABLE board_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type TEXT NOT NULL CHECK(type IN ('roadmap','wish')),
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
promoted_from_wish INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
// Migration: promoted_from_wish Spalte
|
||||
const boardCols = db.prepare("PRAGMA table_info(board_items)").all().map(r => r.name);
|
||||
if (boardCols.length && !boardCols.includes('promoted_from_wish'))
|
||||
db.exec("ALTER TABLE board_items ADD COLUMN promoted_from_wish INTEGER NOT NULL DEFAULT 0");
|
||||
if (boardCols.length && !boardCols.includes('promoted_at'))
|
||||
db.exec("ALTER TABLE board_items ADD COLUMN promoted_at DATETIME DEFAULT NULL");
|
||||
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='board_reads'").get()) {
|
||||
db.exec(`
|
||||
CREATE TABLE board_reads (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
last_read DATETIME DEFAULT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
// Migration: Pushover retry/expire
|
||||
const poCols = db.prepare("PRAGMA table_info(pushover_settings)").all().map(r => r.name);
|
||||
if (poCols.length && !poCols.includes('retry'))
|
||||
db.exec("ALTER TABLE pushover_settings ADD COLUMN retry INTEGER DEFAULT NULL");
|
||||
if (poCols.length && !poCols.includes('expire'))
|
||||
db.exec("ALTER TABLE pushover_settings ADD COLUMN expire INTEGER DEFAULT NULL");
|
||||
|
||||
// ── Changelog ─────────────────────────────────────────────────────────────────
|
||||
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='changelog'").get()) {
|
||||
db.exec(`
|
||||
CREATE TABLE changelog (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
version TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
build_time TEXT DEFAULT NULL,
|
||||
created_at DATETIME DEFAULT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
// Migration: build_time Spalte
|
||||
const clCols = db.prepare("PRAGMA table_info(changelog)").all().map(r => r.name);
|
||||
if (clCols.length && !clCols.includes('build_time'))
|
||||
db.exec("ALTER TABLE changelog ADD COLUMN build_time TEXT DEFAULT NULL");
|
||||
|
||||
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='changelog_reads'").get()) {
|
||||
db.exec(`
|
||||
CREATE TABLE changelog_reads (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
last_read DATETIME DEFAULT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
// ── Push-Zeitplaner ───────────────────────────────────────────────────────────
|
||||
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='push_schedules'").get()) {
|
||||
db.exec(`
|
||||
CREATE TABLE push_schedules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
message TEXT NOT NULL,
|
||||
scheduled_at DATETIME NOT NULL,
|
||||
sent INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
// ── Link-Liste ────────────────────────────────────────────────────────────────
|
||||
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='link_list'").get()) {
|
||||
db.exec(`
|
||||
CREATE TABLE link_list (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
icon TEXT NOT NULL DEFAULT '🔗',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME DEFAULT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='link_list_shares'").get()) {
|
||||
db.exec(`
|
||||
CREATE TABLE link_list_shares (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
link_id INTEGER NOT NULL REFERENCES link_list(id) ON DELETE CASCADE,
|
||||
shared_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
shared_with INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at DATETIME DEFAULT NULL,
|
||||
UNIQUE(link_id, shared_with)
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
// ── Kalkulator-Shares ─────────────────────────────────────────────────────────
|
||||
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='calculation_shares'").get()) {
|
||||
db.exec(`
|
||||
CREATE TABLE calculation_shares (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
calc_id INTEGER NOT NULL REFERENCES calculations(id) ON DELETE CASCADE,
|
||||
shared_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
shared_with INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at DATETIME DEFAULT NULL,
|
||||
UNIQUE(calc_id, shared_with)
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
// ── Code-Schnipsel ────────────────────────────────────────────────────────────
|
||||
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='snippets'").get()) {
|
||||
db.exec(`
|
||||
CREATE TABLE snippets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
code TEXT NOT NULL DEFAULT '',
|
||||
language TEXT NOT NULL DEFAULT 'text',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME DEFAULT NULL,
|
||||
updated_at DATETIME DEFAULT NULL
|
||||
);
|
||||
CREATE TABLE snippet_tags (
|
||||
snippet_id INTEGER NOT NULL REFERENCES snippets(id) ON DELETE CASCADE,
|
||||
tag TEXT NOT NULL,
|
||||
PRIMARY KEY (snippet_id, tag)
|
||||
);
|
||||
CREATE TABLE snippet_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
snippet_id INTEGER NOT NULL REFERENCES snippets(id) ON DELETE CASCADE,
|
||||
code TEXT NOT NULL,
|
||||
language TEXT NOT NULL DEFAULT 'text',
|
||||
saved_at DATETIME DEFAULT NULL
|
||||
);
|
||||
CREATE TABLE snippet_shares (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
snippet_id INTEGER NOT NULL REFERENCES snippets(id) ON DELETE CASCADE,
|
||||
shared_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
shared_with INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at DATETIME DEFAULT NULL,
|
||||
UNIQUE(snippet_id, shared_with)
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
// ── Link-Liste Ordner + Erweiterungen ─────────────────────────────────────────
|
||||
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='link_list_folders'").get()) {
|
||||
db.exec(`
|
||||
CREATE TABLE link_list_folders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
icon TEXT NOT NULL DEFAULT '📁',
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
in_quickaccess INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
// link_list Spalten nachrüsten
|
||||
{
|
||||
const cols = db.pragma('table_info(link_list)').map(c => c.name);
|
||||
if (!cols.includes('folder_id')) db.exec('ALTER TABLE link_list ADD COLUMN folder_id INTEGER DEFAULT NULL');
|
||||
if (!cols.includes('sort_order')) db.exec('ALTER TABLE link_list ADD COLUMN sort_order INTEGER DEFAULT 0');
|
||||
if (!cols.includes('in_quickaccess')) db.exec('ALTER TABLE link_list ADD COLUMN in_quickaccess INTEGER DEFAULT 0');
|
||||
}
|
||||
|
||||
|
||||
// ── QR-Codes ──────────────────────────────────────────────────────────────────
|
||||
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='qr_codes'").get()) {
|
||||
db.exec(`
|
||||
CREATE TABLE qr_codes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
url TEXT NOT NULL,
|
||||
size INTEGER NOT NULL DEFAULT 256,
|
||||
fg_color TEXT NOT NULL DEFAULT '#000000',
|
||||
bg_color TEXT NOT NULL DEFAULT '#ffffff',
|
||||
margin INTEGER NOT NULL DEFAULT 4,
|
||||
dot_style TEXT NOT NULL DEFAULT 'square',
|
||||
corner_style TEXT NOT NULL DEFAULT 'square',
|
||||
caption TEXT NOT NULL DEFAULT '',
|
||||
caption_pos TEXT NOT NULL DEFAULT 'bottom',
|
||||
caption_color TEXT NOT NULL DEFAULT '#000000',
|
||||
caption_size INTEGER NOT NULL DEFAULT 14,
|
||||
created_at DATETIME DEFAULT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
// ── Link-Folder Shares ────────────────────────────────────────────────────────
|
||||
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='link_folder_shares'").get()) {
|
||||
db.exec(`
|
||||
CREATE TABLE link_folder_shares (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
folder_id INTEGER NOT NULL REFERENCES link_list_folders(id) ON DELETE CASCADE,
|
||||
shared_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
shared_with INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at DATETIME DEFAULT NULL,
|
||||
UNIQUE(folder_id, shared_with)
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
// ── Kalender-Event-Cache für Suche ────────────────────────────────────────────
|
||||
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='calendar_event_cache'").get()) {
|
||||
db.exec(`
|
||||
CREATE TABLE calendar_event_cache (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
feed_id INTEGER NOT NULL REFERENCES calendar_feeds(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
location TEXT NOT NULL DEFAULT '',
|
||||
start_dt TEXT,
|
||||
end_dt TEXT,
|
||||
synced_at DATETIME DEFAULT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
// short_url auf upload_shares
|
||||
{
|
||||
const cols = db.pragma('table_info(upload_shares)').map(c => c.name);
|
||||
if (!cols.includes('short_url'))
|
||||
db.exec('ALTER TABLE upload_shares ADD COLUMN short_url TEXT DEFAULT NULL');
|
||||
}
|
||||
|
||||
|
||||
// ── Movie-Favoriten ───────────────────────────────────────────────────────────
|
||||
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='movie_favorites'").get()) {
|
||||
db.exec(`
|
||||
CREATE TABLE movie_favorites (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
tmdb_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
poster_path TEXT NOT NULL DEFAULT '',
|
||||
release_date_de TEXT NOT NULL DEFAULT '',
|
||||
added_at DATETIME DEFAULT NULL,
|
||||
UNIQUE(user_id, tmdb_id)
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
// movie_favorites neue Spalten (Migration)
|
||||
{
|
||||
const cols = db.pragma('table_info(movie_favorites)').map(c => c.name);
|
||||
if (!cols.includes('release_date'))
|
||||
db.exec("ALTER TABLE movie_favorites ADD COLUMN release_date TEXT NOT NULL DEFAULT ''");
|
||||
if (!cols.includes('genres'))
|
||||
db.exec("ALTER TABLE movie_favorites ADD COLUMN genres TEXT NOT NULL DEFAULT '[]'");
|
||||
if (!cols.includes('fsk'))
|
||||
db.exec("ALTER TABLE movie_favorites ADD COLUMN fsk TEXT NOT NULL DEFAULT ''");
|
||||
if (!cols.includes('acknowledged'))
|
||||
db.exec("ALTER TABLE movie_favorites ADD COLUMN acknowledged INTEGER NOT NULL DEFAULT 0");
|
||||
if (!cols.includes('acknowledged_at'))
|
||||
db.exec("ALTER TABLE movie_favorites ADD COLUMN acknowledged_at DATETIME DEFAULT NULL");
|
||||
if (!cols.includes('user_notified'))
|
||||
db.exec("ALTER TABLE movie_favorites ADD COLUMN user_notified INTEGER NOT NULL DEFAULT 0");
|
||||
if (!cols.includes('media_type'))
|
||||
db.exec("ALTER TABLE movie_favorites ADD COLUMN media_type TEXT NOT NULL DEFAULT 'movie'");
|
||||
}
|
||||
|
||||
// xREL Badge Cache (persistent, überlebt Container-Restarts)
|
||||
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='xrel_cache'").get()) {
|
||||
db.exec(`
|
||||
CREATE TABLE xrel_cache (
|
||||
cache_key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
// TMDb-Token Default
|
||||
if (!db.prepare("SELECT value FROM admin_settings WHERE key='tmdb_token'").get())
|
||||
db.prepare("INSERT INTO admin_settings (key,value) VALUES ('tmdb_token','')").run();
|
||||
|
||||
// Öffentliche Datei/Ordner-Freigabe Links
|
||||
// CREATE TABLE IF NOT EXISTS – sicher bei jedem Start
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS public_file_shares (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
file_id INTEGER REFERENCES files(id) ON DELETE CASCADE,
|
||||
folder_id INTEGER REFERENCES folders(id) ON DELETE CASCADE,
|
||||
label TEXT,
|
||||
password_hash TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
download_count INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
`);
|
||||
|
||||
// 3D-Druck Ausgaben (Haushaltsbuch)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS expenses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
date TEXT NOT NULL,
|
||||
category TEXT NOT NULL DEFAULT 'Sonstiges',
|
||||
description TEXT NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// MakerWorld: Empfangs-Token pro Nutzer für den Bookmarklet-Ansatz (kein
|
||||
// Login/Cookie-Handling mehr nötig — der Nutzer schickt seine eigenen, schon
|
||||
// im Browser geladenen Daten per Klick auf ein Lesezeichen an DickenDock)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS makerworld_ingest_tokens (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Zuletzt per Bookmarklet empfangene Statistik — persistiert, damit ein
|
||||
// Container-Neustart die zuletzt bekannten Zahlen nicht verwirft
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS makerworld_stats (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
data_json TEXT NOT NULL,
|
||||
fetched_at INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
module.exports = db;
|
||||
|
||||
@@ -2,19 +2,96 @@ const express = require('express');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
require('./db'); // Datenbank + Tabellen initialisieren
|
||||
require('./db');
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
// Hinter Nginx Proxy Manager (SSL-Terminierung) — sonst meldet req.protocol
|
||||
// immer "http", auch wenn der Nutzer über https zugreift (z.B. wichtig für
|
||||
// das MakerWorld-Bookmarklet: sonst mixed-content-blockiert der Browser den
|
||||
// fetch()-Call stillschweigend, weil die generierte Origin auf http:// zeigt)
|
||||
app.set('trust proxy', true);
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
|
||||
// ── Core-Routen ───────────────────────────────────────────────────────────────
|
||||
// ── Aktivitäts-Tracking: nur bei echten Aktionen (POST/PUT/DELETE) ─────────────
|
||||
app.use((req, res, next) => {
|
||||
if (['POST','PUT','DELETE'].includes(req.method)) {
|
||||
const auth = req.headers.authorization;
|
||||
if (auth?.startsWith('Bearer ')) {
|
||||
try {
|
||||
const jwt = require('jsonwebtoken');
|
||||
const p = jwt.verify(auth.slice(7), process.env.JWT_SECRET || 'dev-secret');
|
||||
if (p?.id) {
|
||||
// Prüfen ob User vorher >15 Min inaktiv war → "gerade online gegangen"
|
||||
const user = db.prepare('SELECT last_active_at, role FROM users WHERE id=?').get(p.id);
|
||||
const wasInactive = !user?.last_active_at ||
|
||||
(Date.now() - new Date(user.last_active_at).getTime()) > 15 * 60 * 1000;
|
||||
|
||||
db.prepare("UPDATE users SET last_active_at=datetime('now','localtime') WHERE id=?").run(p.id);
|
||||
|
||||
// Pushover an alle Admins (außer wenn User selbst Admin ist)
|
||||
if (wasInactive && user?.role !== 'admin') {
|
||||
const username = db.prepare('SELECT username FROM users WHERE id=?').get(p.id)?.username || 'Jemand';
|
||||
const admins = db.prepare(`
|
||||
SELECT p.user_key, p.app_token FROM pushover_settings p
|
||||
JOIN users u ON u.id = p.user_id
|
||||
WHERE u.role = 'admin' AND p.user_key IS NOT NULL AND p.app_token IS NOT NULL
|
||||
`).all();
|
||||
for (const admin of admins) {
|
||||
fetch('https://api.pushover.net/1/messages.json', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: admin.app_token,
|
||||
user: admin.user_key,
|
||||
title: '👤 DickenDock',
|
||||
message: `${username} ist gerade online gegangen.`,
|
||||
priority: -1,
|
||||
}),
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// ── Security Headers ──────────────────────────────────────────────────────────
|
||||
app.use((_req, res, next) => {
|
||||
res.setHeader('Content-Security-Policy', [
|
||||
"default-src 'self'",
|
||||
"script-src 'self'",
|
||||
// unsafe-inline nötig für React inline-styles + @import Google Fonts
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||
"font-src 'self' https://fonts.gstatic.com",
|
||||
// iCal-Feeds werden serverseitig geprosxt → nur 'self' nötig
|
||||
"connect-src 'self' https://web.archive.org wss://dickendock.sermer.org ws://localhost:4000",
|
||||
// data: für Avatare (base64), google.com + gstatic.com für Favicons im QuickLinks-Widget
|
||||
"img-src 'self' data: https://www.google.com https://*.gstatic.com https://image.tmdb.org",
|
||||
"worker-src 'self'",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"frame-src https://archive.ph https://archive.is https://archive.today",
|
||||
].join('; '));
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('X-Frame-Options', 'DENY');
|
||||
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
next();
|
||||
});
|
||||
|
||||
// ── API ───────────────────────────────────────────────────────────────────────
|
||||
app.use('/api/auth', require('./routes/auth'));
|
||||
app.use('/api/admin', require('./routes/admin'));
|
||||
app.use('/api/calendar', require('./routes/calendar'));
|
||||
app.use('/api/dashboard', require('./routes/dashboard'));
|
||||
app.use('/api/system', require('./routes/system'));
|
||||
|
||||
// ── Tool-Routen automatisch laden ─────────────────────────────────────────────
|
||||
// Neues Tool: Datei unter src/tools/<name>/routes.js ablegen → wird automatisch eingebunden
|
||||
// Öffentliche Datei-Share Verwaltung (vor Auto-Loader damit /:id nicht matcht)
|
||||
app.use('/api/tools/dateien/public-shares', require('./tools/dateien/public-share'));
|
||||
|
||||
// Tool-Routen automatisch laden
|
||||
const toolsDir = path.join(__dirname, 'tools');
|
||||
fs.readdirSync(toolsDir, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory())
|
||||
@@ -26,9 +103,241 @@ fs.readdirSync(toolsDir, { withFileTypes: true })
|
||||
}
|
||||
});
|
||||
|
||||
// ── React-Frontend ausliefern ─────────────────────────────────────────────────
|
||||
const PUBLIC = path.join(__dirname, '../public');
|
||||
app.use(express.static(PUBLIC));
|
||||
app.get('*', (_req, res) => res.sendFile(path.join(PUBLIC, 'index.html')));
|
||||
app.use('/api/tools/snippets', require('./tools/snippets/routes'));
|
||||
app.use('/api/tools/qrcodes', require('./tools/qrcodes/routes'));
|
||||
app.use('/api/upload-shares', require('./tools/dateien/upload-share'));
|
||||
app.use('/api/public/file-share', require('./tools/dateien/public-share-public'));
|
||||
app.use('/api/search', require('./routes/search'));
|
||||
|
||||
app.listen(4000, () => console.log('🚀 Dicken Dock läuft auf Port 4000'));
|
||||
// Öffentliche Upload-Seite: React-App ausliefern – App erkennt /u/ Pfad selbst
|
||||
app.get('/u/:token', (_req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-store, no-cache');
|
||||
res.sendFile(require('path').join(PUBLIC, 'index.html'));
|
||||
});
|
||||
|
||||
// Öffentliche Datei-Share-Seite: React-App ausliefern – App erkennt /s/ Pfad selbst
|
||||
app.get('/s/:token', (_req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-store, no-cache');
|
||||
res.sendFile(require('path').join(PUBLIC, 'index.html'));
|
||||
});
|
||||
|
||||
const PUBLIC = path.join(__dirname, '../public');
|
||||
|
||||
app.get('/manifest.json', (_req, res) => {
|
||||
res.setHeader('Content-Type', 'application/manifest+json');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.sendFile(path.join(PUBLIC, 'manifest.json'));
|
||||
});
|
||||
app.get('/sw.js', (_req, res) => {
|
||||
res.setHeader('Content-Type', 'application/javascript');
|
||||
res.setHeader('Service-Worker-Allowed', '/');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.sendFile(path.join(PUBLIC, 'sw.js'));
|
||||
});
|
||||
|
||||
app.use(express.static(PUBLIC, {
|
||||
setHeaders: (res, filePath) => {
|
||||
// JS/CSS Assets haben Hash im Namen → lang cachen
|
||||
if (filePath.match(/\.(js|css)$/) && !filePath.includes('sw.js')) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// index.html nie cachen
|
||||
app.get('*', (_req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
||||
res.setHeader('Pragma', 'no-cache');
|
||||
res.sendFile(path.join(PUBLIC, 'index.html'));
|
||||
});
|
||||
|
||||
// Build-Zeit Endpoint – Frontend prüft ob es eine neue Version gibt
|
||||
app.get('/api/build-time', (_req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
try {
|
||||
const ver = require('fs').readFileSync(path.join(__dirname, '../version.txt'), 'utf8').trim();
|
||||
res.json({ buildTime: ver });
|
||||
} catch { res.json({ buildTime: 'unknown' }); }
|
||||
});
|
||||
|
||||
// ── HTTP + WebSocket Server ───────────────────────────────────────────────────
|
||||
const http = require('http');
|
||||
const { WebSocketServer } = require('ws');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const server = http.createServer(app);
|
||||
const wss = new WebSocketServer({ server, path: '/ws/whiteboard' });
|
||||
|
||||
// Raum-Map: whiteboard_id → Set<ws>
|
||||
const rooms = new Map();
|
||||
|
||||
wss.on('connection', (ws, req) => {
|
||||
// Token aus Query-String auslesen: /ws/whiteboard?token=...&id=...
|
||||
const params = new URL(req.url, 'http://localhost').searchParams;
|
||||
const token = params.get('token');
|
||||
const wbId = parseInt(params.get('id'));
|
||||
|
||||
let userId = null;
|
||||
try {
|
||||
const p = jwt.verify(token, process.env.JWT_SECRET || 'dev-secret');
|
||||
userId = p.id;
|
||||
} catch {
|
||||
ws.close(1008, 'Unauthorized');
|
||||
return;
|
||||
}
|
||||
|
||||
// Zugriff prüfen
|
||||
const wb = db.prepare('SELECT owner_id FROM whiteboards WHERE id=?').get(wbId);
|
||||
if (!wb) { ws.close(1008, 'Not found'); return; }
|
||||
const isOwner = wb.owner_id === userId;
|
||||
const perm = db.prepare('SELECT role FROM whiteboard_permissions WHERE whiteboard_id=? AND user_id=?').get(wbId, userId);
|
||||
if (!isOwner && !perm) { ws.close(1008, 'Forbidden'); return; }
|
||||
const role = isOwner ? 'owner' : perm.role;
|
||||
|
||||
ws.userId = userId;
|
||||
ws.wbId = wbId;
|
||||
ws.role = role;
|
||||
ws.username = db.prepare('SELECT username FROM users WHERE id=?').get(userId)?.username || 'Unbekannt';
|
||||
|
||||
// Raum beitreten
|
||||
if (!rooms.has(wbId)) rooms.set(wbId, new Set());
|
||||
rooms.get(wbId).add(ws);
|
||||
|
||||
// Anderen im Raum mitteilen wer jointe
|
||||
broadcast(wbId, { type: 'user_join', userId, username: ws.username, role }, ws);
|
||||
|
||||
// Aktive User-Liste an neuen Client schicken
|
||||
const activeUsers = [...rooms.get(wbId)]
|
||||
.filter(c => c !== ws && c.readyState === 1)
|
||||
.map(c => ({ userId: c.userId, username: c.username, role: c.role }));
|
||||
ws.send(JSON.stringify({ type: 'active_users', users: activeUsers }));
|
||||
|
||||
ws.on('message', raw => {
|
||||
let msg;
|
||||
try { msg = JSON.parse(raw); } catch { return; }
|
||||
|
||||
switch (msg.type) {
|
||||
case 'cursor':
|
||||
// Cursor-Position live broadcasten (nur wenn nicht view-only)
|
||||
broadcast(wbId, { type:'cursor', userId, username:ws.username, x:msg.x, y:msg.y }, ws);
|
||||
break;
|
||||
|
||||
case 'elements':
|
||||
// Canvas-Änderungen von Edit-Berechtigten an alle broadcasten
|
||||
if (role === 'view') break;
|
||||
broadcast(wbId, { type:'elements', userId, elements: msg.elements }, ws);
|
||||
break;
|
||||
|
||||
case 'ping':
|
||||
ws.send(JSON.stringify({ type: 'pong' }));
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
const room = rooms.get(wbId);
|
||||
if (room) {
|
||||
room.delete(ws);
|
||||
if (room.size === 0) rooms.delete(wbId);
|
||||
else broadcast(wbId, { type: 'user_leave', userId, username: ws.username });
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', () => ws.terminate());
|
||||
});
|
||||
|
||||
function broadcast(wbId, msg, exclude = null) {
|
||||
const room = rooms.get(wbId);
|
||||
if (!room) return;
|
||||
const data = JSON.stringify(msg);
|
||||
for (const client of room) {
|
||||
if (client !== exclude && client.readyState === 1) {
|
||||
client.send(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
server.listen(4000, () => console.log('🚀 Dicken Dock läuft auf Port 4000'));
|
||||
|
||||
// ── Push-Zeitplaner Hintergrund-Job ──────────────────────────────────────────
|
||||
const db = require('./db');
|
||||
const koepiRoutes = require('./tools/koepi/routes');
|
||||
const mediaRoutes = require('./tools/media/routes');
|
||||
const mqttClient = require('./mqtt');
|
||||
|
||||
mqttClient.connectMqtt();
|
||||
// HA-Button "KöPi Check jetzt" ist ein reiner Anzeige-Refresh — sendet nie
|
||||
// Pushover. Benachrichtigungen bleiben exklusiv dem 06:00-Cron vorbehalten.
|
||||
mqttClient.setKoepiCheckHandler(() => koepiRoutes.refreshOffersOnly());
|
||||
// HA-Buttons "Media Quittieren" / "Media Alle quittieren" — danach sofort den
|
||||
// Media-Anfragen-Sensor + die dynamischen Buttons in HA aktualisieren
|
||||
mqttClient.setMediaAckHandler((id) => {
|
||||
mediaRoutes.ackFavorite(id);
|
||||
mqttClient.publishMediaAnfragen();
|
||||
});
|
||||
mqttClient.setMediaAckAllHandler(() => {
|
||||
mediaRoutes.ackAllFavorites();
|
||||
mqttClient.publishMediaAnfragen();
|
||||
});
|
||||
|
||||
async function sendPushoverMsg(userKey, appToken, message, opts = {}) {
|
||||
try {
|
||||
const params = { token: appToken, user: userKey, title: 'DickenDock Erinnerung', message };
|
||||
if (opts.retry && opts.expire) {
|
||||
params.priority = 2;
|
||||
params.retry = opts.retry;
|
||||
params.expire = opts.expire;
|
||||
}
|
||||
await fetch('https://api.pushover.net/1/messages.json', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams(params),
|
||||
});
|
||||
} catch(e) { console.error('Pushover Fehler:', e.message); }
|
||||
}
|
||||
|
||||
// Beim Start auf die nächste volle Minute synchronisieren
|
||||
const msToNextMinute = (60 - new Date().getSeconds()) * 1000 - new Date().getMilliseconds();
|
||||
setTimeout(() => {
|
||||
runScheduler();
|
||||
setInterval(runScheduler, 60 * 1000);
|
||||
}, msToNextMinute);
|
||||
|
||||
async function runScheduler() {
|
||||
mqttClient.publishPresence();
|
||||
mqttClient.publishMediaAnfragen();
|
||||
mqttClient.publishDruckStatistik();
|
||||
mqttClient.publishPushErinnerungen();
|
||||
mqttClient.publishKanban();
|
||||
try {
|
||||
const now = db.prepare("SELECT datetime('now','localtime') as t").get().t;
|
||||
const due = db.prepare(`
|
||||
SELECT s.*, p.user_key, p.app_token, p.retry, p.expire
|
||||
FROM push_schedules s
|
||||
JOIN pushover_settings p ON p.user_id = s.user_id
|
||||
WHERE s.sent = 0 AND s.scheduled_at <= ?
|
||||
`).all(now);
|
||||
for (const row of due) {
|
||||
await sendPushoverMsg(row.user_key, row.app_token, row.message,
|
||||
{ retry: row.retry, expire: row.expire });
|
||||
db.prepare('UPDATE push_schedules SET sent=1 WHERE id=?').run(row.id);
|
||||
console.log(`✓ Push gesendet an user ${row.user_id}: "${row.message}" (fällig: ${row.scheduled_at})`);
|
||||
}
|
||||
} catch(e) { console.error('Push-Job Fehler:', e.message); }
|
||||
|
||||
// KöPi Tages-Check: täglich um 06:00 (localtime), Duplikatschutz über admin_settings-Datum
|
||||
try {
|
||||
const now = db.prepare("SELECT datetime('now','localtime') as t").get().t;
|
||||
const date = now.slice(0, 10);
|
||||
const time = now.slice(11, 16);
|
||||
if (time === '06:00') {
|
||||
const lastRunDate = db.prepare("SELECT value FROM admin_settings WHERE key='koepi_cron_last_date'").get()?.value;
|
||||
if (lastRunDate !== date) {
|
||||
db.prepare("INSERT OR REPLACE INTO admin_settings (key, value) VALUES ('koepi_cron_last_date', ?)").run(date);
|
||||
await koepiRoutes.runDailyCheck();
|
||||
}
|
||||
}
|
||||
} catch(e) { console.error('KöPi-Cron Fehler:', e.message); }
|
||||
}
|
||||
|
||||
console.log(`⏰ Push-Scheduler aktiv – nächste Prüfung in ${Math.round(msToNextMinute/1000)}s`);
|
||||
|
||||
620
backend/src/mqtt.js
Normal file
@@ -0,0 +1,620 @@
|
||||
// ── Home Assistant MQTT Integration ──────────────────────────────────────────
|
||||
// Verbindet sich zum Mosquitto-Broker (HA Add-on) und meldet DickenDock-Entitäten
|
||||
// per HA MQTT Discovery an. Kein Custom Component in HA nötig — Entitäten
|
||||
// erscheinen automatisch unter "Einstellungen → Geräte & Dienste → MQTT".
|
||||
//
|
||||
// Namenskonvention: alle Entity-Namen beginnen mit ihrem Bereich ("KöPi", "Media",
|
||||
// "3D", "System"), damit sie in Home Assistant alphabetisch sortiert gruppiert
|
||||
// untereinander stehen.
|
||||
//
|
||||
// Aktiv nur, wenn MQTT_HOST in der .env gesetzt ist. Ohne Konfiguration bleibt
|
||||
// der Rest der App unverändert lauffähig (alle publish-Funktionen sind No-Ops).
|
||||
|
||||
const mqtt = require('mqtt');
|
||||
const db = require('./db');
|
||||
|
||||
const BASE_TOPIC = process.env.MQTT_BASE_TOPIC || 'dickendock';
|
||||
const AVAILABILITY_TOPIC = `${BASE_TOPIC}/status`;
|
||||
const KOEPI_CHECK_CMD_TOPIC = `${BASE_TOPIC}/koepi/check/set`;
|
||||
const MEDIA_ACK_ALL_CMD_TOPIC = `${BASE_TOPIC}/media/ack_all/set`;
|
||||
const MEDIA_ACK_TOPIC_PREFIX = `${BASE_TOPIC}/media/ack/`; // + {id}/set
|
||||
const MEDIA_ACK_SUBSCRIBE = `${BASE_TOPIC}/media/ack/+/set`;
|
||||
|
||||
const DEVICE = {
|
||||
identifiers: ['dickendock'],
|
||||
name: 'DickenDock',
|
||||
manufacturer: 'Flo',
|
||||
model: 'DockStation',
|
||||
};
|
||||
|
||||
let client = null;
|
||||
let koepiCheckHandler = null; // wird von index.js gesetzt, um Zirkel-Requires zu vermeiden
|
||||
let mediaAckHandler = null; // (id) => void — einzelnen Favoriten quittieren
|
||||
let mediaAckAllHandler = null; // () => void — alle Favoriten quittieren
|
||||
|
||||
// Zuletzt published dynamische Media-Quittier-Buttons (zum sauberen Retracten
|
||||
// wenn ein Favorit quittiert/gelöscht wurde und der Button verschwinden soll)
|
||||
let publishedMediaAckIds = new Set();
|
||||
|
||||
function connectMqtt() {
|
||||
if (!process.env.MQTT_HOST) {
|
||||
console.log('MQTT: MQTT_HOST nicht gesetzt – Home-Assistant-Integration deaktiviert.');
|
||||
return;
|
||||
}
|
||||
|
||||
client = mqtt.connect({
|
||||
host: process.env.MQTT_HOST,
|
||||
port: parseInt(process.env.MQTT_PORT || '1883', 10),
|
||||
username: process.env.MQTT_USERNAME,
|
||||
password: process.env.MQTT_PASSWORD,
|
||||
will: { topic: AVAILABILITY_TOPIC, payload: 'offline', retain: true },
|
||||
reconnectPeriod: 5000,
|
||||
});
|
||||
|
||||
client.on('connect', () => {
|
||||
console.log(`MQTT verbunden: ${process.env.MQTT_HOST}:${process.env.MQTT_PORT || 1883}`);
|
||||
client.publish(AVAILABILITY_TOPIC, 'online', { retain: true });
|
||||
publishDiscovery();
|
||||
client.subscribe(KOEPI_CHECK_CMD_TOPIC);
|
||||
client.subscribe(MEDIA_ACK_ALL_CMD_TOPIC);
|
||||
client.subscribe(MEDIA_ACK_SUBSCRIBE);
|
||||
});
|
||||
|
||||
client.on('message', async (topic) => {
|
||||
try {
|
||||
if (topic === KOEPI_CHECK_CMD_TOPIC && typeof koepiCheckHandler === 'function') {
|
||||
await koepiCheckHandler();
|
||||
} else if (topic === MEDIA_ACK_ALL_CMD_TOPIC && typeof mediaAckAllHandler === 'function') {
|
||||
await mediaAckAllHandler();
|
||||
} else if (topic.startsWith(MEDIA_ACK_TOPIC_PREFIX) && typeof mediaAckHandler === 'function') {
|
||||
const id = topic.slice(MEDIA_ACK_TOPIC_PREFIX.length).replace(/\/set$/, '');
|
||||
if (id) await mediaAckHandler(id);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('MQTT Command-Fehler:', e.message);
|
||||
}
|
||||
});
|
||||
|
||||
client.on('error', (e) => console.error('MQTT Fehler:', e.message));
|
||||
}
|
||||
|
||||
function publishDiscovery() {
|
||||
if (!client) return;
|
||||
const entities = [
|
||||
// ── KöPi ──────────────────────────────────────────────────────────────
|
||||
['sensor', 'dickendock_koepi_angebot', {
|
||||
name: 'KöPi Angebote',
|
||||
unique_id: 'dickendock_koepi_bestes_angebot',
|
||||
state_topic: `${BASE_TOPIC}/koepi/state`,
|
||||
json_attributes_topic: `${BASE_TOPIC}/koepi/attributes`,
|
||||
icon: 'mdi:beer',
|
||||
availability_topic: AVAILABILITY_TOPIC,
|
||||
device: DEVICE,
|
||||
}],
|
||||
['binary_sensor', 'dickendock_koepi_changed', {
|
||||
name: 'KöPi Angebot geändert',
|
||||
unique_id: 'dickendock_koepi_changed',
|
||||
state_topic: `${BASE_TOPIC}/koepi/changed`,
|
||||
payload_on: 'ON',
|
||||
payload_off: 'OFF',
|
||||
icon: 'mdi:bell-alert',
|
||||
availability_topic: AVAILABILITY_TOPIC,
|
||||
device: DEVICE,
|
||||
}],
|
||||
['button', 'dickendock_koepi_check', {
|
||||
name: 'KöPi Check jetzt',
|
||||
unique_id: 'dickendock_koepi_check',
|
||||
command_topic: KOEPI_CHECK_CMD_TOPIC,
|
||||
icon: 'mdi:refresh',
|
||||
availability_topic: AVAILABILITY_TOPIC,
|
||||
device: DEVICE,
|
||||
}],
|
||||
['sensor', 'dickendock_koepi_start', {
|
||||
name: 'KöPi Angebot Start',
|
||||
unique_id: 'dickendock_koepi_start',
|
||||
state_topic: `${BASE_TOPIC}/koepi/start`,
|
||||
device_class: 'date',
|
||||
icon: 'mdi:calendar-start',
|
||||
availability_topic: AVAILABILITY_TOPIC,
|
||||
device: DEVICE,
|
||||
}],
|
||||
['sensor', 'dickendock_koepi_ende', {
|
||||
name: 'KöPi Angebot Ende',
|
||||
unique_id: 'dickendock_koepi_ende',
|
||||
state_topic: `${BASE_TOPIC}/koepi/ende`,
|
||||
device_class: 'date',
|
||||
icon: 'mdi:calendar-end',
|
||||
availability_topic: AVAILABILITY_TOPIC,
|
||||
device: DEVICE,
|
||||
}],
|
||||
|
||||
// ── Media ─────────────────────────────────────────────────────────────
|
||||
['sensor', 'dickendock_media_anfragen', {
|
||||
name: 'Media Anfragen offen',
|
||||
unique_id: 'dickendock_media_anfragen',
|
||||
state_topic: `${BASE_TOPIC}/media/anfragen`,
|
||||
json_attributes_topic: `${BASE_TOPIC}/media/anfragen_attributes`,
|
||||
icon: 'mdi:movie-open-star',
|
||||
unit_of_measurement: 'Anfragen',
|
||||
state_class: 'measurement',
|
||||
availability_topic: AVAILABILITY_TOPIC,
|
||||
device: DEVICE,
|
||||
}],
|
||||
['button', 'dickendock_media_ack_all', {
|
||||
name: 'Media Alle quittieren',
|
||||
unique_id: 'dickendock_media_ack_all',
|
||||
command_topic: MEDIA_ACK_ALL_CMD_TOPIC,
|
||||
icon: 'mdi:check-all',
|
||||
availability_topic: AVAILABILITY_TOPIC,
|
||||
device: DEVICE,
|
||||
}],
|
||||
|
||||
// ── 3D ────────────────────────────────────────────────────────────────
|
||||
['sensor', 'dickendock_3d_umsatz_monat', {
|
||||
name: '3D Umsatz Monat',
|
||||
unique_id: 'dickendock_3ddruck_umsatz_monat',
|
||||
state_topic: `${BASE_TOPIC}/druck/umsatz_monat`,
|
||||
icon: 'mdi:cash-multiple',
|
||||
device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement',
|
||||
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
|
||||
}],
|
||||
['sensor', 'dickendock_3d_umsatz_gesamt', {
|
||||
name: '3D Umsatz Gesamt',
|
||||
unique_id: 'dickendock_3d_umsatz_gesamt',
|
||||
state_topic: `${BASE_TOPIC}/druck/umsatz_gesamt`,
|
||||
icon: 'mdi:cash-multiple',
|
||||
device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement',
|
||||
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
|
||||
}],
|
||||
['sensor', 'dickendock_3d_grundkosten_monat', {
|
||||
name: '3D Grundkosten Monat',
|
||||
unique_id: 'dickendock_3d_grundkosten_monat',
|
||||
state_topic: `${BASE_TOPIC}/druck/grundkosten_monat`,
|
||||
icon: 'mdi:currency-eur-off',
|
||||
device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement',
|
||||
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
|
||||
}],
|
||||
['sensor', 'dickendock_3d_grundkosten_gesamt', {
|
||||
name: '3D Grundkosten Gesamt',
|
||||
unique_id: 'dickendock_3d_grundkosten_gesamt',
|
||||
state_topic: `${BASE_TOPIC}/druck/grundkosten_gesamt`,
|
||||
icon: 'mdi:currency-eur-off',
|
||||
device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement',
|
||||
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
|
||||
}],
|
||||
['sensor', 'dickendock_3d_rohgewinn_monat', {
|
||||
name: '3D Rohgewinn Monat',
|
||||
unique_id: 'dickendock_3d_rohgewinn_monat',
|
||||
state_topic: `${BASE_TOPIC}/druck/rohgewinn_monat`,
|
||||
icon: 'mdi:chart-line',
|
||||
device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement',
|
||||
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
|
||||
}],
|
||||
['sensor', 'dickendock_3d_rohgewinn_gesamt', {
|
||||
name: '3D Rohgewinn Gesamt',
|
||||
unique_id: 'dickendock_3d_rohgewinn_gesamt',
|
||||
state_topic: `${BASE_TOPIC}/druck/rohgewinn_gesamt`,
|
||||
icon: 'mdi:chart-line',
|
||||
device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement',
|
||||
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
|
||||
}],
|
||||
['sensor', 'dickendock_3d_ausgaben_monat', {
|
||||
name: '3D Ausgaben Monat',
|
||||
unique_id: 'dickendock_3d_ausgaben_monat',
|
||||
state_topic: `${BASE_TOPIC}/druck/ausgaben_monat`,
|
||||
icon: 'mdi:receipt-text-minus',
|
||||
device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement',
|
||||
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
|
||||
}],
|
||||
['sensor', 'dickendock_3d_ausgaben_gesamt', {
|
||||
name: '3D Ausgaben Gesamt',
|
||||
unique_id: 'dickendock_3d_ausgaben_gesamt',
|
||||
state_topic: `${BASE_TOPIC}/druck/ausgaben_gesamt`,
|
||||
icon: 'mdi:receipt-text-minus',
|
||||
device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement',
|
||||
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
|
||||
}],
|
||||
['sensor', 'dickendock_3d_nettogewinn_monat', {
|
||||
name: '3D Nettogewinn Monat',
|
||||
unique_id: 'dickendock_3d_nettogewinn_monat',
|
||||
state_topic: `${BASE_TOPIC}/druck/nettogewinn_monat`,
|
||||
icon: 'mdi:cash-check',
|
||||
device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement',
|
||||
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
|
||||
}],
|
||||
['sensor', 'dickendock_3d_nettogewinn_gesamt', {
|
||||
name: '3D Nettogewinn Gesamt',
|
||||
unique_id: 'dickendock_3d_nettogewinn_gesamt',
|
||||
state_topic: `${BASE_TOPIC}/druck/nettogewinn_gesamt`,
|
||||
icon: 'mdi:cash-check',
|
||||
device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement',
|
||||
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
|
||||
}],
|
||||
['sensor', 'dickendock_3d_bestellungen_offen', {
|
||||
name: '3D Bestellungen offen',
|
||||
unique_id: 'dickendock_bestellungen_offen',
|
||||
state_topic: `${BASE_TOPIC}/druck/bestellungen_offen`,
|
||||
json_attributes_topic: `${BASE_TOPIC}/druck/bestellungen_offen_attributes`,
|
||||
icon: 'mdi:cube-send',
|
||||
unit_of_measurement: 'Bestellungen', state_class: 'measurement',
|
||||
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
|
||||
}],
|
||||
['sensor', 'dickendock_3d_bestellungen_warteliste', {
|
||||
name: '3D Bestellungen Warteliste',
|
||||
unique_id: 'dickendock_3d_bestellungen_warteliste',
|
||||
state_topic: `${BASE_TOPIC}/druck/bestellungen_warteliste`,
|
||||
icon: 'mdi:timer-sand',
|
||||
unit_of_measurement: 'Bestellungen', state_class: 'measurement',
|
||||
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
|
||||
}],
|
||||
['sensor', 'dickendock_3d_bestellungen_in_arbeit', {
|
||||
name: '3D Bestellungen In Arbeit',
|
||||
unique_id: 'dickendock_3d_bestellungen_in_arbeit',
|
||||
state_topic: `${BASE_TOPIC}/druck/bestellungen_in_arbeit`,
|
||||
icon: 'mdi:printer-3d-nozzle',
|
||||
unit_of_measurement: 'Bestellungen', state_class: 'measurement',
|
||||
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
|
||||
}],
|
||||
['sensor', 'dickendock_3d_bestellungen_fertig', {
|
||||
name: '3D Bestellungen Fertig',
|
||||
unique_id: 'dickendock_3d_bestellungen_fertig',
|
||||
state_topic: `${BASE_TOPIC}/druck/bestellungen_fertig`,
|
||||
icon: 'mdi:check-circle-outline',
|
||||
unit_of_measurement: 'Bestellungen', state_class: 'measurement',
|
||||
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
|
||||
}],
|
||||
['sensor', 'dickendock_3d_bestellungen_bezahlt', {
|
||||
name: '3D Bestellungen Bezahlt',
|
||||
unique_id: 'dickendock_3d_bestellungen_bezahlt',
|
||||
state_topic: `${BASE_TOPIC}/druck/bestellungen_bezahlt`,
|
||||
icon: 'mdi:cash-check',
|
||||
unit_of_measurement: 'Bestellungen', state_class: 'measurement',
|
||||
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
|
||||
}],
|
||||
['sensor', 'dickendock_3d_bestellungen_abgeschlossen', {
|
||||
name: '3D Bestellungen Abgeschlossen',
|
||||
unique_id: 'dickendock_3d_bestellungen_abgeschlossen',
|
||||
state_topic: `${BASE_TOPIC}/druck/bestellungen_abgeschlossen`,
|
||||
icon: 'mdi:archive-check-outline',
|
||||
unit_of_measurement: 'Bestellungen', state_class: 'measurement',
|
||||
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
|
||||
}],
|
||||
['sensor', 'dickendock_3d_bestellungen_gesamt', {
|
||||
name: '3D Bestellungen Gesamt',
|
||||
unique_id: 'dickendock_3d_bestellungen_gesamt',
|
||||
state_topic: `${BASE_TOPIC}/druck/bestellungen_gesamt`,
|
||||
icon: 'mdi:cube-outline',
|
||||
unit_of_measurement: 'Bestellungen', state_class: 'measurement',
|
||||
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
|
||||
}],
|
||||
|
||||
// ── Dashboard ─────────────────────────────────────────────────────────
|
||||
['sensor', 'dickendock_dashboard_push_erinnerungen', {
|
||||
name: 'Dashboard Push-Erinnerungen offen',
|
||||
unique_id: 'dickendock_dashboard_push_erinnerungen',
|
||||
state_topic: `${BASE_TOPIC}/dashboard/push_erinnerungen`,
|
||||
json_attributes_topic: `${BASE_TOPIC}/dashboard/push_erinnerungen_attributes`,
|
||||
icon: 'mdi:bell-ring-outline',
|
||||
unit_of_measurement: 'Erinnerungen', state_class: 'measurement',
|
||||
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
|
||||
}],
|
||||
|
||||
// ── Kanban ────────────────────────────────────────────────────────────
|
||||
['sensor', 'dickendock_kanban_karten', {
|
||||
name: 'Kanban Karten offen',
|
||||
unique_id: 'dickendock_kanban_karten',
|
||||
state_topic: `${BASE_TOPIC}/kanban/karten`,
|
||||
json_attributes_topic: `${BASE_TOPIC}/kanban/karten_attributes`,
|
||||
icon: 'mdi:view-column-outline',
|
||||
unit_of_measurement: 'Karten', state_class: 'measurement',
|
||||
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
|
||||
}],
|
||||
|
||||
// ── System ────────────────────────────────────────────────────────────
|
||||
['sensor', 'dickendock_system_online', {
|
||||
name: 'System Nutzer online',
|
||||
unique_id: 'dickendock_nutzer_online',
|
||||
state_topic: `${BASE_TOPIC}/presence/state`,
|
||||
json_attributes_topic: `${BASE_TOPIC}/presence/attributes`,
|
||||
icon: 'mdi:account-multiple',
|
||||
unit_of_measurement: 'Nutzer',
|
||||
state_class: 'measurement',
|
||||
availability_topic: AVAILABILITY_TOPIC,
|
||||
device: DEVICE,
|
||||
}],
|
||||
];
|
||||
for (const [component, objectId, payload] of entities) {
|
||||
client.publish(`homeassistant/${component}/${objectId}/config`, JSON.stringify(payload), { retain: true });
|
||||
}
|
||||
}
|
||||
|
||||
function round2(n) { return Math.round((n || 0) * 100) / 100; }
|
||||
|
||||
function parsePrice(str) {
|
||||
if (!str) return Infinity;
|
||||
const n = parseFloat(String(str).replace(/[^0-9,.-]/g, '').replace(',', '.'));
|
||||
return Number.isNaN(n) ? Infinity : n;
|
||||
}
|
||||
|
||||
// Parst ein deutsches Datum "10.07." oder "10.07.2026" aus einem Textschnipsel.
|
||||
// Fehlt das Jahr, wird das aktuelle Jahr angenommen (mit Jahreswechsel-Korrektur
|
||||
// in beide Richtungen, z.B. bei Angeboten die über Silvester laufen).
|
||||
function parseGermanDate(str, ref = new Date()) {
|
||||
const m = String(str).match(/(\d{1,2})\.(\d{1,2})\.(\d{4})?/);
|
||||
if (!m) return null;
|
||||
const day = parseInt(m[1], 10);
|
||||
const month = parseInt(m[2], 10) - 1;
|
||||
let year = m[3] ? parseInt(m[3], 10) : ref.getFullYear();
|
||||
const d = new Date(year, month, day);
|
||||
if (!m[3]) {
|
||||
const diffMonths = (d.getFullYear() - ref.getFullYear()) * 12 + (d.getMonth() - ref.getMonth());
|
||||
if (diffMonths < -3) d.setFullYear(year + 1); // z.B. Ref=Dez, Datum=Jan → nächstes Jahr gemeint
|
||||
else if (diffMonths > 3) d.setFullYear(year - 1); // z.B. Ref=Jan, Datum=Dez → vergangenes Jahr gemeint
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
// Ermittelt aus allen Angeboten den frühesten Start- und spätesten End-Termin
|
||||
// (marktguru-Angebote für ein Produkt laufen i.d.R. im selben Wochenzeitraum)
|
||||
function extractDateRange(offers) {
|
||||
let start = null, end = null;
|
||||
for (const o of offers) {
|
||||
const text = o.dateRange || '';
|
||||
const found = [...text.matchAll(/\d{1,2}\.\d{1,2}\.(?:\d{4})?/g)].map(m => m[0]);
|
||||
if (found.length >= 2) {
|
||||
const d1 = parseGermanDate(found[0]);
|
||||
const d2 = parseGermanDate(found[1]);
|
||||
if (d1 && (!start || d1 < start)) start = d1;
|
||||
if (d2 && (!end || d2 > end)) end = d2;
|
||||
} else if (found.length === 1) {
|
||||
const d1 = parseGermanDate(found[0]);
|
||||
if (d1 && (!end || d1 > end)) end = d1;
|
||||
}
|
||||
}
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function toIsoDate(d) {
|
||||
if (!d) return null;
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
// Wird von koepi/routes.js nach jedem Check (Cron oder manueller Test) aufgerufen
|
||||
function publishKoepiState({ offers, changed }) {
|
||||
if (!client?.connected) return;
|
||||
const list = [...(offers || [])].sort((a, b) => parsePrice(a.price) - parsePrice(b.price));
|
||||
|
||||
// State: nur die Anzahl (bleibt immer kurz und stabil) — die eigentlichen
|
||||
// Angebote stehen vollständig in den Attributen
|
||||
const state = list.length ? `${list.length} Angebote` : 'keine Angebote';
|
||||
|
||||
// Lesbarer Mehrzeiler fürs Dashboard, zusätzlich zur strukturierten Liste
|
||||
const listeText = list.length
|
||||
? list.map(o => `${o.retailer}: ${o.price}${o.dateRange ? ' (' + o.dateRange + ')' : ''}`).join('\n')
|
||||
: 'keine Angebote';
|
||||
|
||||
client.publish(`${BASE_TOPIC}/koepi/state`, state, { retain: true });
|
||||
client.publish(`${BASE_TOPIC}/koepi/attributes`, JSON.stringify({
|
||||
angebote: list,
|
||||
liste_text: listeText,
|
||||
anzahl: list.length,
|
||||
letzter_check: new Date().toISOString(),
|
||||
}), { retain: true });
|
||||
client.publish(`${BASE_TOPIC}/koepi/changed`, changed ? 'ON' : 'OFF', { retain: true });
|
||||
|
||||
const { start, end } = extractDateRange(list);
|
||||
client.publish(`${BASE_TOPIC}/koepi/start`, toIsoDate(start) || 'unknown', { retain: true });
|
||||
client.publish(`${BASE_TOPIC}/koepi/ende`, toIsoDate(end) || 'unknown', { retain: true });
|
||||
}
|
||||
|
||||
// Wird jede Minute vom Scheduler in index.js aufgerufen
|
||||
function publishPresence() {
|
||||
if (!client?.connected) return;
|
||||
const alleNutzer = db.prepare(`
|
||||
SELECT username, last_active_at,
|
||||
CASE WHEN last_active_at > datetime('now','localtime','-5 minutes') THEN 1 ELSE 0 END AS online
|
||||
FROM users
|
||||
ORDER BY last_active_at DESC
|
||||
`).all();
|
||||
const online = alleNutzer.filter(u => !!u.online);
|
||||
|
||||
client.publish(`${BASE_TOPIC}/presence/state`, String(online.length), { retain: true });
|
||||
client.publish(`${BASE_TOPIC}/presence/attributes`, JSON.stringify({
|
||||
online: online.map(u => ({ benutzer: u.username, zuletzt_aktiv: u.last_active_at })),
|
||||
alle_nutzer: alleNutzer.map(u => ({
|
||||
benutzer: u.username,
|
||||
zuletzt_aktiv: u.last_active_at,
|
||||
online: !!u.online,
|
||||
})),
|
||||
}), { retain: true });
|
||||
}
|
||||
|
||||
// Wird jede Minute vom Scheduler UND direkt nach jeder Quittierung aufgerufen.
|
||||
// Offene (unquittierte) Media-Favoriten, systemweit über alle Nutzer.
|
||||
// Legt zusätzlich pro offener Anfrage einen eigenen "Quittieren"-Button in HA an
|
||||
// und entfernt Buttons für Anfragen, die inzwischen quittiert/gelöscht wurden.
|
||||
function publishMediaAnfragen() {
|
||||
if (!client?.connected) return;
|
||||
const open = db.prepare(`
|
||||
SELECT f.id, f.title, f.added_at, u.username
|
||||
FROM movie_favorites f JOIN users u ON u.id = f.user_id
|
||||
WHERE f.acknowledged = 0
|
||||
ORDER BY f.added_at DESC
|
||||
`).all();
|
||||
|
||||
client.publish(`${BASE_TOPIC}/media/anfragen`, String(open.length), { retain: true });
|
||||
client.publish(`${BASE_TOPIC}/media/anfragen_attributes`, JSON.stringify({ anfragen: open }), { retain: true });
|
||||
|
||||
const currentIds = new Set(open.map(f => String(f.id)));
|
||||
|
||||
// Neue Buttons für neu hinzugekommene offene Anfragen anlegen
|
||||
for (const fav of open) {
|
||||
const id = String(fav.id);
|
||||
if (!publishedMediaAckIds.has(id)) {
|
||||
const label = `Media Quittieren: ${fav.title}${fav.username ? ' (' + fav.username + ')' : ''}`.slice(0, 255);
|
||||
client.publish(`homeassistant/button/dickendock_media_ack_${id}/config`, JSON.stringify({
|
||||
name: label,
|
||||
unique_id: `dickendock_media_ack_${id}`,
|
||||
command_topic: `${MEDIA_ACK_TOPIC_PREFIX}${id}/set`,
|
||||
icon: 'mdi:check-circle-outline',
|
||||
availability_topic: AVAILABILITY_TOPIC,
|
||||
device: DEVICE,
|
||||
}), { retain: true });
|
||||
}
|
||||
}
|
||||
// Buttons für inzwischen quittierte/gelöschte Anfragen zurückziehen (leeres retained Payload)
|
||||
for (const id of publishedMediaAckIds) {
|
||||
if (!currentIds.has(id)) {
|
||||
client.publish(`homeassistant/button/dickendock_media_ack_${id}/config`, '', { retain: true });
|
||||
}
|
||||
}
|
||||
publishedMediaAckIds = currentIds;
|
||||
}
|
||||
|
||||
// Umsatz eines einzelnen Auftrags — Festpreis falls gesetzt, sonst Summe der
|
||||
// Positions-Festpreise (spiegelt die Logik aus statistik/routes.js)
|
||||
function getOrderRevenue(order) {
|
||||
if (order.custom_price != null && order.custom_price > 0) return parseFloat(order.custom_price);
|
||||
const r = db.prepare(`
|
||||
SELECT COALESCE(SUM(custom_price * stueckzahl), 0) AS s
|
||||
FROM order_items WHERE order_id=? AND custom_price IS NOT NULL
|
||||
`).get(order.id);
|
||||
return r?.s || 0;
|
||||
}
|
||||
|
||||
// Grundkosten (Material/Zeit-Basis) eines Auftrags — spiegelt statistik/routes.js
|
||||
function getOrderBaseCost(orderId) {
|
||||
const r = db.prepare(`
|
||||
SELECT COALESCE(SUM(COALESCE(c.preis_freundschaft, oi.preis_freundschaft, 0) * oi.stueckzahl), 0) AS s
|
||||
FROM order_items oi
|
||||
LEFT JOIN calculations c ON c.id = oi.calculation_id
|
||||
WHERE oi.order_id = ?
|
||||
`).get(orderId);
|
||||
return r?.s || 0;
|
||||
}
|
||||
|
||||
// Wird jede Minute vom Scheduler aufgerufen — umfangreiche 3D-Druck-Statistik,
|
||||
// systemweit über alle Nutzer, jeweils für den laufenden Monat und komplett (all-time)
|
||||
function publishDruckStatistik() {
|
||||
if (!client?.connected) return;
|
||||
const now = new Date();
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
const monatsAnfang = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-01`;
|
||||
const heute = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
||||
|
||||
const allOrders = db.prepare('SELECT * FROM orders').all();
|
||||
const paidAll = allOrders.filter(o => !!o.bezahlt);
|
||||
const paidMonat = paidAll.filter(o => {
|
||||
const d = (o.bezahlt_am || o.created_at || '').slice(0, 10);
|
||||
return d >= monatsAnfang && d <= heute;
|
||||
});
|
||||
|
||||
const umsatzGesamt = paidAll.reduce((s, o) => s + getOrderRevenue(o), 0);
|
||||
const umsatzMonat = paidMonat.reduce((s, o) => s + getOrderRevenue(o), 0);
|
||||
const kostenGesamt = paidAll.reduce((s, o) => s + getOrderBaseCost(o.id), 0);
|
||||
const kostenMonat = paidMonat.reduce((s, o) => s + getOrderBaseCost(o.id), 0);
|
||||
const rohgewinnGesamt = umsatzGesamt - kostenGesamt;
|
||||
const rohgewinnMonat = umsatzMonat - kostenMonat;
|
||||
|
||||
const ausgabenGesamt = db.prepare('SELECT COALESCE(SUM(amount),0) AS s FROM expenses').get().s;
|
||||
const ausgabenMonat = db.prepare(
|
||||
'SELECT COALESCE(SUM(amount),0) AS s FROM expenses WHERE date>=? AND date<=?'
|
||||
).get(monatsAnfang, heute).s;
|
||||
|
||||
const nettoGesamt = rohgewinnGesamt - ausgabenGesamt;
|
||||
const nettoMonat = rohgewinnMonat - ausgabenMonat;
|
||||
|
||||
const byStatus = { warteliste: 0, in_arbeit: 0, fertig: 0, bezahlt: 0, abgeschlossen: 0 };
|
||||
for (const o of allOrders) {
|
||||
if (o.bezahlt && o.abgeholt) byStatus.abgeschlossen++;
|
||||
else if (o.bezahlt) byStatus.bezahlt++;
|
||||
else if (o.status in byStatus) byStatus[o.status]++;
|
||||
}
|
||||
const offen = allOrders.length - byStatus.abgeschlossen;
|
||||
|
||||
// Konkrete offene Bestellungen (nicht abgeschlossen) mit Besitzer, für die Attribute
|
||||
const offeneBestellungen = db.prepare(`
|
||||
SELECT o.id, o.name, o.status, o.bezahlt, o.abgeholt, o.created_at, u.username
|
||||
FROM orders o JOIN users u ON u.id = o.user_id
|
||||
WHERE NOT (o.bezahlt = 1 AND o.abgeholt = 1)
|
||||
ORDER BY o.created_at DESC
|
||||
`).all();
|
||||
|
||||
const pub = (topic, val) => client.publish(`${BASE_TOPIC}/${topic}`, String(val), { retain: true });
|
||||
pub('druck/umsatz_monat', round2(umsatzMonat));
|
||||
pub('druck/umsatz_gesamt', round2(umsatzGesamt));
|
||||
pub('druck/grundkosten_monat', round2(kostenMonat));
|
||||
pub('druck/grundkosten_gesamt', round2(kostenGesamt));
|
||||
pub('druck/rohgewinn_monat', round2(rohgewinnMonat));
|
||||
pub('druck/rohgewinn_gesamt', round2(rohgewinnGesamt));
|
||||
pub('druck/ausgaben_monat', round2(ausgabenMonat));
|
||||
pub('druck/ausgaben_gesamt', round2(ausgabenGesamt));
|
||||
pub('druck/nettogewinn_monat', round2(nettoMonat));
|
||||
pub('druck/nettogewinn_gesamt', round2(nettoGesamt));
|
||||
pub('druck/bestellungen_offen', offen);
|
||||
client.publish(`${BASE_TOPIC}/druck/bestellungen_offen_attributes`, JSON.stringify({
|
||||
bestellungen: offeneBestellungen,
|
||||
}), { retain: true });
|
||||
pub('druck/bestellungen_warteliste', byStatus.warteliste);
|
||||
pub('druck/bestellungen_in_arbeit', byStatus.in_arbeit);
|
||||
pub('druck/bestellungen_fertig', byStatus.fertig);
|
||||
pub('druck/bestellungen_bezahlt', byStatus.bezahlt);
|
||||
pub('druck/bestellungen_abgeschlossen', byStatus.abgeschlossen);
|
||||
pub('druck/bestellungen_gesamt', allOrders.length);
|
||||
}
|
||||
|
||||
// Wird jede Minute vom Scheduler aufgerufen — offene (noch nicht versendete,
|
||||
// in der Zukunft liegende) Push-Erinnerungen aus dem Dashboard, systemweit
|
||||
function publishPushErinnerungen() {
|
||||
if (!client?.connected) return;
|
||||
const offen = db.prepare(`
|
||||
SELECT s.message, s.scheduled_at, u.username
|
||||
FROM push_schedules s JOIN users u ON u.id = s.user_id
|
||||
WHERE s.sent = 0 AND s.scheduled_at > datetime('now','localtime')
|
||||
ORDER BY s.scheduled_at ASC
|
||||
`).all();
|
||||
client.publish(`${BASE_TOPIC}/dashboard/push_erinnerungen`, String(offen.length), { retain: true });
|
||||
client.publish(`${BASE_TOPIC}/dashboard/push_erinnerungen_attributes`, JSON.stringify({
|
||||
erinnerungen: offen,
|
||||
}), { retain: true });
|
||||
}
|
||||
|
||||
// Wird jede Minute vom Scheduler aufgerufen — alle Kanban-Karten über alle
|
||||
// Nutzer-Boards hinweg (Kanban ist pro Nutzer, hier systemweit aggregiert)
|
||||
function publishKanban() {
|
||||
if (!client?.connected) return;
|
||||
const karten = db.prepare(`
|
||||
SELECT k.title, k.priority, col.title AS spalte, u.username
|
||||
FROM kanban_cards k
|
||||
JOIN kanban_columns col ON col.id = k.column_id
|
||||
JOIN users u ON u.id = k.user_id
|
||||
ORDER BY u.username ASC, col.position ASC, k.position ASC
|
||||
`).all();
|
||||
const proNutzer = {};
|
||||
for (const k of karten) proNutzer[k.username] = (proNutzer[k.username] || 0) + 1;
|
||||
|
||||
client.publish(`${BASE_TOPIC}/kanban/karten`, String(karten.length), { retain: true });
|
||||
client.publish(`${BASE_TOPIC}/kanban/karten_attributes`, JSON.stringify({
|
||||
karten,
|
||||
pro_nutzer: proNutzer,
|
||||
}), { retain: true });
|
||||
}
|
||||
|
||||
// Wird von index.js gesetzt, um Zirkel-Requires zu vermeiden
|
||||
function setKoepiCheckHandler(fn) { koepiCheckHandler = fn; }
|
||||
function setMediaAckHandler(fn) { mediaAckHandler = fn; }
|
||||
function setMediaAckAllHandler(fn) { mediaAckAllHandler = fn; }
|
||||
|
||||
module.exports = {
|
||||
connectMqtt,
|
||||
publishKoepiState,
|
||||
publishPresence,
|
||||
publishMediaAnfragen,
|
||||
publishDruckStatistik,
|
||||
publishPushErinnerungen,
|
||||
publishKanban,
|
||||
setKoepiCheckHandler,
|
||||
setMediaAckHandler,
|
||||
setMediaAckAllHandler,
|
||||
};
|
||||
@@ -1,6 +1,8 @@
|
||||
const express = require('express');
|
||||
const fs = require('fs');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const multer = require('multer');
|
||||
const db = require('../db');
|
||||
const { authenticate, requireAdmin } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
@@ -27,4 +29,475 @@ router.post('/restore', authenticate, requireAdmin, upload.single('database'), (
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/admin/users – alle Benutzer mit Statistiken
|
||||
router.get('/users', authenticate, requireAdmin, (req, res) => {
|
||||
const users = db.prepare(`
|
||||
SELECT u.id, u.username, u.role, u.created_at, u.last_active_at, u.hidden, u.hidden_tools,
|
||||
CASE WHEN p.user_id IS NOT NULL THEN 1 ELSE 0 END as has_pushover
|
||||
FROM users u
|
||||
LEFT JOIN pushover_settings p ON p.user_id = u.id
|
||||
ORDER BY u.role DESC, u.username
|
||||
`).all();
|
||||
const stats = users.map(u => {
|
||||
const archiv = db.prepare('SELECT COUNT(*) c FROM calculations WHERE user_id=?').get(u.id).c;
|
||||
const bestellung = db.prepare('SELECT COUNT(*) c FROM orders WHERE user_id=?').get(u.id).c;
|
||||
const snippets_c = db.prepare('SELECT COUNT(*) c FROM snippets WHERE user_id=?').get(u.id).c;
|
||||
const todos = db.prepare('SELECT COUNT(*) c FROM todos WHERE user_id=?').get(u.id).c;
|
||||
const links = db.prepare('SELECT COUNT(*) c FROM quick_links WHERE user_id=?').get(u.id).c;
|
||||
const files = db.prepare('SELECT COUNT(*) c FROM files WHERE user_id=?').get(u.id).c;
|
||||
const noteLen = (db.prepare('SELECT content FROM notes WHERE user_id=?').get(u.id)?.content || '').length;
|
||||
const icals = db.prepare('SELECT COUNT(*) c FROM calendar_feeds WHERE user_id=?').get(u.id).c;
|
||||
let hidden_tools = [];
|
||||
try { hidden_tools = JSON.parse(u.hidden_tools || '[]'); } catch {}
|
||||
return { ...u, hidden_tools, archiv, bestellung, snippets_c, todos, links, files, icals, noteLen };
|
||||
});
|
||||
res.json(stats);
|
||||
});
|
||||
|
||||
// POST /api/admin/users – neuen Benutzer anlegen
|
||||
router.post('/users', authenticate, requireAdmin, (req, res) => {
|
||||
const { username, password, role = 'user' } = req.body;
|
||||
if (!username?.trim() || username.trim().length < 3)
|
||||
return res.status(400).json({ error: 'Benutzername mind. 3 Zeichen' });
|
||||
if (!password || password.length < 8)
|
||||
return res.status(400).json({ error: 'Passwort mind. 8 Zeichen' });
|
||||
if (!['user','admin'].includes(role))
|
||||
return res.status(400).json({ error: 'Ungültige Rolle' });
|
||||
const exists = db.prepare('SELECT id FROM users WHERE username=?').get(username.trim());
|
||||
if (exists) return res.status(400).json({ error: 'Benutzername bereits vergeben' });
|
||||
const r = db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?,?,?)')
|
||||
.run(username.trim(), bcrypt.hashSync(password, 12), role);
|
||||
res.json({ id: r.lastInsertRowid, username: username.trim(), role });
|
||||
});
|
||||
|
||||
// DELETE /api/admin/users/:id – Benutzer löschen
|
||||
router.delete('/users/:id', authenticate, requireAdmin, (req, res) => {
|
||||
const id = parseInt(req.params.id);
|
||||
if (id === req.user.id) return res.status(400).json({ error: 'Du kannst dich nicht selbst löschen' });
|
||||
const user = db.prepare('SELECT * FROM users WHERE id=?').get(id);
|
||||
if (!user) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
|
||||
// Sicherstellen dass mindestens ein Admin bleibt
|
||||
if (user.role === 'admin') {
|
||||
const adminCount = db.prepare("SELECT COUNT(*) c FROM users WHERE role='admin'").get().c;
|
||||
if (adminCount <= 1) return res.status(400).json({ error: 'Mindestens ein Administrator muss verbleiben' });
|
||||
}
|
||||
// Cascade: alle Daten des Users löschen
|
||||
db.prepare('DELETE FROM calculations WHERE user_id=?').run(id);
|
||||
db.prepare('DELETE FROM orders WHERE user_id=?').run(id);
|
||||
db.prepare('DELETE FROM todos WHERE user_id=?').run(id);
|
||||
db.prepare('DELETE FROM quick_links WHERE user_id=?').run(id);
|
||||
db.prepare('DELETE FROM notes WHERE user_id=?').run(id);
|
||||
db.prepare('DELETE FROM users WHERE id=?').run(id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// PUT /api/admin/users/:id/reset-password – Passwort zurücksetzen
|
||||
router.put('/users/:id/reset-password', authenticate, requireAdmin, (req, res) => {
|
||||
const { newPassword } = req.body;
|
||||
if (!newPassword || newPassword.length < 8)
|
||||
return res.status(400).json({ error: 'Mind. 8 Zeichen' });
|
||||
const user = db.prepare('SELECT * FROM users WHERE id=?').get(req.params.id);
|
||||
if (!user) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
db.prepare('UPDATE users SET password_hash=? WHERE id=?').run(bcrypt.hashSync(newPassword, 12), req.params.id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── Login-Sicherheit Admin-Endpoints ─────────────────────────────────────────
|
||||
|
||||
const getSetting = k => db.prepare('SELECT value FROM admin_settings WHERE key=?').get(k)?.value;
|
||||
|
||||
// GET gesperrte Konten mit IPs
|
||||
router.get('/lockouts', authenticate, requireAdmin, (req, res) => {
|
||||
const locked = db.prepare(`
|
||||
SELECT id, username, failed_attempts, locked_until, last_failed_at
|
||||
FROM users
|
||||
WHERE locked_until IS NOT NULL
|
||||
ORDER BY locked_until DESC
|
||||
`).all();
|
||||
|
||||
// IPs aus login_attempts hinzufügen
|
||||
const result = locked.map(u => {
|
||||
const ips = db.prepare(`
|
||||
SELECT DISTINCT ip FROM login_attempts
|
||||
WHERE username=? AND success=0
|
||||
ORDER BY created_at DESC LIMIT 10
|
||||
`).all(u.username).map(r => r.ip).filter(Boolean);
|
||||
return { ...u, ips };
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// DELETE Sperre aufheben + Login-Versuche löschen
|
||||
router.delete('/lockouts/:userId', authenticate, requireAdmin, (req, res) => {
|
||||
const user = db.prepare('SELECT username FROM users WHERE id=?').get(req.params.userId);
|
||||
if (user) {
|
||||
db.prepare('DELETE FROM login_attempts WHERE username=?').run(user.username);
|
||||
}
|
||||
db.prepare('UPDATE users SET failed_attempts=0, locked_until=NULL, last_failed_at=NULL WHERE id=?')
|
||||
.run(req.params.userId);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// GET Login-Einstellungen
|
||||
router.get('/security-settings', authenticate, requireAdmin, (req, res) => {
|
||||
res.json({
|
||||
login_max_attempts: getSetting('login_max_attempts') || '5',
|
||||
login_lockout_minutes: getSetting('login_lockout_minutes') || '30',
|
||||
});
|
||||
});
|
||||
|
||||
// PUT Login-Einstellungen
|
||||
router.put('/security-settings', authenticate, requireAdmin, (req, res) => {
|
||||
const { login_max_attempts, login_lockout_minutes } = req.body;
|
||||
for (const [k, v] of [['login_max_attempts', login_max_attempts], ['login_lockout_minutes', login_lockout_minutes]]) {
|
||||
if (v !== undefined)
|
||||
db.prepare('INSERT OR REPLACE INTO admin_settings (key, value) VALUES (?, ?)').run(k, String(parseInt(v) || 0));
|
||||
}
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// GET Login-Verlauf (letzte 50)
|
||||
router.get('/login-log', authenticate, requireAdmin, (req, res) => {
|
||||
res.json(db.prepare(`
|
||||
SELECT * FROM login_attempts ORDER BY created_at DESC LIMIT 50
|
||||
`).all());
|
||||
});
|
||||
|
||||
// Toggle hidden
|
||||
router.put('/users/:id/hidden', authenticate, requireAdmin, (req, res) => {
|
||||
const user = db.prepare('SELECT * FROM users WHERE id=?').get(req.params.id);
|
||||
if (!user) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const newHidden = user.hidden ? 0 : 1;
|
||||
db.prepare('UPDATE users SET hidden=? WHERE id=?').run(newHidden, user.id);
|
||||
res.json({ hidden: newHidden });
|
||||
});
|
||||
|
||||
// PUT /api/admin/users/:id/hidden-tools – Sidebar-Sichtbarkeit für einen User setzen
|
||||
// body: { hidden_tools: ['schocken', 'paywallkiller', ...] } (tool-ids aus toolRegistry.js)
|
||||
router.put('/users/:id/hidden-tools', authenticate, requireAdmin, (req, res) => {
|
||||
const user = db.prepare('SELECT * FROM users WHERE id=?').get(req.params.id);
|
||||
if (!user) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const { hidden_tools } = req.body;
|
||||
if (!Array.isArray(hidden_tools)) return res.status(400).json({ error: 'hidden_tools muss ein Array sein' });
|
||||
const cleaned = [...new Set(hidden_tools.filter(t => typeof t === 'string'))];
|
||||
db.prepare('UPDATE users SET hidden_tools=? WHERE id=?').run(JSON.stringify(cleaned), user.id);
|
||||
res.json({ hidden_tools: cleaned });
|
||||
});
|
||||
|
||||
// Test-Pushover an beliebigen User senden
|
||||
router.post('/users/:id/test-push', authenticate, requireAdmin, async (req, res) => {
|
||||
const user = db.prepare('SELECT * FROM users WHERE id=?').get(req.params.id);
|
||||
if (!user) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const poCfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(user.id);
|
||||
if (!poCfg?.app_token || !poCfg?.user_key) return res.status(400).json({ error: 'Kein Pushover eingerichtet' });
|
||||
const { message = 'Wat is mit meinen Fische?' } = req.body;
|
||||
try {
|
||||
const r = await fetch('https://api.pushover.net/1/messages.json', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: poCfg.app_token, user: poCfg.user_key, title: '📣 DockStation', message, priority: 0 }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.status === 1) res.json({ ok: true });
|
||||
else res.status(500).json({ error: d.errors?.join(', ') || 'Pushover-Fehler' });
|
||||
} catch(e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── WebDAV / Synology NAS Einstellungen ───────────────────────────────────
|
||||
router.get('/webdav', authenticate, requireAdmin, (req, res) => {
|
||||
const get = k => db.prepare('SELECT value FROM admin_settings WHERE key=?').get(k)?.value || '';
|
||||
res.json({
|
||||
url: get('webdav_url'),
|
||||
user: get('webdav_user'),
|
||||
password: get('webdav_password'),
|
||||
basePath: get('webdav_base_path') || '/dickendock',
|
||||
enabled: get('webdav_enabled') === '1',
|
||||
});
|
||||
});
|
||||
|
||||
router.put('/webdav', authenticate, requireAdmin, (req, res) => {
|
||||
const { url, user, password, basePath, enabled } = req.body;
|
||||
const set = (k, v) => db.prepare('INSERT OR REPLACE INTO admin_settings (key,value) VALUES (?,?)').run(k, v || '');
|
||||
set('webdav_url', url);
|
||||
set('webdav_user', user);
|
||||
set('webdav_password', password);
|
||||
set('webdav_base_path', basePath || '/dickendock');
|
||||
set('webdav_enabled', enabled ? '1' : '0');
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post('/webdav/test', authenticate, requireAdmin, async (req, res) => {
|
||||
// Temporär die gesendeten Werte zum Testen nutzen (noch nicht gespeichert)
|
||||
const { url, user, password, basePath } = req.body;
|
||||
try {
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const base = new URL(url);
|
||||
const testPath = (base.pathname.replace(/\/$/, '') + (basePath || '/dickendock')).replace(/\/+/g, '/');
|
||||
const mod = base.protocol === 'https:' ? https : http;
|
||||
const auth = 'Basic ' + Buffer.from(`${user}:${password}`).toString('base64');
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const req2 = mod.request({
|
||||
hostname: base.hostname,
|
||||
port: base.port || (base.protocol === 'https:' ? 443 : 80),
|
||||
path: testPath,
|
||||
method: 'PROPFIND',
|
||||
headers: { 'Authorization': auth, 'Depth': '0', 'Content-Type': 'application/xml' },
|
||||
rejectUnauthorized: false,
|
||||
timeout: 8000,
|
||||
}, r => { r.resume(); r.statusCode < 400 || r.statusCode === 404 ? resolve(r.statusCode) : reject(new Error(`HTTP ${r.statusCode}`)); });
|
||||
req2.on('error', reject);
|
||||
req2.on('timeout', () => { req2.destroy(); reject(new Error('Timeout')); });
|
||||
req2.write('<?xml version="1.0"?><propfind xmlns="DAV:"><prop><displayname/></prop></propfind>');
|
||||
req2.end();
|
||||
});
|
||||
|
||||
res.json({ ok: true });
|
||||
} catch(e) {
|
||||
res.status(502).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── WebDAV Sync: alle lokalen Dateien auf NAS kopieren ────────────────────
|
||||
router.post('/webdav/sync', authenticate, requireAdmin, async (req, res) => {
|
||||
const webdav = require('../tools/dateien/webdav');
|
||||
const nodePath = require('path');
|
||||
const fs = require('fs');
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/data/uploads';
|
||||
|
||||
const cfg = webdav.getConfig();
|
||||
if (!cfg.enabled) return res.status(400).json({ error: 'WebDAV nicht aktiviert' });
|
||||
|
||||
const results = { folders: 0, ok: 0, failed: 0, errors: [] };
|
||||
|
||||
try {
|
||||
// ── Ordnerpfad rekursiv auflösen ───────────────────────────────────────
|
||||
function getFolderPath(folderId) {
|
||||
if (!folderId) return '';
|
||||
const parts = [];
|
||||
let cur = folderId;
|
||||
const seen = new Set();
|
||||
while (cur) {
|
||||
if (seen.has(cur)) break; seen.add(cur);
|
||||
const fo = db.prepare('SELECT name, parent_id FROM folders WHERE id=?').get(cur);
|
||||
if (!fo) break;
|
||||
parts.unshift(fo.name.replace(/[\/]/g, '_'));
|
||||
cur = fo.parent_id;
|
||||
}
|
||||
return parts.join('/');
|
||||
}
|
||||
|
||||
// ── Schritt 1: User-Basisordner + alle DB-Ordner anlegen ───────────────
|
||||
const users = db.prepare('SELECT id, username FROM users ORDER BY id').all();
|
||||
for (const user of users) {
|
||||
const userBase = webdav.userPath(cfg, user.username);
|
||||
try { await webdav.mkdirp(userBase); results.folders++; } catch {}
|
||||
try { await webdav.mkdirp(`${userBase}/_Upload-Freigaben`); } catch {}
|
||||
|
||||
// Ordner iterativ sortieren (flach, nach Tiefe)
|
||||
const allFolders = db.prepare('SELECT id, name, parent_id FROM folders WHERE user_id=? ORDER BY id').all(user.id);
|
||||
const depthCache = {};
|
||||
function depth(fid) {
|
||||
if (!fid) return 0;
|
||||
if (depthCache[fid] !== undefined) return depthCache[fid];
|
||||
const fo = allFolders.find(f => f.id === fid);
|
||||
depthCache[fid] = fo ? 1 + depth(fo.parent_id) : 0;
|
||||
return depthCache[fid];
|
||||
}
|
||||
const sorted = [...allFolders].sort((a, b) => depth(a.id) - depth(b.id));
|
||||
for (const folder of sorted) {
|
||||
const fp = getFolderPath(folder.id);
|
||||
if (fp) try { await webdav.mkdirp(`${userBase}/${fp}`); results.folders++; } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Schritt 2: Dateien hochladen ───────────────────────────────────────
|
||||
const files = db.prepare(`
|
||||
SELECT f.*, u.username FROM files f
|
||||
JOIN users u ON u.id = f.user_id
|
||||
ORDER BY f.user_id, f.folder_id, f.id
|
||||
`).all();
|
||||
|
||||
for (const file of files) {
|
||||
const localPath = nodePath.join(UPLOAD_DIR, file.filename);
|
||||
if (!fs.existsSync(localPath)) {
|
||||
results.failed++;
|
||||
results.errors.push(`${file.originalname}: lokale Datei fehlt`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const buf = fs.readFileSync(localPath);
|
||||
const fp = getFolderPath(file.folder_id);
|
||||
const base = webdav.userPath(cfg, file.username);
|
||||
const davPath = fp ? `${base}/${fp}/${file.originalname}` : `${base}/${file.originalname}`;
|
||||
await webdav.uploadFile(buf, davPath, file.mimetype || 'application/octet-stream');
|
||||
results.ok++;
|
||||
} catch(e) {
|
||||
results.failed++;
|
||||
results.errors.push(`${file.originalname}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
total: files.length,
|
||||
...results,
|
||||
message: `${results.folders} Ordner angelegt, ${results.ok} Dateien kopiert${results.failed ? ', ' + results.failed + ' fehlgeschlagen' : ' – alles erfolgreich ✓'}`,
|
||||
});
|
||||
|
||||
} catch(err) {
|
||||
console.error('[webdav sync error]', err);
|
||||
res.status(500).json({ error: err.message, ...results });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// ── Waisen aufräumen ──────────────────────────────────────────────────────
|
||||
// Findet und löscht:
|
||||
// 1. Dateien auf Disk die nicht in der DB sind (Disk-Waisen)
|
||||
// 2. DB-Einträge die keine Datei auf Disk haben (DB-Waisen)
|
||||
router.post('/cleanup-orphans', authenticate, requireAdmin, (req, res) => {
|
||||
const { dry_run = false } = req.body;
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/data/uploads';
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const results = {
|
||||
disk_orphans: [], // auf Disk aber nicht in DB
|
||||
db_orphans: [], // in DB aber nicht auf Disk
|
||||
disk_deleted: 0,
|
||||
db_deleted: 0,
|
||||
};
|
||||
|
||||
try {
|
||||
// Alle Dateinamen aus DB
|
||||
const dbFiles = db.prepare('SELECT id, filename, originalname, size FROM files').all();
|
||||
const dbFilenames = new Set(dbFiles.map(f => f.filename));
|
||||
|
||||
// Alle Dateien auf Disk
|
||||
const diskFiles = fs.readdirSync(UPLOAD_DIR).filter(f => !f.startsWith('.'));
|
||||
|
||||
// 1. Disk-Waisen: auf Disk aber nicht in DB
|
||||
for (const diskFile of diskFiles) {
|
||||
if (!dbFilenames.has(diskFile)) {
|
||||
const fullPath = path.join(UPLOAD_DIR, diskFile);
|
||||
const stat = fs.statSync(fullPath);
|
||||
results.disk_orphans.push({ filename: diskFile, size: stat.size });
|
||||
if (!dry_run) {
|
||||
try { fs.unlinkSync(fullPath); results.disk_deleted++; } catch(e) {
|
||||
console.error('[cleanup] disk unlink failed:', diskFile, e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. DB-Waisen: in DB aber nicht auf Disk
|
||||
const diskFileset = new Set(diskFiles);
|
||||
for (const dbFile of dbFiles) {
|
||||
if (!diskFileset.has(dbFile.filename)) {
|
||||
results.db_orphans.push({ id: dbFile.id, filename: dbFile.filename, originalname: dbFile.originalname });
|
||||
if (!dry_run) {
|
||||
db.prepare('DELETE FROM files WHERE id=?').run(dbFile.id);
|
||||
results.db_deleted++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
dry_run,
|
||||
disk_orphans_count: results.disk_orphans.length,
|
||||
db_orphans_count: results.db_orphans.length,
|
||||
disk_deleted: results.disk_deleted,
|
||||
db_deleted: results.db_deleted,
|
||||
disk_orphans: results.disk_orphans.slice(0, 50),
|
||||
db_orphans: results.db_orphans.slice(0, 50),
|
||||
});
|
||||
} catch(err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Disk-Analyse ──────────────────────────────────────────────────────────
|
||||
router.get('/disk-analysis', authenticate, requireAdmin, (req, res) => {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function dirSize(dirPath) {
|
||||
let total = 0;
|
||||
try {
|
||||
for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
|
||||
const full = path.join(dirPath, entry.name);
|
||||
if (entry.isDirectory()) total += dirSize(full);
|
||||
else try { total += fs.statSync(full).size; } catch {}
|
||||
}
|
||||
} catch {}
|
||||
return total;
|
||||
}
|
||||
|
||||
function fmt(b) {
|
||||
if (b < 1024) return b + ' B';
|
||||
if (b < 1024**2) return (b/1024).toFixed(1) + ' KB';
|
||||
if (b < 1024**3) return (b/1024**2).toFixed(1) + ' MB';
|
||||
return (b/1024**3).toFixed(2) + ' GB';
|
||||
}
|
||||
|
||||
// Alle relevanten Verzeichnisse scannen
|
||||
const locations = [
|
||||
'/data',
|
||||
'/data/uploads',
|
||||
'/tmp',
|
||||
'/app',
|
||||
'/root',
|
||||
'/var/log',
|
||||
'/var/lib/docker',
|
||||
];
|
||||
|
||||
const result = {};
|
||||
for (const loc of locations) {
|
||||
if (fs.existsSync(loc)) {
|
||||
result[loc] = { raw: dirSize(loc), fmt: fmt(dirSize(loc)) };
|
||||
}
|
||||
}
|
||||
|
||||
// DB-Größe
|
||||
const dbPath = process.env.DB_PATH || '/data/db.sqlite';
|
||||
if (fs.existsSync(dbPath)) {
|
||||
result['database'] = { raw: fs.statSync(dbPath).size, fmt: fmt(fs.statSync(dbPath).size) };
|
||||
}
|
||||
|
||||
// /tmp Details
|
||||
const tmpFiles = [];
|
||||
try {
|
||||
for (const f of fs.readdirSync('/tmp')) {
|
||||
try {
|
||||
const stat = fs.statSync('/tmp/' + f);
|
||||
if (stat.size > 1024*1024) tmpFiles.push({ name: f, size: fmt(stat.size) });
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Node modules Größe
|
||||
const nodeModules = '/app/node_modules';
|
||||
const nodeSize = fs.existsSync(nodeModules) ? dirSize(nodeModules) : 0;
|
||||
|
||||
// Top 10 größte Dateien in /data
|
||||
const bigFiles = [];
|
||||
function scanBig(dir) {
|
||||
try {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) scanBig(full);
|
||||
else try {
|
||||
const s = fs.statSync(full).size;
|
||||
if (s > 5*1024*1024) bigFiles.push({ path: full, size: fmt(s), raw: s });
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
scanBig('/data');
|
||||
bigFiles.sort((a,b) => b.raw - a.raw);
|
||||
|
||||
res.json({ locations: result, tmpFiles, nodeModulesMb: fmt(nodeSize), bigFiles: bigFiles.slice(0,20) });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -7,12 +7,99 @@ const { authenticate } = require('../middleware/auth');
|
||||
const router = express.Router();
|
||||
const SECRET = process.env.JWT_SECRET || 'dev-secret';
|
||||
|
||||
// POST /api/auth/login
|
||||
const getSetting = k => db.prepare('SELECT value FROM admin_settings WHERE key=?').get(k)?.value;
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
function logAttempt(username, ip, success) {
|
||||
db.prepare("INSERT INTO login_attempts (username, ip, success, created_at) VALUES (?,?,?,datetime('now','localtime'))")
|
||||
.run(username, ip || 'unknown', success ? 1 : 0);
|
||||
}
|
||||
|
||||
function getClientIp(req) {
|
||||
return req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.socket?.remoteAddress || 'unknown';
|
||||
}
|
||||
|
||||
function checkAndLock(user) {
|
||||
const maxAttempts = parseInt(getSetting('login_max_attempts') || '5');
|
||||
const lockoutMinutes = parseInt(getSetting('login_lockout_minutes') || '30');
|
||||
|
||||
// Gesperrt?
|
||||
if (user.locked_until) {
|
||||
const until = new Date(user.locked_until.replace(' ', 'T'));
|
||||
if (until > new Date()) return { locked: true, until };
|
||||
// Sperre abgelaufen → zurücksetzen
|
||||
db.prepare('UPDATE users SET failed_attempts=0, locked_until=NULL, last_failed_at=NULL WHERE id=?').run(user.id);
|
||||
}
|
||||
|
||||
// Fehlversuch zählen
|
||||
const newAttempts = (user.failed_attempts || 0) + 1;
|
||||
if (newAttempts >= maxAttempts) {
|
||||
db.prepare(`UPDATE users SET failed_attempts=?, locked_until=datetime('now','localtime','+${lockoutMinutes} minutes'), last_failed_at=datetime('now','localtime') WHERE id=?`)
|
||||
.run(newAttempts, user.id);
|
||||
const until = new Date(Date.now() + lockoutMinutes * 60 * 1000);
|
||||
return { locked: true, until, newLock: true };
|
||||
}
|
||||
db.prepare("UPDATE users SET failed_attempts=?, last_failed_at=datetime('now','localtime') WHERE id=?")
|
||||
.run(newAttempts, user.id);
|
||||
return { locked: false, remaining: maxAttempts - newAttempts };
|
||||
}
|
||||
|
||||
// ── POST /api/auth/login ──────────────────────────────────────────────────────
|
||||
router.post('/login', (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
const ip = getClientIp(req);
|
||||
|
||||
if (!username || !password)
|
||||
return res.status(400).json({ error: 'Benutzername und Passwort erforderlich' });
|
||||
|
||||
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
|
||||
if (!user || !bcrypt.compareSync(password, user.password_hash))
|
||||
|
||||
// Unbekannter User → trotzdem loggen (kein Timing-Angriff)
|
||||
if (!user) {
|
||||
logAttempt(username, ip, false);
|
||||
return res.status(401).json({ error: 'Benutzername oder Passwort falsch' });
|
||||
}
|
||||
|
||||
// Gesperrt?
|
||||
if (user.locked_until) {
|
||||
const until = new Date(user.locked_until.replace(' ', 'T'));
|
||||
if (until > new Date()) {
|
||||
logAttempt(username, ip, false);
|
||||
const min = Math.ceil((until - Date.now()) / 60000);
|
||||
return res.status(403).json({
|
||||
error: `Konto gesperrt. Noch ${min} Minute${min !== 1 ? 'n' : ''} warten.`,
|
||||
locked: true,
|
||||
lockedUntil: until.toISOString(),
|
||||
});
|
||||
}
|
||||
// Abgelaufen → zurücksetzen + Attempts löschen
|
||||
db.prepare('UPDATE users SET failed_attempts=0, locked_until=NULL, last_failed_at=NULL WHERE id=?').run(user.id);
|
||||
db.prepare('DELETE FROM login_attempts WHERE username=?').run(username);
|
||||
}
|
||||
|
||||
// Passwort prüfen
|
||||
const valid = bcrypt.compareSync(password, user.password_hash);
|
||||
logAttempt(username, ip, valid);
|
||||
|
||||
if (!valid) {
|
||||
const result = checkAndLock(user);
|
||||
if (result.locked) {
|
||||
const min = parseInt(getSetting('login_lockout_minutes') || '30');
|
||||
return res.status(403).json({
|
||||
error: result.newLock
|
||||
? `Zu viele Fehlversuche. Konto für ${min} Minuten gesperrt.`
|
||||
: `Konto gesperrt. Bitte später versuchen.`,
|
||||
locked: true,
|
||||
});
|
||||
}
|
||||
return res.status(401).json({
|
||||
error: `Benutzername oder Passwort falsch. Noch ${result.remaining} Versuch${result.remaining !== 1 ? 'e' : ''}.`,
|
||||
});
|
||||
}
|
||||
|
||||
// Erfolgreich → Zähler zurücksetzen + Attempts löschen
|
||||
db.prepare('UPDATE users SET failed_attempts=0, locked_until=NULL, last_failed_at=NULL WHERE id=?').run(user.id);
|
||||
db.prepare('DELETE FROM login_attempts WHERE username=?').run(username);
|
||||
|
||||
const token = jwt.sign(
|
||||
{ id: user.id, username: user.username, role: user.role },
|
||||
@@ -21,7 +108,7 @@ router.post('/login', (req, res) => {
|
||||
res.json({ token, user: { id: user.id, username: user.username, role: user.role } });
|
||||
});
|
||||
|
||||
// POST /api/auth/change-password
|
||||
// ── POST /api/auth/change-password ───────────────────────────────────────────
|
||||
router.post('/change-password', authenticate, (req, res) => {
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
|
||||
@@ -34,4 +121,59 @@ router.post('/change-password', authenticate, (req, res) => {
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── POST /api/auth/change-username ───────────────────────────────────────────
|
||||
router.post('/change-username', authenticate, (req, res) => {
|
||||
const { newUsername, password } = req.body;
|
||||
if (!newUsername || newUsername.trim().length < 3)
|
||||
return res.status(400).json({ error: 'Mind. 3 Zeichen' });
|
||||
const user = db.prepare('SELECT * FROM users WHERE id=?').get(req.user.id);
|
||||
if (!bcrypt.compareSync(password, user.password_hash))
|
||||
return res.status(400).json({ error: 'Passwort falsch' });
|
||||
const exists = db.prepare('SELECT id FROM users WHERE username=? AND id!=?').get(newUsername.trim(), req.user.id);
|
||||
if (exists) return res.status(400).json({ error: 'Benutzername bereits vergeben' });
|
||||
db.prepare('UPDATE users SET username=? WHERE id=?').run(newUsername.trim(), req.user.id);
|
||||
const token = jwt.sign({ id: user.id, username: newUsername.trim(), role: user.role }, SECRET, { expiresIn: '7d' });
|
||||
res.json({ token, user: { id: user.id, username: newUsername.trim(), role: user.role } });
|
||||
});
|
||||
|
||||
// ── DELETE /api/auth/account ──────────────────────────────────────────────────
|
||||
router.delete('/account', authenticate, (req, res) => {
|
||||
const { password } = req.body;
|
||||
const user = db.prepare('SELECT * FROM users WHERE id=?').get(req.user.id);
|
||||
if (!password || !bcrypt.compareSync(password, user.password_hash))
|
||||
return res.status(400).json({ error: 'Passwort falsch' });
|
||||
if (user.role === 'admin') {
|
||||
const adminCount = db.prepare("SELECT COUNT(*) c FROM users WHERE role='admin'").get().c;
|
||||
if (adminCount <= 1) return res.status(400).json({ error: 'Letzter Admin kann nicht gelöscht werden' });
|
||||
}
|
||||
db.prepare('DELETE FROM users WHERE id=?').run(req.user.id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── GET/PUT /api/auth/preferences ─────────────────────────────────────────────
|
||||
router.get('/preferences', authenticate, (req, res) => {
|
||||
const user = db.prepare('SELECT preferences FROM users WHERE id=?').get(req.user.id);
|
||||
try { res.json(JSON.parse(user?.preferences || '{}')); }
|
||||
catch { res.json({}); }
|
||||
});
|
||||
|
||||
router.put('/preferences', authenticate, (req, res) => {
|
||||
const user = db.prepare('SELECT preferences FROM users WHERE id=?').get(req.user.id);
|
||||
let current = {};
|
||||
try { current = JSON.parse(user?.preferences || '{}'); } catch {}
|
||||
const merged = { ...current, ...req.body };
|
||||
db.prepare('UPDATE users SET preferences=? WHERE id=?').run(JSON.stringify(merged), req.user.id);
|
||||
res.json(merged);
|
||||
});
|
||||
|
||||
// ── GET /api/auth/hidden-tools ────────────────────────────────────────────────
|
||||
// Liefert die vom Admin für diesen Nutzer ausgeblendeten Sidebar-Bereiche.
|
||||
// Bewusst NICHT über /preferences (das ist selbst-editierbar) — Sichtbarkeit
|
||||
// darf nur der Admin ändern.
|
||||
router.get('/hidden-tools', authenticate, (req, res) => {
|
||||
const user = db.prepare('SELECT hidden_tools FROM users WHERE id=?').get(req.user.id);
|
||||
try { res.json(JSON.parse(user?.hidden_tools || '[]')); }
|
||||
catch { res.json([]); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
69
backend/src/routes/calendar.js
Normal file
@@ -0,0 +1,69 @@
|
||||
const express = require('express');
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const db = require('../db');
|
||||
const { authenticate } = require('../middleware/auth');
|
||||
const router = express.Router();
|
||||
|
||||
// Fetch mit Redirect-Unterstützung
|
||||
function fetchWithRedirects(url, hops = 0) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (hops > 5) return reject(new Error('Zu viele Redirects'));
|
||||
let parsed;
|
||||
try { parsed = new URL(url); } catch { return reject(new Error('Ungültige URL')); }
|
||||
const lib = parsed.protocol === 'https:' ? https : http;
|
||||
const req = lib.get(url, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0 DickenDock/1.0', 'Accept': 'text/calendar, text/plain, */*' }
|
||||
}, res => {
|
||||
if ([301,302,303,307,308].includes(res.statusCode) && res.headers.location) {
|
||||
const next = res.headers.location.startsWith('http')
|
||||
? res.headers.location
|
||||
: new URL(res.headers.location, url).href;
|
||||
res.destroy();
|
||||
return fetchWithRedirects(next, hops + 1).then(resolve).catch(reject);
|
||||
}
|
||||
if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}`));
|
||||
let data = '';
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', d => { data += d; if (data.length > 3*1024*1024) { res.destroy(); reject(new Error('Feed zu groß')); } });
|
||||
res.on('end', () => resolve(data));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.setTimeout(10000, () => { req.destroy(); reject(new Error('Timeout')); });
|
||||
});
|
||||
}
|
||||
|
||||
// GET /fetch?url=...
|
||||
router.get('/fetch', authenticate, async (req, res) => {
|
||||
const { url } = req.query;
|
||||
if (!url) return res.status(400).json({ error: 'URL fehlt' });
|
||||
try { new URL(url); } catch { return res.status(400).json({ error: 'Ungültige URL' }); }
|
||||
try {
|
||||
const data = await fetchWithRedirects(url);
|
||||
if (!data.includes('BEGIN:VCALENDAR'))
|
||||
return res.status(502).json({ error: 'Kein gültiger iCal-Feed' });
|
||||
res.type('text/calendar; charset=utf-8').send(data);
|
||||
} catch(e) { res.status(502).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// GET /feeds
|
||||
router.get('/feeds', authenticate, (req, res) => {
|
||||
res.json(db.prepare('SELECT * FROM calendar_feeds WHERE user_id=? ORDER BY id').all(req.user.id));
|
||||
});
|
||||
|
||||
// POST /feeds
|
||||
router.post('/feeds', authenticate, (req, res) => {
|
||||
const { name, url, color='#4ecdc4' } = req.body;
|
||||
if (!name?.trim() || !url?.trim()) return res.status(400).json({ error: 'Name und URL erforderlich' });
|
||||
const r = db.prepare('INSERT INTO calendar_feeds (user_id,name,url,color) VALUES (?,?,?,?)').run(req.user.id, name.trim(), url.trim(), color);
|
||||
res.json(db.prepare('SELECT * FROM calendar_feeds WHERE id=?').get(r.lastInsertRowid));
|
||||
});
|
||||
|
||||
// DELETE /feeds/:id
|
||||
router.delete('/feeds/:id', authenticate, (req, res) => {
|
||||
const r = db.prepare('DELETE FROM calendar_feeds WHERE id=? AND user_id=?').run(req.params.id, req.user.id);
|
||||
if (!r.changes) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,12 +1,17 @@
|
||||
const express = require('express');
|
||||
const db = require('../db');
|
||||
const { authenticate } = require('../middleware/auth');
|
||||
const { authenticate, requireAdmin } = require('../middleware/auth');
|
||||
const router = express.Router();
|
||||
const uid = req => req.user.id;
|
||||
|
||||
// ── Quick Links ───────────────────────────────────────────────────────────────
|
||||
router.get('/links', authenticate, (req, res) => {
|
||||
res.json(db.prepare('SELECT * FROM quick_links WHERE user_id=? ORDER BY sort_order,id').all(uid(req)));
|
||||
const id = uid(req);
|
||||
// Rückwärtskomp: bestehende quick_links + neue link_list-Einträge mit in_quickaccess=1 + Ordner mit in_quickaccess=1
|
||||
const legacy = db.prepare("SELECT *, 'quicklink' as item_type FROM quick_links WHERE user_id=? ORDER BY sort_order,id").all(id);
|
||||
const listLinks = db.prepare("SELECT *, 'link_list' as item_type FROM link_list WHERE user_id=? AND in_quickaccess=1 ORDER BY sort_order,id").all(id);
|
||||
const folders = db.prepare("SELECT *, 'folder' as item_type FROM link_list_folders WHERE user_id=? AND in_quickaccess=1 ORDER BY sort_order,id").all(id);
|
||||
res.json([...legacy, ...listLinks, ...folders]);
|
||||
});
|
||||
router.post('/links', authenticate, (req, res) => {
|
||||
const { title, url, icon = '🔗' } = req.body;
|
||||
@@ -30,6 +35,14 @@ router.delete('/links/:id', authenticate, (req, res) => {
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.put('/links-order', authenticate, (req, res) => {
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids)) return res.status(400).json({ error: 'ids erforderlich' });
|
||||
const update = db.prepare('UPDATE quick_links SET sort_order=? WHERE id=? AND user_id=?');
|
||||
db.transaction(() => { ids.forEach((id, i) => update.run(i, id, uid(req))); })();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Todos ─────────────────────────────────────────────────────────────────────
|
||||
router.get('/todos', authenticate, (req, res) => {
|
||||
res.json(db.prepare('SELECT * FROM todos WHERE user_id=? ORDER BY done,sort_order,id').all(uid(req)));
|
||||
@@ -67,4 +80,251 @@ router.put('/note', authenticate, (req, res) => {
|
||||
res.json({ success: true, updated_at: new Date().toISOString() });
|
||||
});
|
||||
|
||||
|
||||
// ── Statistiken ───────────────────────────────────────────────────────────────
|
||||
router.get('/stats', authenticate, (req, res) => {
|
||||
const uid = req.user.id;
|
||||
|
||||
const orders = db.prepare(`
|
||||
SELECT status, bezahlt,
|
||||
SUM(COALESCE(custom_price_sum_view,0)) AS revenue
|
||||
FROM (
|
||||
SELECT o.status, o.bezahlt,
|
||||
SUM(COALESCE(oi.custom_price,0)*COALESCE(oi.stueckzahl,0)) AS custom_price_sum_view
|
||||
FROM orders o
|
||||
LEFT JOIN order_items oi ON oi.order_id=o.id
|
||||
WHERE o.user_id=?
|
||||
GROUP BY o.id
|
||||
)
|
||||
GROUP BY status, bezahlt
|
||||
`).all(uid);
|
||||
|
||||
const totalOrders = db.prepare('SELECT COUNT(*) c FROM orders WHERE user_id=?').get(uid).c;
|
||||
const bezahltOrders = db.prepare('SELECT COUNT(*) c FROM orders WHERE user_id=? AND bezahlt=1 AND (abgeholt=0 OR abgeholt IS NULL)').get(uid).c;
|
||||
// Revenue: Gesamt-Festpreis hat Priorität, sonst Summe der Positions-Festpreise
|
||||
const paidOrders = db.prepare('SELECT * FROM orders WHERE user_id=? AND bezahlt=1').all(uid);
|
||||
let totalRevenue = 0;
|
||||
for (const o of paidOrders) {
|
||||
if (o.custom_price != null) {
|
||||
totalRevenue += parseFloat(o.custom_price);
|
||||
} else {
|
||||
const sum = db.prepare('SELECT COALESCE(SUM(custom_price*stueckzahl),0) s FROM order_items WHERE order_id=? AND custom_price IS NOT NULL').get(o.id).s;
|
||||
totalRevenue += sum;
|
||||
}
|
||||
}
|
||||
|
||||
const openOrders = db.prepare("SELECT * FROM orders WHERE user_id=? AND bezahlt=0 AND status!='warteliste'").all(uid);
|
||||
let offeneRevenue = 0;
|
||||
for (const o of openOrders) {
|
||||
if (o.custom_price != null) {
|
||||
offeneRevenue += parseFloat(o.custom_price);
|
||||
} else {
|
||||
const sum = db.prepare('SELECT COALESCE(SUM(custom_price*stueckzahl),0) s FROM order_items WHERE order_id=? AND custom_price IS NOT NULL').get(o.id).s;
|
||||
offeneRevenue += sum;
|
||||
}
|
||||
}
|
||||
const thisMonth = new Date();
|
||||
thisMonth.setDate(1); thisMonth.setHours(0,0,0,0);
|
||||
const ordersThisMonth = db.prepare('SELECT COUNT(*) c FROM orders WHERE user_id=? AND created_at>=?').get(uid, thisMonth.toISOString()).c;
|
||||
const totalCalcs = db.prepare('SELECT COUNT(*) c FROM calculations WHERE user_id=?').get(uid).c;
|
||||
|
||||
const byStatus = { warteliste:0, in_arbeit:0, fertig:0 };
|
||||
for (const r of db.prepare('SELECT status, COUNT(*) c FROM orders WHERE user_id=? AND bezahlt=0 GROUP BY status').all(uid)) {
|
||||
byStatus[r.status] = r.c;
|
||||
}
|
||||
|
||||
// Grundkosten berechnen (preis_freundschaft = Selbstkosten pro Stück aus Kalkulation)
|
||||
function getBaseCost(orderId) {
|
||||
const items = db.prepare(`
|
||||
SELECT oi.stueckzahl, oi.custom_price,
|
||||
COALESCE(c.preis_freundschaft, oi.preis_freundschaft) as basis
|
||||
FROM order_items oi
|
||||
LEFT JOIN calculations c ON c.id = oi.calculation_id
|
||||
WHERE oi.order_id = ?
|
||||
`).all(orderId);
|
||||
return items.reduce((sum, i) => sum + (i.basis * i.stueckzahl), 0);
|
||||
}
|
||||
|
||||
function getRevenue(order) {
|
||||
if (order.custom_price != null) return parseFloat(order.custom_price);
|
||||
return db.prepare('SELECT COALESCE(SUM(custom_price*stueckzahl),0) s FROM order_items WHERE order_id=? AND custom_price IS NOT NULL').get(order.id).s;
|
||||
}
|
||||
|
||||
let totalProfit = 0;
|
||||
for (const o of paidOrders) {
|
||||
const rev = getRevenue(o);
|
||||
const cost = getBaseCost(o.id);
|
||||
totalProfit += rev - cost;
|
||||
}
|
||||
|
||||
let offeneProfit = 0;
|
||||
for (const o of openOrders) {
|
||||
const rev = getRevenue(o);
|
||||
const cost = getBaseCost(o.id);
|
||||
offeneProfit += rev - cost;
|
||||
}
|
||||
|
||||
res.json({ totalOrders, bezahltOrders, totalRevenue, offeneRevenue, ordersThisMonth, totalCalcs, byStatus, totalProfit, offeneProfit });
|
||||
});
|
||||
|
||||
// ── Ideen-Board ───────────────────────────────────────────────────────────────
|
||||
router.get('/board', authenticate, (req, res) => {
|
||||
const u = uid(req);
|
||||
const items = db.prepare(`
|
||||
SELECT b.*, us.username as author
|
||||
FROM board_items b JOIN users us ON us.id=b.user_id
|
||||
ORDER BY b.type ASC, b.created_at DESC
|
||||
`).all();
|
||||
|
||||
const lastRead = db.prepare('SELECT last_read FROM board_reads WHERE user_id=?').get(u)?.last_read;
|
||||
const unread = lastRead
|
||||
? db.prepare("SELECT COUNT(*) c FROM board_items WHERE created_at > ? AND user_id != ?").get(lastRead, u).c
|
||||
: db.prepare('SELECT COUNT(*) c FROM board_items WHERE user_id != ?').get(u).c;
|
||||
|
||||
res.json({ items, unread });
|
||||
});
|
||||
|
||||
router.get('/board/unread', authenticate, (req, res) => {
|
||||
const u = uid(req);
|
||||
const lastRead = db.prepare('SELECT last_read FROM board_reads WHERE user_id=?').get(u)?.last_read;
|
||||
const unread = lastRead
|
||||
? db.prepare("SELECT COUNT(*) c FROM board_items WHERE created_at > ? AND user_id != ?").get(lastRead, u).c
|
||||
: db.prepare('SELECT COUNT(*) c FROM board_items WHERE user_id != ?').get(u).c;
|
||||
const unreadPromoted = lastRead
|
||||
? db.prepare("SELECT COUNT(*) c FROM board_items WHERE promoted_at > ?").get(lastRead).c
|
||||
: db.prepare("SELECT COUNT(*) c FROM board_items WHERE promoted_at IS NOT NULL").get().c;
|
||||
res.json({ unread, unreadPromoted });
|
||||
});
|
||||
|
||||
router.post('/board/read', authenticate, (req, res) => {
|
||||
const u = uid(req);
|
||||
db.prepare(`INSERT OR REPLACE INTO board_reads (user_id, last_read) VALUES (?, datetime('now','localtime'))`).run(u);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post('/board', authenticate, (req, res) => {
|
||||
const u = uid(req);
|
||||
const { type, title, description = '' } = req.body;
|
||||
if (!['roadmap','wish'].includes(type)) return res.status(400).json({ error: 'Ungültiger Typ' });
|
||||
if (type === 'roadmap' && req.user.role !== 'admin') return res.status(403).json({ error: 'Nur Admins' });
|
||||
if (!title?.trim()) return res.status(400).json({ error: 'Titel erforderlich' });
|
||||
const r = db.prepare(`INSERT INTO board_items (type, user_id, title, description, created_at) VALUES (?,?,?,?,datetime('now','localtime'))`)
|
||||
.run(type, u, title.trim(), description.trim());
|
||||
res.json({ ...db.prepare('SELECT * FROM board_items WHERE id=?').get(r.lastInsertRowid),
|
||||
author: req.user.username });
|
||||
});
|
||||
|
||||
router.put('/board/:id', authenticate, (req, res) => {
|
||||
const item = db.prepare('SELECT * FROM board_items WHERE id=?').get(req.params.id);
|
||||
if (!item) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const isAdmin = req.user.role === 'admin';
|
||||
const isOwn = item.user_id === uid(req);
|
||||
if (!isAdmin && (!isOwn || item.type === 'roadmap')) return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
const { title, description } = req.body;
|
||||
db.prepare('UPDATE board_items SET title=?, description=? WHERE id=?')
|
||||
.run(title ?? item.title, description ?? item.description, item.id);
|
||||
res.json({ ...db.prepare('SELECT * FROM board_items WHERE id=?').get(item.id), author: req.user.username });
|
||||
});
|
||||
|
||||
router.delete('/board/:id', authenticate, (req, res) => {
|
||||
const item = db.prepare('SELECT * FROM board_items WHERE id=?').get(req.params.id);
|
||||
if (!item) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const isAdmin = req.user.role === 'admin';
|
||||
const isOwn = item.user_id === uid(req);
|
||||
if (!isAdmin && !isOwn) return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
db.prepare('DELETE FROM board_items WHERE id=?').run(item.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Wunsch zur Roadmap befördern (Admin only)
|
||||
router.post('/board/:id/promote', authenticate, (req, res) => {
|
||||
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
const item = db.prepare('SELECT * FROM board_items WHERE id=?').get(req.params.id);
|
||||
if (!item) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
if (item.type !== 'wish') return res.status(400).json({ error: 'Nur Wünsche können befördert werden' });
|
||||
db.prepare("UPDATE board_items SET type='roadmap', promoted_from_wish=1, promoted_at=datetime('now','localtime') WHERE id=?").run(item.id);
|
||||
const updated = db.prepare(`
|
||||
SELECT b.*, u.username as author FROM board_items b
|
||||
JOIN users u ON u.id=b.user_id WHERE b.id=?
|
||||
`).get(item.id);
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
// ── Changelog ─────────────────────────────────────────────────────────────────
|
||||
router.get('/changelog', authenticate, (req, res) => {
|
||||
res.json(db.prepare('SELECT * FROM changelog ORDER BY created_at DESC').all());
|
||||
});
|
||||
|
||||
router.get('/changelog/unread', authenticate, (req, res) => {
|
||||
const u = req.user.id;
|
||||
const lastRead = db.prepare('SELECT last_read FROM changelog_reads WHERE user_id=?').get(u)?.last_read;
|
||||
const count = lastRead
|
||||
? db.prepare('SELECT COUNT(*) c FROM changelog WHERE created_at > ?').get(lastRead).c
|
||||
: db.prepare('SELECT COUNT(*) c FROM changelog').get().c;
|
||||
res.json({ unread: count });
|
||||
});
|
||||
|
||||
router.post('/changelog/read', authenticate, (req, res) => {
|
||||
db.prepare(`INSERT OR REPLACE INTO changelog_reads (user_id, last_read) VALUES (?, datetime('now','localtime'))`)
|
||||
.run(req.user.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post('/changelog', authenticate, requireAdmin, (req, res) => {
|
||||
const { version, title, body, build_time } = req.body;
|
||||
if (!version?.trim() || !title?.trim()) return res.status(400).json({ error: 'Version und Titel erforderlich' });
|
||||
const r = db.prepare(`INSERT INTO changelog (version, title, body, build_time, created_at) VALUES (?,?,?,?,datetime('now','localtime'))`)
|
||||
.run(version.trim(), title.trim(), body?.trim() || '', build_time || null);
|
||||
res.json(db.prepare('SELECT * FROM changelog WHERE id=?').get(r.lastInsertRowid));
|
||||
});
|
||||
|
||||
router.delete('/changelog/:id', authenticate, requireAdmin, (req, res) => {
|
||||
db.prepare('DELETE FROM changelog WHERE id=?').run(req.params.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Push-Zeitplaner ───────────────────────────────────────────────────────────
|
||||
router.get('/push-schedules', authenticate, (req, res) => {
|
||||
const rows = db.prepare(`
|
||||
SELECT * FROM push_schedules
|
||||
WHERE user_id=? AND sent=0 AND scheduled_at > datetime('now','localtime')
|
||||
ORDER BY scheduled_at ASC
|
||||
`).all(uid(req));
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
router.post('/push-schedules', authenticate, (req, res) => {
|
||||
const { message, scheduled_at } = req.body;
|
||||
if (!message?.trim() || !scheduled_at) return res.status(400).json({ error: 'Nachricht und Zeitpunkt erforderlich' });
|
||||
// Normalisieren: T→Leerzeichen, Sekunden ergänzen
|
||||
const normalized = scheduled_at.replace('T',' ') + (scheduled_at.length <= 16 ? ':00' : '');
|
||||
const r = db.prepare(`
|
||||
INSERT INTO push_schedules (user_id, message, scheduled_at, created_at)
|
||||
VALUES (?, ?, ?, datetime('now','localtime'))
|
||||
`).run(uid(req), message.trim(), normalized);
|
||||
res.json(db.prepare('SELECT * FROM push_schedules WHERE id=?').get(r.lastInsertRowid));
|
||||
});
|
||||
|
||||
router.put('/push-schedules/:id', authenticate, (req, res) => {
|
||||
const row = db.prepare('SELECT * FROM push_schedules WHERE id=? AND user_id=?').get(req.params.id, uid(req));
|
||||
if (!row) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const { message, scheduled_at } = req.body;
|
||||
const normalized = scheduled_at ? scheduled_at.replace('T',' ') + (scheduled_at.length <= 16 ? ':00' : '') : row.scheduled_at;
|
||||
db.prepare('UPDATE push_schedules SET message=?, scheduled_at=?, sent=0 WHERE id=?')
|
||||
.run(message ?? row.message, normalized, row.id);
|
||||
res.json(db.prepare('SELECT * FROM push_schedules WHERE id=?').get(row.id));
|
||||
});
|
||||
|
||||
router.delete('/push-schedules/:id', authenticate, (req, res) => {
|
||||
db.prepare('DELETE FROM push_schedules WHERE id=? AND user_id=?').run(req.params.id, uid(req));
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Unquittierte Favoriten für Admin-Dashboard-Badge
|
||||
router.get('/media-favorites/unacked', authenticate, (req, res) => {
|
||||
if (req.user.role !== 'admin') return res.json({ count: 0 });
|
||||
const count = db.prepare('SELECT COUNT(*) as n FROM movie_favorites WHERE acknowledged=0').get();
|
||||
res.json({ count: count.n });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
260
backend/src/routes/search.js
Normal file
@@ -0,0 +1,260 @@
|
||||
const express = require('express');
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const db = require('../db');
|
||||
const { authenticate } = require('../middleware/auth');
|
||||
const router = express.Router();
|
||||
|
||||
const safe = fn => { try { return fn(); } catch { return []; } };
|
||||
const safeGet = fn => { try { return fn(); } catch { return null; } };
|
||||
|
||||
// ── iCal Fetch + Parse ────────────────────────────────────────────────────────
|
||||
function fetchIcal(url) {
|
||||
return new Promise(resolve => {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const lib = parsed.protocol === 'https:' ? https : http;
|
||||
const req = lib.get(url, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0 DickenDock/1.0', 'Accept': 'text/calendar,*/*' }
|
||||
}, res => {
|
||||
// Redirects verfolgen
|
||||
if ([301,302,303,307,308].includes(res.statusCode) && res.headers.location) {
|
||||
res.destroy();
|
||||
const next = res.headers.location.startsWith('http')
|
||||
? res.headers.location : new URL(res.headers.location, url).href;
|
||||
fetchIcal(next).then(resolve);
|
||||
return;
|
||||
}
|
||||
let data = '';
|
||||
res.on('data', d => { data += d; if (data.length > 1e6) { res.destroy(); resolve(null); } });
|
||||
res.on('end', () => resolve(data.includes('BEGIN:VCALENDAR') ? data : null));
|
||||
});
|
||||
req.on('error', () => resolve(null));
|
||||
req.setTimeout(10000, () => { req.destroy(); resolve(null); });
|
||||
} catch { resolve(null); }
|
||||
});
|
||||
}
|
||||
|
||||
function parseIcal(raw) {
|
||||
const events = [];
|
||||
const parts = raw.split('BEGIN:VEVENT');
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
const b = parts[i];
|
||||
const get = key => {
|
||||
// Handle folded lines and key variants like DTSTART;TZID=...
|
||||
const m = b.match(new RegExp(key + '[^:]*:([^\\r\\n]+)'));
|
||||
return m ? m[1].replace(/\\n/g,' ').replace(/\\,/g,',').replace(/\\;/g,';').trim() : '';
|
||||
};
|
||||
const summary = get('SUMMARY');
|
||||
if (!summary) continue;
|
||||
events.push({ summary, description:get('DESCRIPTION'), location:get('LOCATION'), start:get('DTSTART'), end:get('DTEND') });
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
// ── Kalender-Sync Endpoint (POST /api/search/sync-calendar) ──────────────────
|
||||
// Frontend ruft das beim Dashboard-Laden auf — kein Sync beim Suchen selbst
|
||||
router.post('/sync-calendar', authenticate, async (req, res) => {
|
||||
const uid = req.user.id;
|
||||
const force = req.body?.force === true;
|
||||
|
||||
const feeds = safeGet(() => db.prepare('SELECT * FROM calendar_feeds WHERE user_id=?').all(uid)) || [];
|
||||
if (!feeds.length) return res.json({ ok: true, synced: 0, message: 'Keine Kalender-Abos vorhanden' });
|
||||
|
||||
// Alter des Caches prüfen
|
||||
// Alte Cache-Prüfung: Wenn noch historische Events drin (start_dt < 2000), force-sync
|
||||
const hasOldData = safeGet(() =>
|
||||
db.prepare("SELECT 1 FROM calendar_event_cache WHERE user_id=? AND start_dt < '20200101' LIMIT 1").get(uid)
|
||||
);
|
||||
if (!force && !hasOldData) {
|
||||
const lastSync = safeGet(() =>
|
||||
db.prepare("SELECT MAX(synced_at) as s FROM calendar_event_cache WHERE user_id=?").get(uid)?.s
|
||||
);
|
||||
if (lastSync && (Date.now() - new Date(lastSync).getTime()) < 10 * 60 * 1000) {
|
||||
return res.json({ ok: true, synced: 0, message: 'Cache noch frisch' });
|
||||
}
|
||||
}
|
||||
|
||||
let totalSynced = 0;
|
||||
const errors = [];
|
||||
|
||||
for (const feed of feeds) {
|
||||
try {
|
||||
const raw = await fetchIcal(feed.url);
|
||||
if (!raw) { errors.push(`${feed.name}: kein gültiger iCal-Feed`); continue; }
|
||||
const events = parseIcal(raw);
|
||||
if (!events.length) { errors.push(`${feed.name}: 0 Events geparst`); continue; }
|
||||
|
||||
// Nur zukünftige Events speichern (ab gestern rückwärts 1 Tag für Tagesevents)
|
||||
// start_dt Format: YYYYMMDD oder YYYYMMDDTHHmmssZ → string-vergleichbar
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const minDt = yesterday.toISOString().replace(/-/g,'').slice(0,8); // '20260604'
|
||||
const futureEvents = events.filter(ev => !ev.start || ev.start >= minDt);
|
||||
|
||||
// prepare INSIDE try-catch – wirft wenn Tabelle nicht existiert
|
||||
const ins = db.prepare(`
|
||||
INSERT INTO calendar_event_cache
|
||||
(feed_id, user_id, summary, description, location, start_dt, end_dt, synced_at)
|
||||
VALUES (?,?,?,?,?,?,?,datetime('now','localtime'))
|
||||
`);
|
||||
db.transaction(() => {
|
||||
db.prepare('DELETE FROM calendar_event_cache WHERE feed_id=?').run(feed.id);
|
||||
futureEvents.slice(0, 500).forEach(ev =>
|
||||
ins.run(feed.id, uid, ev.summary||'', ev.description||'', ev.location||'', ev.start||'', ev.end||'')
|
||||
);
|
||||
})();
|
||||
totalSynced += futureEvents.length;
|
||||
} catch(e) {
|
||||
console.error(`[CalendarSync] Feed "${feed.name}" (${feed.url}): ${e.message}`);
|
||||
errors.push(`${feed.name}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[CalendarSync] uid=${uid} synced=${totalSynced} feeds=${feeds.length} errors=${errors.length}`);
|
||||
res.json({ ok: true, synced: totalSynced, feeds: feeds.length, errors });
|
||||
});
|
||||
|
||||
// ── Suche ─────────────────────────────────────────────────────────────────────
|
||||
router.get('/', authenticate, (req, res) => {
|
||||
const q = (req.query.q || '').trim();
|
||||
const uid = req.user.id;
|
||||
|
||||
if (q.length < 2) return res.json({
|
||||
links:[], folders:[], snippets:[], calculations:[], orders:[],
|
||||
todos:[], notes:[], board_items:[], changelog:[],
|
||||
files:[], file_folders:[], push_schedules:[],
|
||||
calendar_feeds:[], calendar_events:[], qr_codes:[]
|
||||
});
|
||||
|
||||
const like = `%${q.toLowerCase()}%`;
|
||||
|
||||
const links = safe(() => db.prepare(`
|
||||
SELECT id, title, url, icon, description FROM link_list
|
||||
WHERE user_id=? AND (LOWER(title) LIKE ? OR LOWER(url) LIKE ? OR LOWER(description) LIKE ?)
|
||||
ORDER BY title LIMIT 8
|
||||
`).all(uid, like, like, like));
|
||||
|
||||
const folders = safe(() => db.prepare(`
|
||||
SELECT id, name, icon FROM link_list_folders
|
||||
WHERE user_id=? AND LOWER(name) LIKE ? ORDER BY name LIMIT 5
|
||||
`).all(uid, like));
|
||||
|
||||
const snippets = safe(() => db.prepare(`
|
||||
SELECT id, title, description, language FROM snippets
|
||||
WHERE user_id=? AND (LOWER(title) LIKE ? OR LOWER(description) LIKE ?)
|
||||
ORDER BY title LIMIT 6
|
||||
`).all(uid, like, like));
|
||||
|
||||
const calculations = safe(() => db.prepare(`
|
||||
SELECT id, name FROM calculations
|
||||
WHERE user_id=? AND LOWER(name) LIKE ? ORDER BY name LIMIT 5
|
||||
`).all(uid, like));
|
||||
|
||||
const orders = safe(() => db.prepare(`
|
||||
SELECT id, name, status FROM orders
|
||||
WHERE user_id=? AND LOWER(name) LIKE ? ORDER BY name LIMIT 5
|
||||
`).all(uid, like));
|
||||
|
||||
const todos = safe(() => db.prepare(`
|
||||
SELECT id, text, done FROM todos
|
||||
WHERE user_id=? AND LOWER(text) LIKE ?
|
||||
ORDER BY done, created_at DESC LIMIT 6
|
||||
`).all(uid, like));
|
||||
|
||||
const noteRow = safeGet(() => db.prepare(
|
||||
'SELECT content FROM notes WHERE user_id=? AND LOWER(content) LIKE ?'
|
||||
).get(uid, like));
|
||||
const notes = noteRow ? [(() => {
|
||||
const idx = noteRow.content.toLowerCase().indexOf(q.toLowerCase());
|
||||
const s = Math.max(0, idx - 30);
|
||||
return { id:'note', preview: (s>0?'…':'') + noteRow.content.slice(s, idx+60) + '…' };
|
||||
})()] : [];
|
||||
|
||||
const board_items = safe(() => db.prepare(`
|
||||
SELECT id, title, description, type FROM board_items
|
||||
WHERE user_id=? AND (LOWER(title) LIKE ? OR LOWER(description) LIKE ?)
|
||||
ORDER BY created_at DESC LIMIT 5
|
||||
`).all(uid, like, like));
|
||||
|
||||
const changelog = safe(() => db.prepare(`
|
||||
SELECT id, version, title, body FROM changelog
|
||||
WHERE LOWER(title) LIKE ? OR LOWER(body) LIKE ?
|
||||
ORDER BY created_at DESC LIMIT 4
|
||||
`).all(like, like));
|
||||
|
||||
const files = safe(() => db.prepare(`
|
||||
SELECT id, originalname, mimetype, size FROM files
|
||||
WHERE user_id=? AND (LOWER(originalname) LIKE ? OR LOWER(filename) LIKE ?)
|
||||
ORDER BY originalname LIMIT 6
|
||||
`).all(uid, like, like));
|
||||
|
||||
const file_folders = safe(() => db.prepare(`
|
||||
SELECT id, name FROM folders
|
||||
WHERE user_id=? AND LOWER(name) LIKE ? ORDER BY name LIMIT 5
|
||||
`).all(uid, like));
|
||||
|
||||
const push_schedules = safe(() => db.prepare(`
|
||||
SELECT id, message, scheduled_at FROM push_schedules
|
||||
WHERE user_id=? AND LOWER(message) LIKE ? AND sent=0
|
||||
ORDER BY scheduled_at LIMIT 5
|
||||
`).all(uid, like));
|
||||
|
||||
const calendar_feeds = safe(() => db.prepare(`
|
||||
SELECT id, name, url FROM calendar_feeds
|
||||
WHERE user_id=? AND (LOWER(name) LIKE ? OR LOWER(url) LIKE ?)
|
||||
ORDER BY name LIMIT 4
|
||||
`).all(uid, like, like));
|
||||
|
||||
const calendar_events = safe(() => db.prepare(`
|
||||
SELECT c.id, c.summary, c.description, c.location, c.start_dt, f.name as feed_name
|
||||
FROM calendar_event_cache c
|
||||
JOIN calendar_feeds f ON f.id=c.feed_id
|
||||
WHERE c.user_id=? AND f.user_id=?
|
||||
AND (LOWER(c.summary) LIKE ? OR LOWER(c.description) LIKE ? OR LOWER(c.location) LIKE ?)
|
||||
AND c.start_dt >= strftime('%Y%m%d', 'now')
|
||||
ORDER BY c.start_dt LIMIT 8
|
||||
`).all(uid, uid, like, like, like));
|
||||
|
||||
const qr_codes = safe(() => db.prepare(`
|
||||
SELECT id, label, url FROM qr_codes
|
||||
WHERE user_id=? AND (LOWER(label) LIKE ? OR LOWER(url) LIKE ?)
|
||||
ORDER BY created_at DESC LIMIT 5
|
||||
`).all(uid, like, like));
|
||||
|
||||
// Gebietseroberung-Spiele
|
||||
const geo_games = safe(() => db.prepare(`
|
||||
SELECT g.id, u1.username as owner_name, u2.username as opp_name, g.status, g.current_turn
|
||||
FROM geo_games g
|
||||
JOIN users u1 ON u1.id=g.owner_id
|
||||
JOIN users u2 ON u2.id=g.opponent_id
|
||||
WHERE (g.owner_id=? OR g.opponent_id=?)
|
||||
AND (LOWER(u1.username) LIKE ? OR LOWER(u2.username) LIKE ?)
|
||||
ORDER BY g.updated_at DESC LIMIT 5
|
||||
`).all(uid, uid, like, like));
|
||||
|
||||
// Andere Benutzer – für Chat-Navigation (nur Benutzernamen, keine sensiblen Daten)
|
||||
const users = safe(() => db.prepare(`
|
||||
SELECT id, username FROM users
|
||||
WHERE id != ? AND LOWER(username) LIKE ?
|
||||
ORDER BY username LIMIT 5
|
||||
`).all(uid, like));
|
||||
|
||||
res.json({ links, folders, snippets, calculations, orders, todos, notes,
|
||||
board_items, changelog, files, file_folders, push_schedules,
|
||||
calendar_feeds, calendar_events, qr_codes, users, geo_games });
|
||||
});
|
||||
|
||||
// Debug-Endpoint: Kalender-Cache inspizieren
|
||||
router.get('/calendar-debug', authenticate, (req, res) => {
|
||||
const uid = req.user.id;
|
||||
const count = db.prepare('SELECT COUNT(*) as n FROM calendar_event_cache WHERE user_id=?').get(uid);
|
||||
const samples = db.prepare('SELECT summary, start_dt FROM calendar_event_cache WHERE user_id=? ORDER BY start_dt LIMIT 10').all(uid);
|
||||
const today = db.prepare("SELECT strftime('%Y%m%d','now') as d").get();
|
||||
const future = db.prepare("SELECT COUNT(*) as n FROM calendar_event_cache WHERE user_id=? AND start_dt >= strftime('%Y%m%d','now')").get(uid);
|
||||
const search = db.prepare("SELECT summary, start_dt FROM calendar_event_cache WHERE user_id=? AND LOWER(summary) LIKE '%arzt%'").all(uid);
|
||||
res.json({ count: count.n, today: today.d, future_count: future.n, samples, arzt_matches: search });
|
||||
});
|
||||
|
||||
|
||||
module.exports = router;
|
||||
169
backend/src/tools/bestellungen/routes.js
Normal file
@@ -0,0 +1,169 @@
|
||||
const express = require('express');
|
||||
const db = require('../../db');
|
||||
const { authenticate } = require('../../middleware/auth');
|
||||
const router = express.Router();
|
||||
const uid = req => req.user.id;
|
||||
|
||||
// Status der Bestellung automatisch aus den Stück-Zählern berechnen
|
||||
const autoUpdateOrderStatus = (orderId) => {
|
||||
const items = db.prepare('SELECT * FROM order_items WHERE order_id=?').all(orderId);
|
||||
if (!items.length) return;
|
||||
const totalPieces = items.reduce((s,i) => s + (i.stueckzahl||0), 0);
|
||||
const fertigPieces = items.reduce((s,i) => s + (i.qty_fertig||0), 0);
|
||||
const arbeitPieces = items.reduce((s,i) => s + (i.qty_in_arbeit||0), 0);
|
||||
let newStatus = 'warteliste';
|
||||
if (fertigPieces >= totalPieces && totalPieces > 0) newStatus = 'fertig';
|
||||
else if (fertigPieces > 0 || arbeitPieces > 0) newStatus = 'in_arbeit';
|
||||
db.prepare('UPDATE orders SET status=?,updated_at=CURRENT_TIMESTAMP WHERE id=?').run(newStatus, orderId);
|
||||
};
|
||||
|
||||
const loadOrder = (id, userId) => {
|
||||
const order = db.prepare('SELECT * FROM orders WHERE id=? AND user_id=?').get(id, userId);
|
||||
if (!order) return null;
|
||||
order.items = db.prepare('SELECT * FROM order_items WHERE order_id=? ORDER BY id').all(id);
|
||||
return order;
|
||||
};
|
||||
|
||||
// GET / – Liste mit Zählern + Items
|
||||
router.get('/', authenticate, (req, res) => {
|
||||
const orders = db.prepare(`
|
||||
SELECT o.*,
|
||||
COUNT(oi.id) AS item_count,
|
||||
SUM(COALESCE(oi.qty_fertig,0)) AS qty_fertig_total,
|
||||
SUM(COALESCE(oi.stueckzahl,0)) AS qty_total,
|
||||
SUM(COALESCE(oi.custom_price,0) * COALESCE(oi.stueckzahl,0)) AS custom_price_sum,
|
||||
SUM(COALESCE(oi.stunden,0) * COALESCE(oi.stueckzahl,0)) AS stunden_total
|
||||
FROM orders o
|
||||
LEFT JOIN order_items oi ON oi.order_id = o.id
|
||||
WHERE o.user_id = ?
|
||||
GROUP BY o.id
|
||||
ORDER BY o.created_at DESC
|
||||
`).all(uid(req));
|
||||
// Attach items to each order
|
||||
const items = db.prepare('SELECT * FROM order_items WHERE order_id IN (SELECT id FROM orders WHERE user_id=?) ORDER BY order_id, id').all(uid(req));
|
||||
const itemsByOrder = {};
|
||||
for (const item of items) {
|
||||
if (!itemsByOrder[item.order_id]) itemsByOrder[item.order_id] = [];
|
||||
itemsByOrder[item.order_id].push(item);
|
||||
}
|
||||
for (const order of orders) order.items = itemsByOrder[order.id] || [];
|
||||
res.json(orders);
|
||||
});
|
||||
|
||||
router.get('/:id', authenticate, (req, res) => {
|
||||
const order = loadOrder(req.params.id, uid(req));
|
||||
if (!order) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
res.json(order);
|
||||
});
|
||||
|
||||
router.post('/', authenticate, (req, res) => {
|
||||
const { name, bemerkung='', status='warteliste', custom_price=null } = req.body;
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'Name erforderlich' });
|
||||
const r = db.prepare('INSERT INTO orders (user_id,name,bemerkung,status,custom_price) VALUES (?,?,?,?,?)')
|
||||
.run(uid(req), name.trim(), bemerkung, status, custom_price);
|
||||
res.json(loadOrder(r.lastInsertRowid, uid(req)));
|
||||
});
|
||||
|
||||
router.put('/:id', authenticate, (req, res) => {
|
||||
const ex = db.prepare('SELECT * FROM orders WHERE id=? AND user_id=?').get(req.params.id, uid(req));
|
||||
if (!ex) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const { name=ex.name, bemerkung=ex.bemerkung, status=ex.status, custom_price=ex.custom_price } = req.body;
|
||||
|
||||
// Bezahlt-Status mit Datum (optional überschreibbar)
|
||||
let bezahlt = ex.bezahlt;
|
||||
let bezahlt_am = ex.bezahlt_am;
|
||||
if (req.body.bezahlt !== undefined) {
|
||||
bezahlt = req.body.bezahlt ? 1 : 0;
|
||||
bezahlt_am = req.body.bezahlt
|
||||
? (req.body.bezahlt_am_override || new Date().toISOString())
|
||||
: null;
|
||||
}
|
||||
|
||||
// Abgeholt-Status mit Datum (optional überschreibbar)
|
||||
let abgeholt = ex.abgeholt;
|
||||
let abgeholt_am = ex.abgeholt_am;
|
||||
if (req.body.abgeholt !== undefined) {
|
||||
abgeholt = req.body.abgeholt ? 1 : 0;
|
||||
abgeholt_am = req.body.abgeholt
|
||||
? (req.body.abgeholt_am_override || new Date().toISOString())
|
||||
: null;
|
||||
}
|
||||
|
||||
db.prepare('UPDATE orders SET name=?,bemerkung=?,status=?,custom_price=?,bezahlt=?,bezahlt_am=?,abgeholt=?,abgeholt_am=?,updated_at=CURRENT_TIMESTAMP WHERE id=? AND user_id=?')
|
||||
.run(name, bemerkung, status, custom_price, bezahlt, bezahlt_am, abgeholt, abgeholt_am, req.params.id, uid(req));
|
||||
res.json(loadOrder(req.params.id, uid(req)));
|
||||
});
|
||||
|
||||
router.delete('/:id', authenticate, (req, res) => {
|
||||
const r = db.prepare('DELETE FROM orders WHERE id=? AND user_id=?').run(req.params.id, uid(req));
|
||||
if (!r.changes) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.post('/:id/items', authenticate, (req, res) => {
|
||||
const order = db.prepare('SELECT * FROM orders WHERE id=? AND user_id=?').get(req.params.id, uid(req));
|
||||
if (!order) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const { calculation_id, stueckzahl=1, custom_price=null } = req.body;
|
||||
const calc = db.prepare('SELECT * FROM calculations WHERE id=? AND user_id=?').get(calculation_id, uid(req));
|
||||
if (!calc) return res.status(404).json({ error: 'Archiv-Eintrag nicht gefunden' });
|
||||
const existing = db.prepare('SELECT * FROM order_items WHERE order_id=? AND calculation_id=?').get(req.params.id, calculation_id);
|
||||
if (existing) {
|
||||
const newQty = existing.stueckzahl + stueckzahl;
|
||||
db.prepare('UPDATE order_items SET stueckzahl=?,qty_warteliste=qty_warteliste+? WHERE id=?').run(newQty, stueckzahl, existing.id);
|
||||
} else {
|
||||
db.prepare(`INSERT INTO order_items
|
||||
(order_id,calculation_id,calc_name,preis_freundschaft,preis_normal,preis_auftrag,
|
||||
stueckzahl,custom_price,status,qty_warteliste,qty_in_arbeit,qty_fertig,stunden)
|
||||
VALUES (?,?,?,?,?,?,?,?,'warteliste',?,0,0,?)`)
|
||||
.run(req.params.id, calc.id, calc.name, calc.preis_freundschaft, calc.preis_normal, calc.preis_auftrag,
|
||||
stueckzahl, custom_price, stueckzahl, calc.stunden || 0);
|
||||
}
|
||||
autoUpdateOrderStatus(req.params.id);
|
||||
res.json(loadOrder(req.params.id, uid(req)));
|
||||
});
|
||||
|
||||
router.put('/:id/items/:itemId', authenticate, (req, res) => {
|
||||
const order = db.prepare('SELECT * FROM orders WHERE id=? AND user_id=?').get(req.params.id, uid(req));
|
||||
if (!order) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const item = db.prepare('SELECT * FROM order_items WHERE id=? AND order_id=?').get(req.params.itemId, req.params.id);
|
||||
if (!item) return res.status(404).json({ error: 'Position nicht gefunden' });
|
||||
|
||||
// Stückzahl geändert → Warteliste anpassen
|
||||
if (req.body.stueckzahl !== undefined) {
|
||||
const newQty = Math.max(1, req.body.stueckzahl);
|
||||
const diff = newQty - item.stueckzahl;
|
||||
const newWarte = Math.max(0, item.qty_warteliste + diff);
|
||||
db.prepare('UPDATE order_items SET stueckzahl=?,qty_warteliste=? WHERE id=?').run(newQty, newWarte, item.id);
|
||||
}
|
||||
|
||||
// Status-Zähler direkt setzen
|
||||
if (req.body.qty_warteliste !== undefined || req.body.qty_in_arbeit !== undefined || req.body.qty_fertig !== undefined) {
|
||||
const fresh = db.prepare('SELECT * FROM order_items WHERE id=?').get(item.id);
|
||||
const w = req.body.qty_warteliste ?? fresh.qty_warteliste;
|
||||
const a = req.body.qty_in_arbeit ?? fresh.qty_in_arbeit;
|
||||
const f = req.body.qty_fertig ?? fresh.qty_fertig;
|
||||
// Summe darf stueckzahl nicht überschreiten
|
||||
const total = w + a + f;
|
||||
if (total <= fresh.stueckzahl) {
|
||||
db.prepare('UPDATE order_items SET qty_warteliste=?,qty_in_arbeit=?,qty_fertig=? WHERE id=?').run(w, a, f, item.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Festpreis
|
||||
if (req.body.custom_price !== undefined) {
|
||||
db.prepare('UPDATE order_items SET custom_price=? WHERE id=?').run(req.body.custom_price, item.id);
|
||||
}
|
||||
|
||||
autoUpdateOrderStatus(req.params.id);
|
||||
res.json(loadOrder(req.params.id, uid(req)));
|
||||
});
|
||||
|
||||
router.delete('/:id/items/:itemId', authenticate, (req, res) => {
|
||||
const order = db.prepare('SELECT * FROM orders WHERE id=? AND user_id=?').get(req.params.id, uid(req));
|
||||
if (!order) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
db.prepare('DELETE FROM order_items WHERE id=? AND order_id=?').run(req.params.itemId, req.params.id);
|
||||
autoUpdateOrderStatus(req.params.id);
|
||||
res.json(loadOrder(req.params.id, uid(req)));
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
101
backend/src/tools/dateien/public-share-public.js
Normal file
@@ -0,0 +1,101 @@
|
||||
// Öffentliche Endpunkte – kein Login nötig
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const db = require('../../db');
|
||||
const router = express.Router();
|
||||
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/data/uploads';
|
||||
|
||||
function getShare(token) {
|
||||
return db.prepare(`
|
||||
SELECT s.*,
|
||||
f.originalname as file_name, f.size as file_size, f.mimetype as mime_type, f.filename as file_path,
|
||||
fo.name as folder_name
|
||||
FROM public_file_shares s
|
||||
LEFT JOIN files f ON f.id = s.file_id
|
||||
LEFT JOIN folders fo ON fo.id = s.folder_id
|
||||
WHERE s.token = ?
|
||||
`).get(token);
|
||||
}
|
||||
|
||||
function isExpired(s) {
|
||||
return s.expires_at && s.expires_at < Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
// GET /:token – Metadaten
|
||||
router.get('/:token', (req, res) => {
|
||||
const s = getShare(req.params.token);
|
||||
if (!s) return res.status(404).json({ error: 'Link nicht gefunden' });
|
||||
if (isExpired(s)) return res.status(410).json({ error: 'Link abgelaufen' });
|
||||
res.json({
|
||||
label: s.label,
|
||||
file_name: s.file_name,
|
||||
file_size: s.file_size,
|
||||
mime_type: s.mime_type,
|
||||
folder_name: s.folder_name,
|
||||
is_folder: !!s.folder_id,
|
||||
needs_password: !!s.password_hash,
|
||||
expires_at: s.expires_at,
|
||||
});
|
||||
});
|
||||
|
||||
// POST /:token/verify – nur Passwort prüfen, kein Download
|
||||
router.post('/:token/verify', async (req, res) => {
|
||||
const s = getShare(req.params.token);
|
||||
if (!s) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
if (isExpired(s)) return res.status(410).json({ error: 'Link abgelaufen' });
|
||||
const ok = await bcrypt.compare(req.body?.password || '', s.password_hash);
|
||||
if (!ok) return res.status(403).json({ error: 'Falsches Passwort' });
|
||||
res.json({ ok: true, file_name: s.file_name, file_size: s.file_size, mime_type: s.mime_type, is_folder: !!s.folder_id });
|
||||
});
|
||||
|
||||
// POST /:token/download – Datei herunterladen
|
||||
router.post('/:token/download', async (req, res) => {
|
||||
const s = getShare(req.params.token);
|
||||
if (!s || !s.file_id) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
if (isExpired(s)) return res.status(410).json({ error: 'Link abgelaufen' });
|
||||
const ok = await bcrypt.compare(req.body?.password || '', s.password_hash);
|
||||
if (!ok) return res.status(403).json({ error: 'Falsches Passwort' });
|
||||
const filePath = path.join(UPLOAD_DIR, s.file_path);
|
||||
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Datei nicht gefunden' });
|
||||
db.prepare('UPDATE public_file_shares SET download_count = download_count + 1 WHERE id=?').run(s.id);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(s.file_name)}"`);
|
||||
res.setHeader('Content-Type', s.mime_type || 'application/octet-stream');
|
||||
fs.createReadStream(filePath).pipe(res);
|
||||
});
|
||||
|
||||
// POST /:token/folder – Ordnerinhalt
|
||||
router.post('/:token/folder', async (req, res) => {
|
||||
const s = getShare(req.params.token);
|
||||
if (!s || !s.folder_id) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
if (isExpired(s)) return res.status(410).json({ error: 'Link abgelaufen' });
|
||||
const ok = await bcrypt.compare(req.body?.password || '', s.password_hash);
|
||||
if (!ok) return res.status(403).json({ error: 'Falsches Passwort' });
|
||||
const files = db.prepare(`
|
||||
SELECT id, originalname as name, size, mimetype as mime_type, filename as file_path FROM files
|
||||
WHERE folder_id=? AND user_id=? ORDER BY name
|
||||
`).all(s.folder_id, s.user_id);
|
||||
db.prepare('UPDATE public_file_shares SET download_count = download_count + 1 WHERE id=?').run(s.id);
|
||||
res.json({ folder_name: s.folder_name, files, token: s.token });
|
||||
});
|
||||
|
||||
// POST /:token/folder/:fileId – Einzelne Datei aus Ordner
|
||||
router.post('/:token/folder/:fileId', async (req, res) => {
|
||||
const s = getShare(req.params.token);
|
||||
if (!s || !s.folder_id) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
if (isExpired(s)) return res.status(410).json({ error: 'Link abgelaufen' });
|
||||
const ok = await bcrypt.compare(req.body?.password || '', s.password_hash);
|
||||
if (!ok) return res.status(403).json({ error: 'Falsches Passwort' });
|
||||
const file = db.prepare('SELECT * FROM files WHERE id=? AND folder_id=? AND user_id=?')
|
||||
.get(req.params.fileId, s.folder_id, s.user_id);
|
||||
if (!file) return res.status(404).json({ error: 'Datei nicht gefunden' });
|
||||
const filePath = path.join(UPLOAD_DIR, file.file_path || file.filename);
|
||||
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Datei nicht gefunden' });
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(file.name || file.originalname)}"`);
|
||||
res.setHeader('Content-Type', file.mime_type || file.mimetype || 'application/octet-stream');
|
||||
fs.createReadStream(filePath).pipe(res);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
88
backend/src/tools/dateien/public-share.js
Normal file
@@ -0,0 +1,88 @@
|
||||
// Authentifizierte Endpunkte zum Verwalten öffentlicher Datei-Links
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const crypto = require('crypto');
|
||||
const db = require('../../db');
|
||||
const { authenticate } = require('../../middleware/auth');
|
||||
const router = express.Router();
|
||||
|
||||
function generateToken() { return crypto.randomBytes(24).toString('base64url'); }
|
||||
|
||||
// GET / – eigene Shares auflisten
|
||||
router.get('/', authenticate, (req, res) => {
|
||||
try {
|
||||
const shares = db.prepare(`
|
||||
SELECT s.id, s.token, s.user_id, s.file_id, s.folder_id, s.label,
|
||||
s.password_hash, s.expires_at, s.created_at, s.download_count,
|
||||
f.originalname as file_name, f.size as file_size,
|
||||
fo.name as folder_name
|
||||
FROM public_file_shares s
|
||||
LEFT JOIN files f ON f.id = s.file_id
|
||||
LEFT JOIN folders fo ON fo.id = s.folder_id
|
||||
WHERE s.user_id = ?
|
||||
ORDER BY s.created_at DESC
|
||||
`).all(req.user.id);
|
||||
res.json({ shares });
|
||||
} catch(e) {
|
||||
console.error('[public-shares GET]', e.message);
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST / – neuen Share erstellen
|
||||
router.post('/', authenticate, async (req, res) => {
|
||||
const { file_id, folder_id, password, expires_hours, label } = req.body;
|
||||
if (!file_id && !folder_id) return res.status(400).json({ error: 'file_id oder folder_id erforderlich' });
|
||||
if (!password || !password.trim()) return res.status(400).json({ error: 'Passwort ist Pflicht' });
|
||||
if (!expires_hours || Number(expires_hours) <= 0) return res.status(400).json({ error: 'Ablaufzeit ist Pflicht' });
|
||||
|
||||
if (file_id) {
|
||||
const f = db.prepare('SELECT id FROM files WHERE id=? AND user_id=?').get(file_id, req.user.id);
|
||||
if (!f) return res.status(403).json({ error: 'Keine Berechtigung' });
|
||||
}
|
||||
if (folder_id) {
|
||||
const fo = db.prepare('SELECT id FROM folders WHERE id=? AND user_id=?').get(folder_id, req.user.id);
|
||||
if (!fo) return res.status(403).json({ error: 'Keine Berechtigung' });
|
||||
}
|
||||
|
||||
const token = generateToken();
|
||||
const password_hash = await bcrypt.hash(password.trim(), 10);
|
||||
const expires_at = Math.floor(Date.now() / 1000) + Number(expires_hours) * 3600;
|
||||
|
||||
const r = db.prepare(`
|
||||
INSERT INTO public_file_shares (token, user_id, file_id, folder_id, label, password_hash, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(token, req.user.id, file_id || null, folder_id || null, label || null, password_hash, expires_at);
|
||||
|
||||
const share = db.prepare('SELECT * FROM public_file_shares WHERE id=?').get(r.lastInsertRowid);
|
||||
res.json({ share, token });
|
||||
});
|
||||
|
||||
// DELETE /:id – Share löschen
|
||||
router.delete('/:id', authenticate, (req, res) => {
|
||||
const s = db.prepare('SELECT id FROM public_file_shares WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!s) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
db.prepare('DELETE FROM public_file_shares WHERE id=?').run(s.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Debug: Tabellenstatus + eigene Shares mit Token
|
||||
router.get('/debug-table', (req, res) => {
|
||||
try {
|
||||
const exists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='public_file_shares'").get();
|
||||
const count = exists ? db.prepare('SELECT COUNT(*) as n FROM public_file_shares').get() : null;
|
||||
const cols = exists ? db.pragma('table_info(public_file_shares)').map(c=>c.name) : [];
|
||||
const sample = exists ? db.prepare('SELECT id, token, user_id, file_id, folder_id, expires_at, download_count FROM public_file_shares LIMIT 5').all() : [];
|
||||
// Test: ersten Token direkt abfragen wie public endpoint es tut
|
||||
let publicTest = null;
|
||||
if (sample.length > 0) {
|
||||
const t = sample[0].token;
|
||||
const s = db.prepare('SELECT s.*, f.originalname as file_name, fo.name as folder_name FROM public_file_shares s LEFT JOIN files f ON f.id = s.file_id LEFT JOIN folders fo ON fo.id = s.folder_id WHERE s.token=?').get(t);
|
||||
const now = Math.floor(Date.now()/1000);
|
||||
publicTest = { token: t, expires_at: s?.expires_at, now, isExpired: s?.expires_at < now, typeof_expires: typeof s?.expires_at };
|
||||
}
|
||||
res.json({ exists: !!exists, count: count?.n, cols, sample, publicTest });
|
||||
} catch(e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
444
backend/src/tools/dateien/routes.js
Normal file
@@ -0,0 +1,444 @@
|
||||
const express = require('express');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const db = require('../../db');
|
||||
const { authenticate, requireAdmin } = require('../../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/data/uploads';
|
||||
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
const webdav = require('./webdav');
|
||||
|
||||
// ── WebDAV Hilfsfunktionen ────────────────────────────────────────────────
|
||||
function getFolderPathById(folderId) {
|
||||
if (!folderId) return '';
|
||||
const parts = [];
|
||||
let cur = folderId;
|
||||
const seen = new Set();
|
||||
while (cur) {
|
||||
if (seen.has(cur)) break; seen.add(cur);
|
||||
const fo = db.prepare('SELECT name, parent_id FROM folders WHERE id=?').get(cur);
|
||||
if (!fo) break;
|
||||
parts.unshift(fo.name.replace(/[\/]/g, '_'));
|
||||
cur = fo.parent_id;
|
||||
}
|
||||
return parts.join('/');
|
||||
}
|
||||
|
||||
function davFileUrl(username, folderId, filename) {
|
||||
const cfg = webdav.getConfig();
|
||||
if (!cfg.enabled) return null;
|
||||
const base = webdav.userPath(cfg, username);
|
||||
const folder = getFolderPathById(folderId);
|
||||
return folder ? `${base}/${folder}/${filename}` : `${base}/${filename}`;
|
||||
}
|
||||
|
||||
function davFolderUrl(username, folderId) {
|
||||
const cfg = webdav.getConfig();
|
||||
if (!cfg.enabled) return null;
|
||||
const base = webdav.userPath(cfg, username);
|
||||
const folder = getFolderPathById(folderId);
|
||||
return folder ? `${base}/${folder}` : base;
|
||||
}
|
||||
|
||||
function getUserName(userId) {
|
||||
return db.prepare('SELECT username FROM users WHERE id=?').get(userId)?.username || String(userId);
|
||||
}
|
||||
|
||||
const getSetting = key => db.prepare('SELECT value FROM admin_settings WHERE key=?').get(key)?.value;
|
||||
|
||||
const diskStorage = multer.diskStorage({
|
||||
destination: UPLOAD_DIR,
|
||||
filename: (req, file, cb) => cb(null, `${Date.now()}-${Math.random().toString(36).slice(2)}${path.extname(file.originalname)}`),
|
||||
});
|
||||
const memStorage = multer.memoryStorage();
|
||||
|
||||
const storage = diskStorage; // Standard: Disk
|
||||
const getUpload = () => multer({
|
||||
storage,
|
||||
limits: { fileSize: parseInt(getSetting('file_max_size_mb') || '50') * 1024 * 1024 },
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowed = (getSetting('file_allowed_ext') || '').split(',').map(e => e.trim().toLowerCase()).filter(Boolean);
|
||||
if (!allowed.length) return cb(null, true);
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
allowed.includes(ext) ? cb(null, true) : cb(new Error(`Dateityp nicht erlaubt: ${ext}`));
|
||||
},
|
||||
});
|
||||
|
||||
// Walk folder tree upward to check if any ancestor is shared with user
|
||||
function isInSharedFolder(folderId, uid) {
|
||||
let cur = folderId;
|
||||
while (cur) {
|
||||
if (db.prepare('SELECT id FROM folder_shares WHERE folder_id=? AND shared_with=?').get(cur, uid)) return true;
|
||||
const p = db.prepare('SELECT parent_id FROM folders WHERE id=?').get(cur);
|
||||
cur = p?.parent_id || null;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const enrichFolder = (folder) => ({
|
||||
...folder,
|
||||
fileCount: db.prepare('SELECT COUNT(*) c FROM files WHERE folder_id=?').get(folder.id).c,
|
||||
subCount: db.prepare('SELECT COUNT(*) c FROM folders WHERE parent_id=?').get(folder.id).c,
|
||||
totalSize: db.prepare('SELECT COALESCE(SUM(size),0) s FROM files WHERE folder_id=?').get(folder.id).s,
|
||||
});
|
||||
|
||||
// ── Ordner ────────────────────────────────────────────────────────────────────
|
||||
router.get('/folders', authenticate, (req, res) => {
|
||||
const uid = req.user.id;
|
||||
const parentId = req.query.parent_id ? parseInt(req.query.parent_id) : null;
|
||||
|
||||
// Own folders in this directory
|
||||
const own = parentId
|
||||
? db.prepare('SELECT * FROM folders WHERE user_id=? AND parent_id=? ORDER BY name').all(uid, parentId)
|
||||
: db.prepare('SELECT * FROM folders WHERE user_id=? AND parent_id IS NULL ORDER BY name').all(uid);
|
||||
|
||||
// Shared folders
|
||||
let shared = [];
|
||||
if (!parentId) {
|
||||
shared = db.prepare(`
|
||||
SELECT f.*, u.username as owner,
|
||||
GROUP_CONCAT(su.username) as shared_with_names,
|
||||
MIN(fs.created_at) as shared_at
|
||||
FROM folders f JOIN users u ON u.id=f.user_id
|
||||
JOIN folder_shares fs ON fs.folder_id=f.id
|
||||
JOIN users su ON su.id=fs.shared_with
|
||||
WHERE fs.shared_with=?
|
||||
GROUP BY f.id ORDER BY f.name
|
||||
`).all(uid);
|
||||
} else if (isInSharedFolder(parentId, uid)) {
|
||||
const parentFolder = db.prepare('SELECT * FROM folders WHERE id=?').get(parentId);
|
||||
if (parentFolder) {
|
||||
shared = db.prepare('SELECT f.*, u.username as owner FROM folders f JOIN users u ON u.id=f.user_id WHERE f.parent_id=? AND f.user_id!=? ORDER BY f.name').all(parentId, uid);
|
||||
}
|
||||
}
|
||||
|
||||
// Folders shared BY me (root only)
|
||||
const sharedByMe = parentId ? [] : db.prepare(`
|
||||
SELECT DISTINCT f.*, u.username as owner,
|
||||
GROUP_CONCAT(su.username) as shared_with_names,
|
||||
MIN(fs.created_at) as shared_at
|
||||
FROM folders f JOIN users u ON u.id=f.user_id
|
||||
JOIN folder_shares fs ON fs.folder_id=f.id
|
||||
JOIN users su ON su.id=fs.shared_with
|
||||
WHERE f.user_id=?
|
||||
GROUP BY f.id ORDER BY f.name
|
||||
`).all(uid);
|
||||
|
||||
res.json({ own: own.map(enrichFolder), shared: shared.map(enrichFolder), sharedByMe: sharedByMe.map(enrichFolder) });
|
||||
});
|
||||
|
||||
router.post('/folders', authenticate, (req, res) => {
|
||||
const { name, parent_id } = req.body;
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'Name erforderlich' });
|
||||
const parentId = parent_id ? parseInt(parent_id) : null;
|
||||
if (parentId) {
|
||||
const parent = db.prepare('SELECT * FROM folders WHERE id=? AND user_id=?').get(parentId, req.user.id);
|
||||
if (!parent) return res.status(404).json({ error: 'Überordner nicht gefunden' });
|
||||
}
|
||||
const r = db.prepare('INSERT INTO folders (user_id,name,parent_id) VALUES (?,?,?)').run(req.user.id, name.trim(), parentId);
|
||||
const newFolder = db.prepare('SELECT * FROM folders WHERE id=?').get(r.lastInsertRowid);
|
||||
// WebDAV: Ordner anlegen
|
||||
const _davFolderCreate = davFolderUrl(getUserName(req.user.id), newFolder.id);
|
||||
if (_davFolderCreate) webdav.mkdirp(_davFolderCreate).catch(e => console.warn('[webdav] mkdir:', e.message));
|
||||
res.json(enrichFolder(newFolder));
|
||||
});
|
||||
|
||||
router.delete('/folders/:id', authenticate, (req, res) => {
|
||||
const uid = req.user.id;
|
||||
const folder = db.prepare('SELECT * FROM folders WHERE id=? AND user_id=?').get(req.params.id, uid);
|
||||
if (!folder) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
|
||||
// Alle Unterordner rekursiv sammeln
|
||||
function collectSubfolderIds(parentId) {
|
||||
const children = db.prepare('SELECT id FROM folders WHERE parent_id=? AND user_id=?').all(parentId, uid);
|
||||
let ids = [parentId];
|
||||
for (const c of children) ids = ids.concat(collectSubfolderIds(c.id));
|
||||
return ids;
|
||||
}
|
||||
const allFolderIds = collectSubfolderIds(folder.id);
|
||||
|
||||
// WebDAV: Ordner löschen (vor DB-Löschung damit Pfad noch auflösbar)
|
||||
const _davFolderDel = davFolderUrl(getUserName(uid), folder.id);
|
||||
if (_davFolderDel) webdav.deleteFile(_davFolderDel).catch(e => console.warn('[webdav] rmdir:', e.message));
|
||||
|
||||
// Alle Dateien in allen Unterordnern löschen (Disk + DB)
|
||||
for (const fid of allFolderIds) {
|
||||
const files = db.prepare('SELECT * FROM files WHERE folder_id=? AND user_id=?').all(fid, uid);
|
||||
for (const f of files) { try { fs.unlinkSync(path.join(UPLOAD_DIR, f.filename)); } catch {} }
|
||||
db.prepare('DELETE FROM files WHERE folder_id=? AND user_id=?').run(fid, uid);
|
||||
}
|
||||
// Alle Unterordner + Hauptordner aus DB löschen
|
||||
for (const fid of allFolderIds.reverse()) {
|
||||
db.prepare('DELETE FROM folders WHERE id=?').run(fid);
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.get('/folders/:id/shares', authenticate, (req, res) => {
|
||||
const folder = db.prepare('SELECT * FROM folders WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!folder) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
res.json(db.prepare('SELECT u.id,u.username,fs.created_at as shared_at FROM folder_shares fs JOIN users u ON u.id=fs.shared_with WHERE fs.folder_id=?').all(folder.id));
|
||||
});
|
||||
|
||||
router.post('/folders/:id/share', authenticate, (req, res) => {
|
||||
const folder = db.prepare('SELECT * FROM folders WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!folder) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const target = db.prepare('SELECT * FROM users WHERE username=?').get(req.body.username);
|
||||
if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
|
||||
if (target.id === req.user.id) return res.status(400).json({ error: 'Kann nicht mit dir selbst teilen' });
|
||||
db.prepare('INSERT OR IGNORE INTO folder_shares (folder_id,shared_by,shared_with) VALUES (?,?,?)').run(folder.id, req.user.id, target.id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.delete('/folders/:id/share/:userId', authenticate, (req, res) => {
|
||||
const folder = db.prepare('SELECT * FROM folders WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!folder) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
db.prepare('DELETE FROM folder_shares WHERE folder_id=? AND shared_with=?').run(folder.id, req.params.userId);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── Dateien ───────────────────────────────────────────────────────────────────
|
||||
router.get('/', authenticate, (req, res) => {
|
||||
const uid = req.user.id;
|
||||
const folderId = req.query.folder_id ? parseInt(req.query.folder_id) : null;
|
||||
|
||||
// Own files in this directory
|
||||
const own = folderId
|
||||
? db.prepare('SELECT f.*, u.username as owner FROM files f JOIN users u ON u.id=f.user_id WHERE f.user_id=? AND f.folder_id=? ORDER BY f.created_at DESC').all(uid, folderId)
|
||||
: db.prepare('SELECT f.*, u.username as owner FROM files f JOIN users u ON u.id=f.user_id WHERE f.user_id=? AND f.folder_id IS NULL ORDER BY f.created_at DESC').all(uid);
|
||||
|
||||
// Files shared BY me
|
||||
const sharedByMe = folderId ? [] : db.prepare(`
|
||||
SELECT DISTINCT f.*, u.username as owner,
|
||||
GROUP_CONCAT(su.username) as shared_with_names,
|
||||
MIN(s.created_at) as shared_at,
|
||||
MAX(s.accessed_at) as accessed_at,
|
||||
MAX(CASE WHEN s.password_hash IS NOT NULL THEN 1 ELSE 0 END) as has_password
|
||||
FROM files f JOIN users u ON u.id=f.user_id
|
||||
JOIN file_shares s ON s.file_id=f.id
|
||||
JOIN users su ON su.id=s.shared_with
|
||||
WHERE f.user_id=?
|
||||
GROUP BY f.id ORDER BY f.created_at DESC
|
||||
`).all(uid);
|
||||
|
||||
// Files shared with me
|
||||
let shared = [];
|
||||
if (!folderId) {
|
||||
shared = db.prepare(`
|
||||
SELECT f.*, u.username as owner, sb.username as shared_by_name,
|
||||
CASE WHEN s.password_hash IS NOT NULL THEN 1 ELSE 0 END as has_password
|
||||
FROM files f JOIN users u ON u.id=f.user_id
|
||||
JOIN file_shares s ON s.file_id=f.id
|
||||
JOIN users sb ON sb.id=s.shared_by
|
||||
WHERE s.shared_with=?
|
||||
ORDER BY f.created_at DESC
|
||||
`).all(uid);
|
||||
} else if (isInSharedFolder(folderId, uid)) {
|
||||
const folder = db.prepare('SELECT * FROM folders WHERE id=?').get(folderId);
|
||||
if (folder) {
|
||||
shared = db.prepare('SELECT f.*, u.username as owner FROM files f JOIN users u ON u.id=f.user_id WHERE f.folder_id=? AND f.user_id=? ORDER BY f.created_at DESC').all(folderId, folder.user_id);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ own, shared, sharedByMe });
|
||||
});
|
||||
|
||||
router.post('/', authenticate, (req, res) => {
|
||||
const uid = req.user.id;
|
||||
const maxSizeMb = parseInt(getSetting('file_max_size_mb') || '50');
|
||||
const totalLimitMb= parseInt(getSetting('file_total_size_mb') || '500');
|
||||
const usedBytes = db.prepare('SELECT COALESCE(SUM(size),0) AS s FROM files WHERE user_id=?').get(uid).s;
|
||||
if (usedBytes >= totalLimitMb * 1024 * 1024)
|
||||
return res.status(400).json({ error: `Gesamtlimit von ${totalLimitMb} MB erreicht` });
|
||||
getUpload().single('file')(req, res, err => {
|
||||
if (err) {
|
||||
if (req.file) try { fs.unlinkSync(req.file.path); } catch {}
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
if (!req.file) return res.status(400).json({ error: 'Keine Datei' });
|
||||
const folderId = req.body.folder_id ? parseInt(req.body.folder_id) : null;
|
||||
const r = db.prepare('INSERT INTO files (user_id,filename,originalname,mimetype,size,folder_id) VALUES (?,?,?,?,?,?)')
|
||||
.run(uid, req.file.filename, req.file.originalname, req.file.mimetype||'application/octet-stream', req.file.size, folderId);
|
||||
const newFile = db.prepare('SELECT f.*,u.username as owner FROM files f JOIN users u ON u.id=f.user_id WHERE f.id=?').get(r.lastInsertRowid);
|
||||
|
||||
// WebDAV: Datei asynchron spiegeln
|
||||
const _davPath = davFileUrl(getUserName(uid), folderId, req.file.originalname);
|
||||
if (_davPath) {
|
||||
fs.readFile(path.join(UPLOAD_DIR, req.file.filename), (err, buf) => {
|
||||
if (!err) webdav.uploadFile(buf, _davPath, req.file.mimetype).catch(e => console.error('[webdav upload]', e.message));
|
||||
});
|
||||
}
|
||||
|
||||
res.json(newFile);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 3D-Modelle Hilfsendpoints ─────────────────────────────────────────────────
|
||||
|
||||
// Sucht einen Ordner ohne ihn anzulegen – gibt null zurück wenn nicht gefunden
|
||||
router.get('/find-folder', authenticate, (req, res) => {
|
||||
const uid = req.user.id;
|
||||
const { name, parent_id } = req.query;
|
||||
if (!name?.trim()) return res.json(null);
|
||||
const parentId = parent_id ? parseInt(parent_id) : null;
|
||||
const folder = parentId
|
||||
? db.prepare('SELECT * FROM folders WHERE user_id=? AND name=? AND parent_id=?').get(uid, name.trim(), parentId)
|
||||
: db.prepare('SELECT * FROM folders WHERE user_id=? AND name=? AND parent_id IS NULL').get(uid, name.trim());
|
||||
res.json(folder ? enrichFolder(folder) : null);
|
||||
});
|
||||
|
||||
// Stellt sicher dass ein Ordner existiert (Name + optionaler parent), gibt ihn zurück
|
||||
router.post('/ensure-folder', authenticate, (req, res) => {
|
||||
const uid = req.user.id;
|
||||
const { name, parent_id } = req.body;
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'Name erforderlich' });
|
||||
const parentId = parent_id ? parseInt(parent_id) : null;
|
||||
|
||||
// Suche existierenden Ordner
|
||||
const existing = parentId
|
||||
? db.prepare('SELECT * FROM folders WHERE user_id=? AND name=? AND parent_id=?').get(uid, name.trim(), parentId)
|
||||
: db.prepare('SELECT * FROM folders WHERE user_id=? AND name=? AND parent_id IS NULL').get(uid, name.trim());
|
||||
|
||||
if (existing) return res.json(enrichFolder(existing));
|
||||
|
||||
const r = db.prepare('INSERT INTO folders (user_id,name,parent_id) VALUES (?,?,?)').run(uid, name.trim(), parentId);
|
||||
res.json(enrichFolder(db.prepare('SELECT * FROM folders WHERE id=?').get(r.lastInsertRowid)));
|
||||
});
|
||||
|
||||
// Upload 3D-Dateien (ohne Ext-Beschränkung, für den Kalkulator)
|
||||
const upload3d = multer({
|
||||
storage,
|
||||
limits: { fileSize: parseInt(process.env.MAX_3D_SIZE_MB || '200') * 1024 * 1024 },
|
||||
});
|
||||
|
||||
router.post('/upload-3d', authenticate, (req, res) => {
|
||||
upload3d.array('files', 20)(req, res, err => {
|
||||
if (err) {
|
||||
if (req.files?.length) req.files.forEach(f => { try { fs.unlinkSync(f.path); } catch {} });
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
if (!req.files?.length) return res.status(400).json({ error: 'Keine Dateien' });
|
||||
const uid = req.user.id;
|
||||
const folderId = req.body.folder_id ? parseInt(req.body.folder_id) : null;
|
||||
const inserted = [];
|
||||
for (const f of req.files) {
|
||||
const r = db.prepare('INSERT INTO files (user_id,filename,originalname,mimetype,size,folder_id) VALUES (?,?,?,?,?,?)')
|
||||
.run(uid, f.filename, f.originalname, f.mimetype || 'application/octet-stream', f.size, folderId);
|
||||
inserted.push(r.lastInsertRowid);
|
||||
}
|
||||
res.json({ uploaded: inserted.length });
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/storage', authenticate, (req, res) => {
|
||||
const uid = req.user.id;
|
||||
const used = db.prepare('SELECT COALESCE(SUM(size),0) AS s FROM files WHERE user_id=?').get(uid).s;
|
||||
const count = db.prepare('SELECT COUNT(*) AS c FROM files WHERE user_id=?').get(uid).c;
|
||||
const maxSizeMb = parseInt(getSetting('file_max_size_mb') || '50');
|
||||
const totalLimitMb = parseInt(getSetting('file_total_size_mb') || '500');
|
||||
res.json({ used, count, maxSizeMb, totalLimitMb });
|
||||
});
|
||||
|
||||
router.get('/:id/download', authenticate, async (req, res) => {
|
||||
const uid = req.user.id;
|
||||
const file = db.prepare('SELECT * FROM files WHERE id=?').get(req.params.id);
|
||||
if (!file) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
|
||||
const isOwner = file.user_id === uid;
|
||||
const share = db.prepare('SELECT * FROM file_shares WHERE file_id=? AND shared_with=?').get(file.id, uid);
|
||||
const inSharedDir = file.folder_id && isInSharedFolder(file.folder_id, uid);
|
||||
|
||||
if (!isOwner && !share && !inSharedDir) return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
|
||||
// Passwortprüfung für Shares (Owner braucht kein Passwort)
|
||||
if (!isOwner && share?.password_hash) {
|
||||
const bcrypt = require('bcryptjs');
|
||||
const pw = req.query.password || req.headers['x-share-password'] || '';
|
||||
const ok = pw && await bcrypt.compare(pw, share.password_hash);
|
||||
if (!ok) return res.status(403).json({
|
||||
error: 'Falsches Passwort',
|
||||
passwordRequired: !pw // true = noch kein PW eingegeben, false = falsches PW
|
||||
});
|
||||
}
|
||||
|
||||
const fp = path.join(UPLOAD_DIR, file.filename);
|
||||
if (!fs.existsSync(fp)) return res.status(404).json({ error: 'Datei fehlt' });
|
||||
|
||||
// Zugriff tracken (immer aktualisieren damit es sicher gesetzt wird)
|
||||
if (share) {
|
||||
db.prepare("UPDATE file_shares SET accessed_at = datetime('now','localtime') WHERE file_id=? AND shared_with=?")
|
||||
.run(file.id, uid);
|
||||
}
|
||||
|
||||
res.download(fp, file.originalname);
|
||||
});
|
||||
|
||||
router.delete('/:id', authenticate, (req, res) => {
|
||||
const file = db.prepare('SELECT * FROM files WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!file) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
// WebDAV: Datei löschen
|
||||
const _davFileDel = davFileUrl(getUserName(file.user_id), file.folder_id, file.originalname);
|
||||
if (_davFileDel) webdav.deleteFile(_davFileDel).catch(e => console.warn('[webdav] delete:', e.message));
|
||||
try { fs.unlinkSync(path.join(UPLOAD_DIR, file.filename)); } catch {}
|
||||
db.prepare('DELETE FROM files WHERE id=?').run(file.id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.get('/:id/shares', authenticate, (req, res) => {
|
||||
const file = db.prepare('SELECT * FROM files WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!file) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
res.json(db.prepare(`
|
||||
SELECT u.id, u.username, s.created_at as shared_at,
|
||||
s.accessed_at,
|
||||
CASE WHEN s.password_hash IS NOT NULL THEN 1 ELSE 0 END as has_password
|
||||
FROM file_shares s JOIN users u ON u.id=s.shared_with
|
||||
WHERE s.file_id=?
|
||||
`).all(file.id));
|
||||
});
|
||||
|
||||
router.post('/:id/share', authenticate, async (req, res) => {
|
||||
const file = db.prepare('SELECT * FROM files WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!file) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const target = db.prepare('SELECT * FROM users WHERE username=?').get(req.body.username);
|
||||
if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
|
||||
if (target.id === req.user.id) return res.status(400).json({ error: 'Kann nicht mit dir selbst teilen' });
|
||||
let passwordHash = null;
|
||||
if (req.body.password) {
|
||||
const bcrypt = require('bcryptjs');
|
||||
passwordHash = await bcrypt.hash(req.body.password, 10);
|
||||
}
|
||||
const existing = db.prepare('SELECT id FROM file_shares WHERE file_id=? AND shared_with=?').get(file.id, target.id);
|
||||
if (existing) {
|
||||
db.prepare('UPDATE file_shares SET password_hash=?, accessed_at=NULL WHERE file_id=? AND shared_with=?')
|
||||
.run(passwordHash, file.id, target.id);
|
||||
} else {
|
||||
db.prepare('INSERT INTO file_shares (file_id,shared_by,shared_with,password_hash) VALUES (?,?,?,?)')
|
||||
.run(file.id, req.user.id, target.id, passwordHash);
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.delete('/:id/share/:userId', authenticate, (req, res) => {
|
||||
const file = db.prepare('SELECT * FROM files WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!file) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
db.prepare('DELETE FROM file_shares WHERE file_id=? AND shared_with=?').run(file.id, req.params.userId);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// Settings
|
||||
router.get('/settings', authenticate, requireAdmin, (req, res) => {
|
||||
const s = db.prepare('SELECT * FROM admin_settings').all();
|
||||
const obj = {}; for (const r of s) obj[r.key] = r.value;
|
||||
res.json(obj);
|
||||
});
|
||||
router.put('/settings', authenticate, requireAdmin, (req, res) => {
|
||||
for (const key of ['file_max_size_mb','file_total_size_mb','file_allowed_ext']) {
|
||||
if (req.body[key] !== undefined)
|
||||
db.prepare('INSERT OR REPLACE INTO admin_settings (key,value) VALUES (?,?)').run(key, String(req.body[key]));
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
171
backend/src/tools/dateien/upload-share.js
Normal file
@@ -0,0 +1,171 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const crypto = require('crypto');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const db = require('../../db');
|
||||
const { authenticate, requireAdmin } = require('../../middleware/auth');
|
||||
const router = express.Router();
|
||||
|
||||
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/data/uploads';
|
||||
|
||||
function generateToken() { return crypto.randomBytes(20).toString('base64url'); }
|
||||
|
||||
function getOrCreateUploadFolder(userId) {
|
||||
let f = db.prepare("SELECT * FROM folders WHERE user_id=? AND is_upload_folder=1 LIMIT 1").get(userId);
|
||||
if (!f) {
|
||||
const r = db.prepare("INSERT INTO folders (user_id,name,is_upload_folder,created_at) VALUES (?,?,1,datetime('now','localtime'))").run(userId,'Upload');
|
||||
f = db.prepare('SELECT * FROM folders WHERE id=?').get(r.lastInsertRowid);
|
||||
}
|
||||
return f;
|
||||
}
|
||||
|
||||
function canUseUploadShare(userId) {
|
||||
const u = db.prepare('SELECT role,allow_upload_share FROM users WHERE id=?').get(userId);
|
||||
return u && (u.role==='admin' || !!u.allow_upload_share);
|
||||
}
|
||||
|
||||
// ── Eigene Shares ─────────────────────────────────────────────────────────────
|
||||
router.get('/', authenticate, (req, res) => {
|
||||
if (!canUseUploadShare(req.user.id)) return res.status(403).json({ error:'Keine Berechtigung' });
|
||||
const folder = getOrCreateUploadFolder(req.user.id);
|
||||
const shares = db.prepare(`
|
||||
SELECT s.*, COUNT(l.id) as upload_count
|
||||
FROM upload_shares s
|
||||
LEFT JOIN upload_share_logs l ON l.share_id=s.id
|
||||
WHERE s.user_id=?
|
||||
GROUP BY s.id ORDER BY s.created_at DESC
|
||||
`).all(req.user.id);
|
||||
res.json({ shares, folder });
|
||||
});
|
||||
|
||||
router.post('/', authenticate, (req, res) => {
|
||||
if (!canUseUploadShare(req.user.id)) return res.status(403).json({ error:'Keine Berechtigung' });
|
||||
const { password, expires_hours=24, max_size_mb=10 } = req.body;
|
||||
if (!password?.trim()) return res.status(400).json({ error:'Passwort erforderlich' });
|
||||
const folder = getOrCreateUploadFolder(req.user.id);
|
||||
const token = generateToken();
|
||||
const hash = bcrypt.hashSync(password.trim(), 10);
|
||||
const expires = new Date(Date.now() + Number(expires_hours)*3600000).toISOString();
|
||||
const r = db.prepare(`
|
||||
INSERT INTO upload_shares (user_id,token,password_hash,expires_at,max_size_mb,folder_id,created_at)
|
||||
VALUES (?,?,?,?,?,?,datetime('now','localtime'))
|
||||
`).run(req.user.id, token, hash, expires, Number(max_size_mb), folder.id);
|
||||
res.json(db.prepare('SELECT * FROM upload_shares WHERE id=?').get(r.lastInsertRowid));
|
||||
});
|
||||
|
||||
router.delete('/:id', authenticate, (req, res) => {
|
||||
const s = db.prepare('SELECT * FROM upload_shares WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!s) return res.status(404).json({ error:'Nicht gefunden' });
|
||||
db.prepare("UPDATE upload_shares SET is_active=0, deactivated_reason='manual' WHERE id=?").run(s.id);
|
||||
res.json({ ok:true });
|
||||
});
|
||||
|
||||
router.delete('/:id/remove', authenticate, (req, res) => {
|
||||
const s = db.prepare('SELECT * FROM upload_shares WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!s) return res.status(404).json({ error:'Nicht gefunden' });
|
||||
db.prepare('DELETE FROM upload_shares WHERE id=?').run(s.id);
|
||||
res.json({ ok:true });
|
||||
});
|
||||
|
||||
router.get('/logs', authenticate, (req, res) => {
|
||||
if (!canUseUploadShare(req.user.id)) return res.status(403).json({ error:'Keine Berechtigung' });
|
||||
const logs = db.prepare(`
|
||||
SELECT l.*, s.expires_at, s.max_size_mb
|
||||
FROM upload_share_logs l
|
||||
JOIN upload_shares s ON s.id=l.share_id
|
||||
WHERE l.user_id=? ORDER BY l.uploaded_at DESC LIMIT 100
|
||||
`).all(req.user.id);
|
||||
res.json(logs);
|
||||
});
|
||||
|
||||
// ── Admin ─────────────────────────────────────────────────────────────────────
|
||||
router.get('/admin/logs', authenticate, requireAdmin, (req, res) => {
|
||||
const logs = db.prepare(`
|
||||
SELECT l.*, s.expires_at, s.max_size_mb, u.username
|
||||
FROM upload_share_logs l
|
||||
JOIN upload_shares s ON s.id=l.share_id
|
||||
JOIN users u ON u.id=l.user_id
|
||||
ORDER BY l.uploaded_at DESC LIMIT 500
|
||||
`).all();
|
||||
res.json(logs);
|
||||
});
|
||||
|
||||
router.get('/admin/shares', authenticate, requireAdmin, (req, res) => {
|
||||
const shares = db.prepare(`
|
||||
SELECT s.*, u.username, COUNT(l.id) as upload_count
|
||||
FROM upload_shares s JOIN users u ON u.id=s.user_id
|
||||
LEFT JOIN upload_share_logs l ON l.share_id=s.id
|
||||
GROUP BY s.id ORDER BY s.created_at DESC
|
||||
`).all();
|
||||
res.json(shares);
|
||||
});
|
||||
|
||||
router.post('/admin/permission', authenticate, requireAdmin, (req, res) => {
|
||||
const { user_id, allow } = req.body;
|
||||
db.prepare('UPDATE users SET allow_upload_share=? WHERE id=?').run(allow?1:0, user_id);
|
||||
res.json({ ok:true });
|
||||
});
|
||||
|
||||
// ── Öffentlich: Share-Info ────────────────────────────────────────────────────
|
||||
router.get('/public/:token', (req, res) => {
|
||||
const s = db.prepare("SELECT id,expires_at,max_size_mb,is_active,failed_attempts,deactivated_reason FROM upload_shares WHERE token=?").get(req.params.token);
|
||||
if (!s || !s.is_active) return res.status(404).json({ error: s?.deactivated_reason==='too_many_attempts' ? 'Link wegen zu vieler Fehlversuche gesperrt' : 'Link ungültig oder deaktiviert' });
|
||||
if (new Date(s.expires_at) < new Date()) return res.status(410).json({ error:'Link abgelaufen' });
|
||||
res.json({ ok:true, max_size_mb:s.max_size_mb, expires_at:s.expires_at });
|
||||
});
|
||||
|
||||
// ── Öffentlich: Passwort prüfen ───────────────────────────────────────────────
|
||||
router.post('/public/:token/verify', (req, res) => {
|
||||
const s = db.prepare("SELECT * FROM upload_shares WHERE token=?").get(req.params.token);
|
||||
if (!s || !s.is_active) return res.status(404).json({ error:'Link ungültig' });
|
||||
if (new Date(s.expires_at) < new Date()) return res.status(410).json({ error:'Link abgelaufen' });
|
||||
|
||||
if (!bcrypt.compareSync(req.body?.password || '', s.password_hash)) {
|
||||
const attempts = (s.failed_attempts||0) + 1;
|
||||
if (attempts >= 3) {
|
||||
// Link sperren
|
||||
db.prepare("UPDATE upload_shares SET is_active=0, failed_attempts=?, deactivated_reason='too_many_attempts' WHERE id=?").run(attempts, s.id);
|
||||
return res.status(401).json({ error:'Zu viele Fehlversuche – Link wurde gesperrt', locked:true });
|
||||
}
|
||||
db.prepare("UPDATE upload_shares SET failed_attempts=? WHERE id=?").run(attempts, s.id);
|
||||
return res.status(401).json({ error:'Falsches Passwort', attempts_left: 3-attempts });
|
||||
}
|
||||
|
||||
// Erfolgreich – Fehlversuche zurücksetzen
|
||||
db.prepare("UPDATE upload_shares SET failed_attempts=0 WHERE id=?").run(s.id);
|
||||
res.json({ ok:true, max_size_mb:s.max_size_mb, expires_at:s.expires_at });
|
||||
});
|
||||
|
||||
// ── Öffentlich: Upload ────────────────────────────────────────────────────────
|
||||
router.post('/public/:token/upload', (req, res) => {
|
||||
const s = db.prepare("SELECT * FROM upload_shares WHERE token=?").get(req.params.token);
|
||||
if (!s || !s.is_active) return res.status(404).json({ error:'Link ungültig' });
|
||||
if (new Date(s.expires_at) < new Date()) return res.status(410).json({ error:'Link abgelaufen' });
|
||||
|
||||
const pw = req.headers['x-upload-password'] || '';
|
||||
if (!bcrypt.compareSync(pw, s.password_hash)) return res.status(401).json({ error:'Nicht autorisiert' });
|
||||
|
||||
const storage = multer.diskStorage({ destination: UPLOAD_DIR });
|
||||
const upload = multer({ storage, limits:{ fileSize: s.max_size_mb*1024*1024 } }).single('file');
|
||||
|
||||
upload(req, res, err => {
|
||||
if (err) {
|
||||
if (err.code==='LIMIT_FILE_SIZE') return res.status(413).json({ error:`Datei zu groß (max ${s.max_size_mb} MB)` });
|
||||
return res.status(500).json({ error:err.message });
|
||||
}
|
||||
if (!req.file) return res.status(400).json({ error:'Keine Datei' });
|
||||
try {
|
||||
db.prepare(`INSERT INTO files (user_id,filename,originalname,mimetype,size,folder_id,created_at) VALUES (?,?,?,?,?,?,datetime('now','localtime'))`).run(s.user_id,req.file.filename,req.file.originalname,req.file.mimetype,req.file.size,s.folder_id);
|
||||
db.prepare(`INSERT INTO upload_share_logs (share_id,user_id,originalname,size,ip_address,uploaded_at) VALUES (?,?,?,?,?,datetime('now','localtime'))`).run(s.id,s.user_id,req.file.originalname,req.file.size,req.ip||'');
|
||||
res.json({ ok:true, filename:req.file.originalname });
|
||||
} catch(e) {
|
||||
try { fs.unlinkSync(path.join(UPLOAD_DIR, req.file.filename)); } catch {}
|
||||
res.status(500).json({ error:e.message });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
169
backend/src/tools/dateien/webdav.js
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* WebDAV Storage Backend für Synology NAS
|
||||
* Abstrahiert alle Datei-Operationen – lokal oder WebDAV
|
||||
*/
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const db = require('../../db');
|
||||
|
||||
function getSetting(key) {
|
||||
return db.prepare('SELECT value FROM admin_settings WHERE key=?').get(key)?.value || '';
|
||||
}
|
||||
|
||||
function getConfig() {
|
||||
return {
|
||||
url: getSetting('webdav_url'), // z.B. http://192.168.1.100:5005
|
||||
user: getSetting('webdav_user'),
|
||||
password: getSetting('webdav_password'),
|
||||
basePath: getSetting('webdav_base_path') || '/dickendock', // z.B. /dickendock
|
||||
enabled: getSetting('webdav_enabled') === '1',
|
||||
};
|
||||
}
|
||||
|
||||
function authHeader(cfg) {
|
||||
return 'Basic ' + Buffer.from(`${cfg.user}:${cfg.password}`).toString('base64');
|
||||
}
|
||||
|
||||
// WebDAV Request ausführen
|
||||
function davRequest(method, davPath, opts = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const cfg = getConfig();
|
||||
const base = new URL(cfg.url);
|
||||
const fullPath = base.pathname.replace(/\/$/, '') + davPath;
|
||||
const mod = base.protocol === 'https:' ? https : http;
|
||||
|
||||
const headers = {
|
||||
'Authorization': authHeader(cfg),
|
||||
'Accept': '*/*',
|
||||
...opts.headers,
|
||||
};
|
||||
|
||||
const reqOpts = {
|
||||
hostname: base.hostname,
|
||||
port: base.port || (base.protocol === 'https:' ? 443 : 80),
|
||||
path: fullPath,
|
||||
method,
|
||||
headers,
|
||||
rejectUnauthorized: false, // Synology Self-Signed Certs
|
||||
};
|
||||
|
||||
if (opts.body) {
|
||||
headers['Content-Length'] = Buffer.byteLength(opts.body);
|
||||
}
|
||||
|
||||
const req = mod.request(reqOpts, res => {
|
||||
let data = '';
|
||||
res.setEncoding('binary');
|
||||
res.on('data', d => data += d);
|
||||
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: data }));
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
if (opts.body) req.write(opts.body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Öffentliche API ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Ordner auf WebDAV anlegen (inkl. alle Parent-Ordner)
|
||||
*/
|
||||
async function mkdirp(davPath) {
|
||||
const parts = davPath.split('/').filter(Boolean);
|
||||
let current = '';
|
||||
for (const part of parts) {
|
||||
current += '/' + part;
|
||||
const r = await davRequest('MKCOL', current);
|
||||
// 201 = erstellt, 301/405/409/423 = schon vorhanden oder Konflikt (ignorieren)
|
||||
const ok = [201, 301, 405, 409, 423].includes(r.status);
|
||||
if (!ok) {
|
||||
console.warn(`[webdav mkdirp] MKCOL ${current} → ${r.status} (ignoriert)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* User-Basispfad auf WebDAV: /dickendock/{username}
|
||||
*/
|
||||
function userPath(cfg, username) {
|
||||
return cfg.basePath.replace(/\/$/, '') + '/' + username.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
}
|
||||
|
||||
/**
|
||||
* Datei auf WebDAV hochladen
|
||||
* @param {Buffer} buffer - Dateiinhalt
|
||||
* @param {string} davPath - Zielpfad auf WebDAV
|
||||
* @param {string} mimeType
|
||||
*/
|
||||
async function uploadFile(buffer, davPath, mimeType = 'application/octet-stream') {
|
||||
try { await mkdirp(davPath.split('/').slice(0, -1).join('/')); } catch(e) {
|
||||
console.warn('[webdav] mkdirp warning:', e.message);
|
||||
}
|
||||
const r = await new Promise((resolve, reject) => {
|
||||
const cfg = getConfig();
|
||||
const base = new URL(cfg.url);
|
||||
const fullPath = base.pathname.replace(/\/$/, '') + davPath;
|
||||
const mod = base.protocol === 'https:' ? https : http;
|
||||
|
||||
const headers = {
|
||||
'Authorization': authHeader(cfg),
|
||||
'Content-Type': mimeType,
|
||||
'Content-Length': buffer.length,
|
||||
};
|
||||
|
||||
const req = mod.request({
|
||||
hostname: base.hostname,
|
||||
port: base.port || (base.protocol === 'https:' ? 443 : 80),
|
||||
path: fullPath,
|
||||
method: 'PUT',
|
||||
headers,
|
||||
rejectUnauthorized: false,
|
||||
}, res => { res.resume(); resolve({ status: res.statusCode }); });
|
||||
|
||||
req.on('error', reject);
|
||||
req.write(buffer);
|
||||
req.end();
|
||||
});
|
||||
|
||||
if (r.status !== 201 && r.status !== 204) {
|
||||
throw new Error(`PUT ${davPath} → ${r.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Datei von WebDAV downloaden (gibt Buffer zurück)
|
||||
*/
|
||||
async function downloadFile(davPath) {
|
||||
const r = await davRequest('GET', davPath);
|
||||
if (r.status !== 200) throw new Error(`GET ${davPath} → ${r.status}`);
|
||||
return Buffer.from(r.body, 'binary');
|
||||
}
|
||||
|
||||
/**
|
||||
* Datei/Ordner auf WebDAV löschen
|
||||
*/
|
||||
async function deleteFile(davPath) {
|
||||
const r = await davRequest('DELETE', davPath);
|
||||
if (r.status !== 204 && r.status !== 200 && r.status !== 404) {
|
||||
throw new Error(`DELETE ${davPath} → ${r.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verbindung testen
|
||||
*/
|
||||
async function testConnection() {
|
||||
const cfg = getConfig();
|
||||
if (!cfg.url || !cfg.user || !cfg.password) throw new Error('WebDAV nicht konfiguriert');
|
||||
const r = await davRequest('PROPFIND', cfg.basePath || '/', {
|
||||
headers: { 'Depth': '0', 'Content-Type': 'application/xml' },
|
||||
body: '<?xml version="1.0"?><propfind xmlns="DAV:"><prop><displayname/></prop></propfind>',
|
||||
});
|
||||
if (r.status !== 207 && r.status !== 200) throw new Error(`Verbindung fehlgeschlagen: HTTP ${r.status}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
module.exports = { getConfig, userPath, mkdirp, uploadFile, downloadFile, deleteFile, testConnection };
|
||||
615
backend/src/tools/gebietseroberung/routes.js
Normal file
@@ -0,0 +1,615 @@
|
||||
const express = require('express');
|
||||
const db = require('../../db');
|
||||
const { authenticate } = require('../../middleware/auth');
|
||||
const router = express.Router();
|
||||
|
||||
// ── DB-Migration ──────────────────────────────────────────────────────────────
|
||||
(function migrate() {
|
||||
const cols = db.pragma('table_info(geo_games)').map(c => c.name);
|
||||
if (!cols.length) {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS geo_games (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner_id INTEGER NOT NULL,
|
||||
opponent_id INTEGER NOT NULL,
|
||||
grid TEXT NOT NULL,
|
||||
terrain TEXT NOT NULL DEFAULT '[]',
|
||||
current_turn INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
winner_id INTEGER,
|
||||
move_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_event TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
|
||||
);
|
||||
`);
|
||||
}
|
||||
for (const [col, def] of [
|
||||
['move_count', 'INTEGER NOT NULL DEFAULT 0'],
|
||||
['terrain', 'TEXT'],
|
||||
['last_event', 'TEXT'],
|
||||
['owner_head', 'TEXT'],
|
||||
['opp_head', 'TEXT'],
|
||||
['owner_scouts', 'TEXT'],
|
||||
['opp_scouts', 'TEXT'],
|
||||
['seen_by', 'TEXT'],
|
||||
]) {
|
||||
if (!cols.includes(col)) db.exec(`ALTER TABLE geo_games ADD COLUMN ${col} ${def}`);
|
||||
}
|
||||
// fog_owner/fog_opp waren alte Spalten, ignorieren
|
||||
})();
|
||||
|
||||
// ── Konstanten ────────────────────────────────────────────────────────────────
|
||||
const GRID = 20;
|
||||
const DIRS8 = [[-1,-1],[-1,0],[-1,1],[0,-1],[0,1],[1,-1],[1,0],[1,1]];
|
||||
const T_NORMAL = 0;
|
||||
const T_GOLD = 1;
|
||||
const T_MINE = 2;
|
||||
const T_ROCK = 3;
|
||||
|
||||
// ── Terrain generieren ────────────────────────────────────────────────────────
|
||||
function generateTerrain(ownerHead, oppHead) {
|
||||
const t = Array(GRID).fill(null).map(() => Array(GRID).fill(T_NORMAL));
|
||||
const safe = new Set();
|
||||
// 3x3 Sicherheitszone um beide Startecken
|
||||
for (const headStr of [ownerHead, oppHead]) {
|
||||
const [hr,hc] = headStr.split(',').map(Number);
|
||||
for (let dr=-2;dr<=2;dr++) for (let dc=-2;dc<=2;dc++) {
|
||||
const r=hr+dr, c=hc+dc;
|
||||
if (r>=0&&r<GRID&&c>=0&&c<GRID) safe.add(`${r},${c}`);
|
||||
}
|
||||
}
|
||||
|
||||
const cells = [];
|
||||
for (let r=0;r<GRID;r++) for (let c=0;c<GRID;c++) if (!safe.has(`${r},${c}`)) cells.push([r,c]);
|
||||
for (let i=cells.length-1;i>0;i--) { const j=Math.floor(Math.random()*(i+1)); [cells[i],cells[j]]=[cells[j],cells[i]]; }
|
||||
|
||||
let idx=0;
|
||||
for (let i=0;i<12&&idx<cells.length;i++,idx++) t[cells[idx][0]][cells[idx][1]] = T_GOLD;
|
||||
for (let i=0;i<10&&idx<cells.length;i++,idx++) t[cells[idx][0]][cells[idx][1]] = T_MINE;
|
||||
for (let i=0;i<18&&idx<cells.length;i++,idx++) t[cells[idx][0]][cells[idx][1]] = T_ROCK;
|
||||
return t;
|
||||
}
|
||||
|
||||
// ── Fog of War ────────────────────────────────────────────────────────────────
|
||||
function computeFog(grid, playerId, scouts = []) {
|
||||
const visible = Array(GRID).fill(null).map(() => Array(GRID).fill(false));
|
||||
for (let r=0;r<GRID;r++) for (let c=0;c<GRID;c++) {
|
||||
if (grid[r][c] === playerId) {
|
||||
visible[r][c] = true;
|
||||
for (const [dr,dc] of DIRS8) {
|
||||
const nr=r+dr, nc=c+dc;
|
||||
if (nr>=0&&nr<GRID&&nc>=0&&nc<GRID) visible[nr][nc] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Scout-Aufdeckungen: 3x3 um jeden Scout-Mittelpunkt
|
||||
for (const [sr,sc] of scouts) {
|
||||
for (let dr=-1;dr<=1;dr++) for (let dc=-1;dc<=1;dc++) {
|
||||
const nr=sr+dr, nc=sc+dc;
|
||||
if (nr>=0&&nr<GRID&&nc>=0&&nc<GRID) visible[nr][nc] = true;
|
||||
}
|
||||
}
|
||||
return visible;
|
||||
}
|
||||
|
||||
// ── Hilfsfunktionen ───────────────────────────────────────────────────────────
|
||||
function createGrid(ownerId, oppId) {
|
||||
const corners = [[0,0],[0,GRID-1],[GRID-1,0],[GRID-1,GRID-1]];
|
||||
for (let i=corners.length-1;i>0;i--) {
|
||||
const j=Math.floor(Math.random()*(i+1));
|
||||
[corners[i],corners[j]]=[corners[j],corners[i]];
|
||||
}
|
||||
const [or,oc] = corners[0];
|
||||
const [pr,pc] = corners[1];
|
||||
const g = Array(GRID).fill(null).map(() => Array(GRID).fill(0));
|
||||
g[or][oc] = ownerId;
|
||||
g[pr][pc] = oppId;
|
||||
return { grid:g, ownerHead:`${or},${oc}`, oppHead:`${pr},${pc}` };
|
||||
}
|
||||
|
||||
// Prüft ob [row,col] an den Kopf [hr,hc] angrenzt (8 Richtungen)
|
||||
function isAdjacentToHead(row, col, hr, hc) {
|
||||
return Math.abs(row - hr) <= 1 && Math.abs(col - hc) <= 1 && !(row === hr && col === hc);
|
||||
}
|
||||
|
||||
// Fallback für canMove: prüft ob Spieler noch irgendwo vom Kopf aus ziehen kann
|
||||
function isAdjacent(grid, row, col, playerId) {
|
||||
for (const [dr,dc] of DIRS8) {
|
||||
const r=row+dr, c=col+dc;
|
||||
if (r>=0&&r<GRID&&c>=0&&c<GRID&&grid[r][c]===playerId) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function canMove(grid, terrain, playerId, headRow, headCol) {
|
||||
// Schlangen-Regel: nur Felder die an den Kopf grenzen sind erreichbar
|
||||
if (headRow !== undefined && headCol !== undefined) {
|
||||
for (const [dr,dc] of DIRS8) {
|
||||
const r=headRow+dr, c=headCol+dc;
|
||||
if (r>=0&&r<GRID&&c>=0&&c<GRID&&grid[r][c]===0&&terrain[r][c]!==T_ROCK) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// Fallback (alte Spiele ohne head)
|
||||
for (let r=0;r<GRID;r++) for (let c=0;c<GRID;c++)
|
||||
if (grid[r][c]===0 && terrain[r][c]!==T_ROCK && isAdjacent(grid,r,c,playerId)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Score auf dem vollständigen (ungemaskten) Grid berechnen
|
||||
function calcScore(grid, terrain, playerId) {
|
||||
let score = 0;
|
||||
for (let r=0;r<GRID;r++) for (let c=0;c<GRID;c++) {
|
||||
if (grid[r][c] !== playerId) continue;
|
||||
if (terrain[r][c] === T_GOLD) score += 3;
|
||||
else score += 1; // Mine zählt als normales Feld (+1), Strafe kommt durch Explosion der Nachbarn
|
||||
}
|
||||
return Math.max(0, score);
|
||||
}
|
||||
|
||||
// ── Minenexplosion: alle 8 Nachbarn der Mine werden zerstört ─────────────────
|
||||
// Gibt zurück: { grid, destroyedByOwner, destroyedByOpp, blastCells }
|
||||
// Kettenexplosion: Mine explodiert, alle 8 Nachbarn werden zu Felsen.
|
||||
// Wenn ein Nachbar auch eine Mine ist, explodiert diese ebenfalls (rekursiv).
|
||||
// exploded = Set von bereits gezündeten Minen-Positionen (verhindert Endlosschleife)
|
||||
function explodeMine(grid, terrain, row, col, ownerId, oppId, exploded = new Set()) {
|
||||
const key = `${row},${col}`;
|
||||
if (exploded.has(key)) return { destroyedByOwner:0, destroyedByOpp:0, blastCells:[] };
|
||||
exploded.add(key);
|
||||
|
||||
let destroyedByOwner = 0;
|
||||
let destroyedByOpp = 0;
|
||||
const blastCells = [];
|
||||
const chainMines = [];
|
||||
|
||||
// Das Mine-Feld selbst wird auch zu Felsen
|
||||
grid[row][col] = 0;
|
||||
terrain[row][col] = T_ROCK;
|
||||
blastCells.push([row, col]);
|
||||
|
||||
// Alle 8 Nachbarn werden zu Felsen — erst Ketten-Minen merken, dann umwandeln
|
||||
for (const [dr,dc] of DIRS8) {
|
||||
const r=row+dr, c=col+dc;
|
||||
if (r<0||r>=GRID||c<0||c>=GRID) continue;
|
||||
if (terrain[r][c] === T_ROCK) continue; // schon Felsen
|
||||
|
||||
// Ketten-Mine merken BEVOR terrain geändert wird
|
||||
if (terrain[r][c] === T_MINE && !exploded.has(`${r},${c}`)) {
|
||||
chainMines.push([r,c]);
|
||||
}
|
||||
|
||||
// Punktabzug für besetzte Felder
|
||||
if (grid[r][c] === ownerId) destroyedByOwner++;
|
||||
else if (grid[r][c] === oppId) destroyedByOpp++;
|
||||
|
||||
// Feld wird zu Felsen
|
||||
grid[r][c] = 0;
|
||||
terrain[r][c] = T_ROCK;
|
||||
blastCells.push([r,c]);
|
||||
}
|
||||
|
||||
// Kettenexplosionen
|
||||
for (const [mr,mc] of chainMines) {
|
||||
const chain = explodeMine(grid, terrain, mr, mc, ownerId, oppId, exploded);
|
||||
destroyedByOwner += chain.destroyedByOwner;
|
||||
destroyedByOpp += chain.destroyedByOpp;
|
||||
blastCells.push(...chain.blastCells);
|
||||
}
|
||||
|
||||
return { destroyedByOwner, destroyedByOpp, blastCells };
|
||||
}
|
||||
|
||||
function sendPush(userId, title, message) {
|
||||
const cfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(userId);
|
||||
if (!cfg?.app_token || !cfg?.user_key) return;
|
||||
fetch('https://api.pushover.net/1/messages.json', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ token:cfg.app_token, user:cfg.user_key, title, message, priority:0 }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// Spiel laden + Fog anwenden + echte Scores beifügen
|
||||
function getGameForUser(id, requesterId) {
|
||||
const game = db.prepare(`
|
||||
SELECT g.*, u1.username as owner_name, u2.username as opp_name
|
||||
FROM geo_games g
|
||||
JOIN users u1 ON u1.id = g.owner_id
|
||||
JOIN users u2 ON u2.id = g.opponent_id
|
||||
WHERE g.id = ?
|
||||
`).get(id);
|
||||
if (!game) return null;
|
||||
|
||||
const grid = JSON.parse(game.grid);
|
||||
const terrain = JSON.parse(game.terrain || '[]');
|
||||
|
||||
// Echte Scores auf ungemasktem Grid berechnen
|
||||
const ownerScore = terrain.length ? calcScore(grid, terrain, game.owner_id) : 0;
|
||||
const oppScore = terrain.length ? calcScore(grid, terrain, game.opponent_id) : 0;
|
||||
// Dem Requester mitteilen welcher Score seiner ist
|
||||
const myScore = requesterId === game.owner_id ? ownerScore : oppScore;
|
||||
const oppSc = requesterId === game.owner_id ? oppScore : ownerScore;
|
||||
|
||||
const myHead = requesterId === game.owner_id ? game.owner_head : game.opp_head;
|
||||
const oppHead = requesterId === game.owner_id ? game.opp_head : game.owner_head;
|
||||
|
||||
// Altes Spiel ohne Terrain: direkt zurückgeben, kein Fog
|
||||
if (!terrain.length) {
|
||||
return { ...game, myScore:0, oppScore:0, myHead, oppHead };
|
||||
}
|
||||
|
||||
// Scouts des anfragenden Spielers laden
|
||||
const myScoutsRaw = requesterId === game.owner_id ? game.owner_scouts : game.opp_scouts;
|
||||
const myScouts = myScoutsRaw ? JSON.parse(myScoutsRaw) : [];
|
||||
|
||||
// Bei beendetem Spiel: vollständiges Grid ohne Fog
|
||||
let maskedGrid, maskedTerrain;
|
||||
if (game.status === 'finished') {
|
||||
maskedGrid = grid;
|
||||
maskedTerrain = terrain;
|
||||
} else {
|
||||
const visible = computeFog(grid, requesterId, myScouts);
|
||||
maskedGrid = grid.map((row,r) => row.map((cell,c) => visible[r][c] ? cell : (cell===requesterId ? cell : 0)));
|
||||
maskedTerrain = terrain.map((row,r) => row.map((cell,c) => visible[r][c] ? cell : -1));
|
||||
}
|
||||
|
||||
return {
|
||||
...game,
|
||||
grid: JSON.stringify(maskedGrid),
|
||||
terrain: JSON.stringify(maskedTerrain),
|
||||
myScore,
|
||||
oppScore: oppSc,
|
||||
myHead,
|
||||
oppHead,
|
||||
myScouts,
|
||||
};
|
||||
}
|
||||
|
||||
const uid = req => req.user.id;
|
||||
|
||||
// ── Alle Spiele ───────────────────────────────────────────────────────────────
|
||||
router.get('/', authenticate, (req, res) => {
|
||||
const me = uid(req);
|
||||
const games = db.prepare(`
|
||||
SELECT g.*, u1.username as owner_name, u2.username as opp_name
|
||||
FROM geo_games g
|
||||
JOIN users u1 ON u1.id = g.owner_id
|
||||
JOIN users u2 ON u2.id = g.opponent_id
|
||||
WHERE (g.owner_id=? OR g.opponent_id=?)
|
||||
AND NOT (g.status='finished' AND g.move_count < 2)
|
||||
ORDER BY g.updated_at DESC
|
||||
`).all(me, me);
|
||||
res.json(games);
|
||||
});
|
||||
|
||||
// ── Meine Züge + ungesehene Spielenden ──────────────────────────────────────
|
||||
router.get('/my-turns', authenticate, (req, res) => {
|
||||
const me = uid(req);
|
||||
// Aktive Spiele wo ich dran bin
|
||||
const { count: turns } = db.prepare(`
|
||||
SELECT COUNT(*) as count FROM geo_games
|
||||
WHERE status='active' AND current_turn=?
|
||||
`).get(me);
|
||||
// Beendete Spiele wo ich beteiligt bin aber noch nicht gesehen habe
|
||||
const games = db.prepare(`
|
||||
SELECT id, seen_by FROM geo_games
|
||||
WHERE status='finished' AND move_count >= 2
|
||||
AND (owner_id=? OR opponent_id=?)
|
||||
`).all(me, me);
|
||||
const unseen = games.filter(g => {
|
||||
const seen = g.seen_by ? JSON.parse(g.seen_by) : [];
|
||||
return !seen.includes(me);
|
||||
}).length;
|
||||
res.json({ count: turns + unseen });
|
||||
});
|
||||
|
||||
// ── Topliste ──────────────────────────────────────────────────────────────────
|
||||
router.get('/leaderboard', authenticate, (req, res) => {
|
||||
const rows = db.prepare(`
|
||||
SELECT u.username, COUNT(*) as wins
|
||||
FROM geo_games g
|
||||
JOIN users u ON u.id = g.winner_id
|
||||
WHERE g.status='finished' AND g.winner_id IS NOT NULL AND g.move_count >= 2
|
||||
GROUP BY g.winner_id ORDER BY wins DESC LIMIT 20
|
||||
`).all();
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
// ── User ──────────────────────────────────────────────────────────────────────
|
||||
router.get('/users', authenticate, (req, res) => {
|
||||
const me = uid(req);
|
||||
const isAdmin = req.user?.role === 'admin';
|
||||
const users = isAdmin
|
||||
? db.prepare('SELECT id, username FROM users WHERE id != ? ORDER BY username').all(me)
|
||||
: db.prepare('SELECT id, username FROM users WHERE id != ? AND hidden != 1 ORDER BY username').all(me);
|
||||
res.json(users);
|
||||
});
|
||||
|
||||
// ── Einzelnes Spiel ───────────────────────────────────────────────────────────
|
||||
router.get('/:id', authenticate, (req, res) => {
|
||||
const me = uid(req);
|
||||
const game = getGameForUser(req.params.id, me);
|
||||
if (!game) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
if (game.owner_id !== me && game.opponent_id !== me) return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
res.json(game);
|
||||
});
|
||||
|
||||
// ── Neues Spiel ───────────────────────────────────────────────────────────────
|
||||
router.post('/', authenticate, (req, res) => {
|
||||
const me = uid(req);
|
||||
const { opponent_id } = req.body;
|
||||
if (!opponent_id) return res.status(400).json({ error: 'Gegner fehlt' });
|
||||
if (Number(opponent_id) === me) return res.status(400).json({ error: 'Du kannst nicht gegen dich spielen' });
|
||||
|
||||
const opp = db.prepare('SELECT id, username FROM users WHERE id=?').get(opponent_id);
|
||||
if (!opp) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
|
||||
|
||||
const { grid, ownerHead, oppHead } = createGrid(me, Number(opponent_id));
|
||||
const terrain = generateTerrain(ownerHead, oppHead);
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO geo_games (owner_id, opponent_id, grid, terrain, current_turn, move_count, owner_head, opp_head)
|
||||
VALUES (?, ?, ?, ?, ?, 0, ?, ?)
|
||||
`).run(me, Number(opponent_id), JSON.stringify(grid), JSON.stringify(terrain), me, ownerHead, oppHead);
|
||||
|
||||
const myName = db.prepare('SELECT username FROM users WHERE id=?').get(me)?.username || 'Jemand';
|
||||
sendPush(Number(opponent_id), '⬡ Hex Wars', `${myName} hat dich zu einem Spiel eingeladen! Du bist zuerst dran.`);
|
||||
res.json({ id: result.lastInsertRowid });
|
||||
});
|
||||
|
||||
// ── Zug machen ────────────────────────────────────────────────────────────────
|
||||
router.post('/:id/move', authenticate, (req, res) => {
|
||||
const me = uid(req);
|
||||
const { row, col } = req.body;
|
||||
if (row === undefined || col === undefined) return res.status(400).json({ error: 'row/col fehlt' });
|
||||
|
||||
const game = db.prepare('SELECT * FROM geo_games WHERE id=?').get(req.params.id);
|
||||
if (!game) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
if (game.owner_id !== me && game.opponent_id !== me) return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
if (game.status !== 'active') return res.status(400).json({ error: 'Spiel beendet' });
|
||||
if (game.current_turn !== me) return res.status(400).json({ error: 'Nicht dein Zug' });
|
||||
|
||||
const grid = JSON.parse(game.grid);
|
||||
const terrain = JSON.parse(game.terrain || '[]');
|
||||
|
||||
if (row<0||row>=GRID||col<0||col>=GRID) return res.status(400).json({ error: 'Ungültige Position' });
|
||||
if (grid[row][col] !== 0) return res.status(400).json({ error: 'Zelle belegt' });
|
||||
if (terrain.length && terrain[row][col] === T_ROCK) return res.status(400).json({ error: 'Felsen — nicht betretbar' });
|
||||
// Schlangen-Regel: Zug muss an den eigenen Kopf angrenzen
|
||||
const myHeadStr = game.owner_id === me ? game.owner_head : game.opp_head;
|
||||
const [myHR, myHC] = myHeadStr ? myHeadStr.split(',').map(Number) : [null, null];
|
||||
if (myHR !== null && !isAdjacentToHead(row, col, myHR, myHC))
|
||||
return res.status(400).json({ error: 'Nur an deinen Kopf anbauen!' });
|
||||
if (myHR === null && !isAdjacent(grid, row, col, me))
|
||||
return res.status(400).json({ error: 'Nicht angrenzend' });
|
||||
|
||||
const opponent = game.owner_id === me ? game.opponent_id : game.owner_id;
|
||||
const newMoves = (game.move_count || 0) + 1;
|
||||
const myName = db.prepare('SELECT username FROM users WHERE id=?').get(me)?.username || 'Jemand';
|
||||
const oppName = db.prepare('SELECT username FROM users WHERE id=?').get(opponent)?.username || 'Jemand';
|
||||
|
||||
// Zug ausführen
|
||||
grid[row][col] = me;
|
||||
|
||||
// Neuen Kopf setzen
|
||||
let newOwnerHead = game.owner_head;
|
||||
let newOppHead = game.opp_head;
|
||||
if (game.owner_id === me) newOwnerHead = `${row},${col}`;
|
||||
else newOppHead = `${row},${col}`;
|
||||
|
||||
let lastEvent = null;
|
||||
let mineHit = false;
|
||||
let blastCells = [];
|
||||
let destroyedOwn = 0;
|
||||
let destroyedOpp = 0;
|
||||
|
||||
// ── Mine betreten: Explosion der 8 Nachbarn ──────────────────────────────
|
||||
if (terrain.length && terrain[row][col] === T_MINE) {
|
||||
mineHit = true;
|
||||
const explosion = explodeMine(grid, terrain, row, col, me, opponent);
|
||||
blastCells = explosion.blastCells;
|
||||
destroyedOwn = explosion.destroyedByOwner;
|
||||
destroyedOpp = explosion.destroyedByOpp;
|
||||
lastEvent = JSON.stringify({ type:'mine', player:me, row, col, blastCells, destroyedOwn, destroyedOpp });
|
||||
|
||||
// Kopf zurücksetzen: Mine-Feld gehört dem Spieler, aber Kopf soll auf das letzte
|
||||
// noch existierende eigene Feld vor dem Mine-Zug zeigen.
|
||||
// Der alte Kopf (myHR, myHC) wird durch explodeMine nicht zerstört (Explosion trifft Nachbarn),
|
||||
// also ist er noch vorhanden — Kopf bleibt beim alten Kopf (vor dem Mine-Zug).
|
||||
// Falls kein alter Kopf bekannt: suche nächstes eigenes Feld neben der Mine.
|
||||
// Kopf nach Explosion: erstes eigenes Feld das AUSSERHALB des 3x3-Radius der Mine liegt
|
||||
// (Mine + 8 Nachbarn sind alle Felsen — der Kopf muss weiter weg sein)
|
||||
let safeHead = null;
|
||||
// Alle eigenen Felder sammeln und nach Entfernung zur Mine sortieren
|
||||
const ownCells = [];
|
||||
for (let r=0;r<GRID;r++) for (let c=0;c<GRID;c++) {
|
||||
if (grid[r][c] === me) {
|
||||
const outside = Math.abs(r-row)>1 || Math.abs(c-col)>1;
|
||||
if (outside) ownCells.push([r, c, Math.abs(r-row)+Math.abs(c-col)]);
|
||||
}
|
||||
}
|
||||
// Nächstes eigenes Feld außerhalb des Radius (Manhattan-Distanz minimal)
|
||||
if (ownCells.length > 0) {
|
||||
ownCells.sort((a,b) => a[2]-b[2]);
|
||||
safeHead = `${ownCells[0][0]},${ownCells[0][1]}`;
|
||||
}
|
||||
if (safeHead) {
|
||||
if (game.owner_id === me) newOwnerHead = safeHead;
|
||||
else newOppHead = safeHead;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mine ins Sichtfeld gekommen: Explosion prüfen ────────────────────────
|
||||
// Alle Minen die jetzt im Sichtfeld von BEIDEN Spielern liegen und noch nicht explodiert sind
|
||||
// werden gezündet sobald ein Spieler sie sieht (d.h. sie grenzen an sein Gebiet)
|
||||
// Dieses Feature: Mine explodiert wenn sie ins Sichtfeld kommt (nicht wenn betreten)
|
||||
// → wird beim Betreten gehandhabt (oben), kein extra Trigger nötig
|
||||
|
||||
// Nächster Zug
|
||||
let nextTurn = mineHit ? opponent : opponent; // Mine = Zug verlieren
|
||||
let status = 'active';
|
||||
let winnerId = null;
|
||||
|
||||
// Effektiver Kopf nach Zug (bei Mine: zurückgesetzter Kopf, sonst neue Position)
|
||||
const oppHeadStr = game.owner_id === me ? game.opp_head : game.owner_head;
|
||||
const [oppHR, oppHC] = oppHeadStr ? oppHeadStr.split(',').map(Number) : [null, null];
|
||||
const effectiveMyHeadStr = game.owner_id === me ? newOwnerHead : newOppHead;
|
||||
const [effectiveMyHR, effectiveMyHC] = effectiveMyHeadStr
|
||||
? effectiveMyHeadStr.split(',').map(Number) : [row, col];
|
||||
|
||||
// Spielende: niemand kann mehr ziehen
|
||||
if (!canMove(grid, terrain, opponent, oppHR, oppHC) && !canMove(grid, terrain, me, effectiveMyHR, effectiveMyHC)) {
|
||||
status = 'finished';
|
||||
} else if (!canMove(grid, terrain, opponent, oppHR, oppHC)) {
|
||||
nextTurn = me;
|
||||
}
|
||||
|
||||
// Schnellsieg: jemand hat doppelt so viele Punkte wie der Gegner (mind. 10 Züge gespielt)
|
||||
if (status === 'active' && newMoves >= 10) {
|
||||
const ms = calcScore(grid, terrain, me);
|
||||
const os = calcScore(grid, terrain, opponent);
|
||||
if (ms >= os * 2 && os > 0) { status = 'finished'; }
|
||||
else if (os >= ms * 2 && ms > 0) { status = 'finished'; }
|
||||
}
|
||||
|
||||
if (status === 'finished') {
|
||||
const ms = calcScore(grid, terrain, me);
|
||||
const os = calcScore(grid, terrain, opponent);
|
||||
winnerId = ms > os ? me : (os > ms ? opponent : null);
|
||||
}
|
||||
|
||||
// last_event: nach jedem Zug das vorherige Event clearen (war vom letzten Zug)
|
||||
// Nur das neue Event (falls Mine) bleibt stehen
|
||||
const finalEvent = lastEvent; // null wenn kein Mine-Treffer
|
||||
|
||||
db.prepare(`
|
||||
UPDATE geo_games SET grid=?, terrain=?, current_turn=?, status=?, winner_id=?, move_count=?, last_event=?,
|
||||
owner_head=?, opp_head=?, updated_at=datetime('now','localtime') WHERE id=?
|
||||
`).run(JSON.stringify(grid), JSON.stringify(terrain), nextTurn, status, winnerId, newMoves, finalEvent, newOwnerHead, newOppHead, game.id);
|
||||
|
||||
// Pushover
|
||||
if (status === 'finished') {
|
||||
const ms = calcScore(grid, terrain, me);
|
||||
const os = calcScore(grid, terrain, opponent);
|
||||
const reason = newMoves >= 10 && (ms >= os*2 || os >= ms*2) ? ' (Dominanzsieg!)' : '';
|
||||
if (winnerId === me) {
|
||||
sendPush(opponent, '⬡ Hex Wars beendet', `${myName} hat gewonnen!${reason} ${ms} vs ${os} Punkte.`);
|
||||
sendPush(me, '⬡ Hex Wars gewonnen!', `Du hast gewonnen!${reason} ${ms} vs ${os} Punkte. 🎉`);
|
||||
} else if (winnerId === opponent) {
|
||||
sendPush(me, '⬡ Hex Wars beendet', `${oppName} hat gewonnen.${reason} ${ms} vs ${os} Punkte.`);
|
||||
sendPush(opponent, '⬡ Hex Wars gewonnen!', `Du hast gewonnen!${reason} ${os} vs ${ms} Punkte. 🎉`);
|
||||
} else {
|
||||
sendPush(me, '⬡ Hex Wars — Unentschieden!', `${ms} vs ${os} Punkte.`);
|
||||
sendPush(opponent, '⬡ Hex Wars — Unentschieden!', `${os} vs ${ms} Punkte.`);
|
||||
}
|
||||
} else {
|
||||
if (mineHit) {
|
||||
sendPush(me, '⬡ Hex Wars — 💣 Mine!', `Du hast eine Mine getroffen! Explosion zerstörte ${destroyedOwn + destroyedOpp} Felder. Du verlierst deinen nächsten Zug.`);
|
||||
sendPush(opponent, '⬡ Hex Wars — 💥 Explosion!', `${myName} hat eine Mine getroffen! ${destroyedOpp} deiner Felder wurden zerstört. Du bist dran!`);
|
||||
} else if (nextTurn === opponent) {
|
||||
sendPush(opponent, '⬡ Hex Wars — Du bist dran!', `${myName} hat gezogen!`);
|
||||
}
|
||||
}
|
||||
|
||||
res.json(getGameForUser(game.id, me));
|
||||
});
|
||||
|
||||
// ── Spiel als gesehen markieren ──────────────────────────────────────────────
|
||||
router.post('/:id/seen', authenticate, (req, res) => {
|
||||
const me = uid(req);
|
||||
const game = db.prepare('SELECT * FROM geo_games WHERE id=?').get(req.params.id);
|
||||
if (!game) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
if (game.owner_id !== me && game.opponent_id !== me) return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
const seen = game.seen_by ? JSON.parse(game.seen_by) : [];
|
||||
if (!seen.includes(me)) {
|
||||
seen.push(me);
|
||||
db.prepare('UPDATE geo_games SET seen_by=? WHERE id=?').run(JSON.stringify(seen), game.id);
|
||||
}
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Scout: 3x3 Bereich aufdecken (kostet Zug) ───────────────────────────────
|
||||
router.post('/:id/scout', authenticate, (req, res) => {
|
||||
const me = uid(req);
|
||||
const { row, col } = req.body;
|
||||
if (row === undefined || col === undefined) return res.status(400).json({ error: 'row/col fehlt' });
|
||||
|
||||
const game = db.prepare('SELECT * FROM geo_games WHERE id=?').get(req.params.id);
|
||||
if (!game) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
if (game.owner_id !== me && game.opponent_id !== me) return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
if (game.status !== 'active') return res.status(400).json({ error: 'Spiel beendet' });
|
||||
if (game.current_turn !== me) return res.status(400).json({ error: 'Nicht dein Zug' });
|
||||
|
||||
const opponent = game.owner_id === me ? game.opponent_id : game.owner_id;
|
||||
const newMoves = (game.move_count || 0) + 1;
|
||||
|
||||
// Scouts des Spielers laden und neuen hinzufügen
|
||||
const isOwner = game.owner_id === me;
|
||||
const scoutsKey = isOwner ? 'owner_scouts' : 'opp_scouts';
|
||||
const existingRaw = game[scoutsKey];
|
||||
const existing = existingRaw ? JSON.parse(existingRaw) : [];
|
||||
existing.push([row, col]);
|
||||
const newScoutsStr = JSON.stringify(existing);
|
||||
|
||||
// Zug weitergeben (Scout kostet Zug)
|
||||
const grid = JSON.parse(game.grid);
|
||||
const terrain = JSON.parse(game.terrain || '[]');
|
||||
let status = 'active', winnerId = null;
|
||||
|
||||
// Schnellsieg-Check
|
||||
if (newMoves >= 10 && terrain.length) {
|
||||
const ms = calcScore(grid, terrain, me);
|
||||
const os = calcScore(grid, terrain, opponent);
|
||||
if (ms >= os*2 && os > 0) status = 'finished';
|
||||
else if (os >= ms*2 && ms > 0) status = 'finished';
|
||||
if (status === 'finished') {
|
||||
winnerId = ms > os ? me : (os > ms ? opponent : null);
|
||||
}
|
||||
}
|
||||
|
||||
const updateFields = isOwner
|
||||
? 'owner_scouts=?, current_turn=?, status=?, winner_id=?, move_count=?, last_event=NULL'
|
||||
: 'opp_scouts=?, current_turn=?, status=?, winner_id=?, move_count=?, last_event=NULL';
|
||||
|
||||
db.prepare(`UPDATE geo_games SET ${updateFields}, updated_at=datetime('now','localtime') WHERE id=?`)
|
||||
.run(newScoutsStr, opponent, status, winnerId, newMoves, game.id);
|
||||
|
||||
const myName = db.prepare('SELECT username FROM users WHERE id=?').get(me)?.username || 'Jemand';
|
||||
sendPush(opponent, '⬡ Hex Wars — Du bist dran!', `${myName} hat gekundschaftet — jetzt bist du dran!`);
|
||||
|
||||
res.json(getGameForUser(game.id, me));
|
||||
});
|
||||
|
||||
// ── Aufgeben ──────────────────────────────────────────────────────────────────
|
||||
router.post('/:id/resign', authenticate, (req, res) => {
|
||||
const me = uid(req);
|
||||
const game = db.prepare('SELECT * FROM geo_games WHERE id=?').get(req.params.id);
|
||||
if (!game) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
if (game.owner_id !== me && game.opponent_id !== me) return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
if (game.status !== 'active') return res.status(400).json({ error: 'Spiel bereits beendet' });
|
||||
|
||||
const winner = game.owner_id === me ? game.opponent_id : game.owner_id;
|
||||
// move_count auf min. 2 setzen damit Spiel in Liste + Leaderboard erscheint
|
||||
// seen_by: aufgebender Spieler hat es gesehen, Gewinner noch nicht
|
||||
const newMoveCount = Math.max(game.move_count || 0, 2);
|
||||
const seenBy = JSON.stringify([me]); // nur der Aufgebende hat es "gesehen"
|
||||
db.prepare(`
|
||||
UPDATE geo_games SET status='finished', winner_id=?, move_count=?, seen_by=?,
|
||||
updated_at=datetime('now','localtime') WHERE id=?
|
||||
`).run(winner, newMoveCount, seenBy, game.id);
|
||||
const myName = db.prepare('SELECT username FROM users WHERE id=?').get(me)?.username || 'Jemand';
|
||||
sendPush(winner, '⬡ Hex Wars gewonnen!', `${myName} hat aufgegeben. Du gewinnst! 🎉`);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Löschen (Admin) ───────────────────────────────────────────────────────────
|
||||
router.delete('/:id', authenticate, (req, res) => {
|
||||
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
const game = db.prepare('SELECT * FROM geo_games WHERE id=?').get(req.params.id);
|
||||
if (!game) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
if (game.status !== 'finished') return res.status(400).json({ error: 'Nur beendete Spiele können gelöscht werden' });
|
||||
db.prepare('DELETE FROM geo_games WHERE id=?').run(req.params.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,50 +1,184 @@
|
||||
// Tool: 3D-Druck-Kalkulator
|
||||
// Routen werden unter /api/tools/kalkulator3d/... eingebunden
|
||||
const express = require('express');
|
||||
const db = require('../../db');
|
||||
const { authenticate } = require('../../middleware/auth');
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/', authenticate, (req, res) => {
|
||||
res.json(db.prepare('SELECT * FROM calculations WHERE user_id=? ORDER BY created_at DESC').all(req.user.id));
|
||||
const uid = req.user.id;
|
||||
const items = db.prepare('SELECT * FROM calculations WHERE user_id=? ORDER BY created_at DESC').all(uid);
|
||||
|
||||
const baseFolder = db.prepare(
|
||||
"SELECT id FROM folders WHERE user_id=? AND name='3D-Modelle' AND parent_id IS NULL"
|
||||
).get(uid);
|
||||
let filesMap = {};
|
||||
if (baseFolder) {
|
||||
const rows = db.prepare(`
|
||||
SELECT f.name, COUNT(fi.id) AS cnt FROM folders f
|
||||
LEFT JOIN files fi ON fi.folder_id = f.id
|
||||
WHERE f.parent_id = ? AND f.user_id = ? GROUP BY f.id
|
||||
`).all(baseFolder.id, uid);
|
||||
for (const r of rows) filesMap[r.name] = r.cnt > 0;
|
||||
}
|
||||
|
||||
// Geteilt mit mir
|
||||
const shared = db.prepare(`
|
||||
SELECT c.*, u.username as owner_name, 1 as is_shared
|
||||
FROM calculations c
|
||||
JOIN calculation_shares cs ON cs.calc_id = c.id
|
||||
JOIN users u ON u.id = c.user_id
|
||||
WHERE cs.shared_with = ?
|
||||
ORDER BY c.created_at DESC
|
||||
`).all(uid);
|
||||
|
||||
// Geteilt von mir
|
||||
const sharedByMe = db.prepare(`
|
||||
SELECT c.*, u.username as shared_with_name, cs.created_at as shared_at
|
||||
FROM calculations c
|
||||
JOIN calculation_shares cs ON cs.calc_id = c.id
|
||||
JOIN users u ON u.id = cs.shared_with
|
||||
WHERE c.user_id = ?
|
||||
ORDER BY c.name ASC
|
||||
`).all(uid);
|
||||
|
||||
// has_files auch für shared items prüfen (anhand owner's Ordner)
|
||||
const sharedWithFiles = shared.map(i => {
|
||||
const ownerBase = db.prepare(
|
||||
"SELECT id FROM folders WHERE user_id=? AND name='3D-Modelle' AND parent_id IS NULL"
|
||||
).get(i.user_id);
|
||||
let has_files = false;
|
||||
if (ownerBase) {
|
||||
const sub = db.prepare(
|
||||
'SELECT id FROM folders WHERE user_id=? AND name=? AND parent_id=?'
|
||||
).get(i.user_id, i.name, ownerBase.id);
|
||||
if (sub) {
|
||||
const cnt = db.prepare('SELECT COUNT(*) AS c FROM files WHERE folder_id=?').get(sub.id);
|
||||
has_files = cnt.c > 0;
|
||||
}
|
||||
}
|
||||
return { ...i, has_files };
|
||||
});
|
||||
|
||||
res.json({
|
||||
own: items.map(i => ({ ...i, has_files: !!filesMap[i.name] })),
|
||||
shared: sharedWithFiles,
|
||||
sharedByMe: sharedByMe.map(i => ({ ...i, has_files: !!filesMap[i.name] })),
|
||||
});
|
||||
});
|
||||
|
||||
// Dateien eines Modells abrufen (für Owner + shared users)
|
||||
router.get('/:id/files', authenticate, (req, res) => {
|
||||
const uid = req.user.id;
|
||||
const calc = db.prepare('SELECT * FROM calculations WHERE id=?').get(req.params.id);
|
||||
if (!calc) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const isOwner = calc.user_id === uid;
|
||||
const isShared = db.prepare('SELECT id FROM calculation_shares WHERE calc_id=? AND shared_with=?').get(calc.id, uid);
|
||||
if (!isOwner && !isShared) return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
|
||||
const baseFolder = db.prepare(
|
||||
"SELECT id FROM folders WHERE user_id=? AND name='3D-Modelle' AND parent_id IS NULL"
|
||||
).get(calc.user_id);
|
||||
if (!baseFolder) return res.json([]);
|
||||
const sub = db.prepare('SELECT id FROM folders WHERE user_id=? AND name=? AND parent_id=?')
|
||||
.get(calc.user_id, calc.name, baseFolder.id);
|
||||
if (!sub) return res.json([]);
|
||||
res.json(db.prepare('SELECT * FROM files WHERE folder_id=? ORDER BY created_at ASC').all(sub.id));
|
||||
});
|
||||
|
||||
// Einzelne Datei herunterladen (Owner + shared users)
|
||||
router.get('/:id/files/:fileId/download', authenticate, (req, res) => {
|
||||
const uid = req.user.id;
|
||||
const calc = db.prepare('SELECT * FROM calculations WHERE id=?').get(req.params.id);
|
||||
if (!calc) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const isOwner = calc.user_id === uid;
|
||||
const isShared = db.prepare('SELECT id FROM calculation_shares WHERE calc_id=? AND shared_with=?').get(calc.id, uid);
|
||||
if (!isOwner && !isShared) return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
|
||||
const file = db.prepare('SELECT * FROM files WHERE id=?').get(req.params.fileId);
|
||||
if (!file) return res.status(404).json({ error: 'Datei nicht gefunden' });
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/data/uploads';
|
||||
const fp = path.join(UPLOAD_DIR, file.filename);
|
||||
if (!fs.existsSync(fp)) return res.status(404).json({ error: 'Datei fehlt' });
|
||||
res.download(fp, file.originalname);
|
||||
});
|
||||
|
||||
|
||||
// Share-Management
|
||||
router.get('/:id/shares', authenticate, (req, res) => {
|
||||
const calc = db.prepare('SELECT * FROM calculations WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!calc) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
res.json(db.prepare(`
|
||||
SELECT u.id, u.username, cs.created_at as shared_at
|
||||
FROM calculation_shares cs JOIN users u ON u.id = cs.shared_with
|
||||
WHERE cs.calc_id = ?
|
||||
`).all(calc.id));
|
||||
});
|
||||
|
||||
router.post('/:id/share', authenticate, (req, res) => {
|
||||
const calc = db.prepare('SELECT * FROM calculations WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!calc) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const target = db.prepare('SELECT * FROM users WHERE username=?').get(req.body.username);
|
||||
if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
|
||||
if (target.id === req.user.id) return res.status(400).json({ error: 'Kann nicht mit dir selbst teilen' });
|
||||
db.prepare("INSERT OR IGNORE INTO calculation_shares (calc_id, shared_by, shared_with, created_at) VALUES (?,?,?,datetime('now','localtime'))")
|
||||
.run(calc.id, req.user.id, target.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.delete('/:id/share/:userId', authenticate, (req, res) => {
|
||||
const calc = db.prepare('SELECT * FROM calculations WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!calc) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
db.prepare('DELETE FROM calculation_shares WHERE calc_id=? AND shared_with=?').run(calc.id, req.params.userId);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post('/', authenticate, (req, res) => {
|
||||
const { name, gramm, stunden, farben,
|
||||
materialpreis_pro_gramm, stromverbrauch_kw, strompreis_pro_kwh,
|
||||
druckerpreis, gesamtdruckstunden, verschleiss_pro_stunde,
|
||||
preis_freundschaft, preis_normal, preis_auftrag } = req.body;
|
||||
preis_freundschaft, preis_normal, preis_auftrag,
|
||||
image = null, bemerkung = '' } = req.body;
|
||||
|
||||
if (!name || gramm == null || stunden == null)
|
||||
return res.status(400).json({ error: 'Name, Gramm und Stunden erforderlich' });
|
||||
|
||||
const r = db.prepare(`INSERT INTO calculations
|
||||
(user_id,name,gramm,stunden,farben,materialpreis_pro_gramm,stromverbrauch_kw,
|
||||
strompreis_pro_kwh,druckerpreis,gesamtdruckstunden,verschleiss_pro_stunde,
|
||||
preis_freundschaft,preis_normal,preis_auftrag)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
||||
preis_freundschaft,preis_normal,preis_auftrag,image,bemerkung)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
||||
.run(req.user.id, name, gramm, stunden, farben,
|
||||
materialpreis_pro_gramm, stromverbrauch_kw, strompreis_pro_kwh,
|
||||
druckerpreis, gesamtdruckstunden, verschleiss_pro_stunde,
|
||||
preis_freundschaft, preis_normal, preis_auftrag);
|
||||
preis_freundschaft, preis_normal, preis_auftrag, image, bemerkung);
|
||||
|
||||
res.json(db.prepare('SELECT * FROM calculations WHERE id=?').get(r.lastInsertRowid));
|
||||
});
|
||||
|
||||
router.put('/:id', authenticate, (req, res) => {
|
||||
const ex = db.prepare('SELECT * FROM calculations WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!ex) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
|
||||
const { name, gramm, stunden, farben,
|
||||
materialpreis_pro_gramm, stromverbrauch_kw, strompreis_pro_kwh,
|
||||
druckerpreis, gesamtdruckstunden, verschleiss_pro_stunde,
|
||||
preis_freundschaft, preis_normal, preis_auftrag } = req.body;
|
||||
preis_freundschaft, preis_normal, preis_auftrag,
|
||||
image = ex.image, bemerkung = ex.bemerkung } = req.body;
|
||||
|
||||
db.prepare(`UPDATE calculations SET
|
||||
name=?,gramm=?,stunden=?,farben=?,materialpreis_pro_gramm=?,stromverbrauch_kw=?,
|
||||
strompreis_pro_kwh=?,druckerpreis=?,gesamtdruckstunden=?,verschleiss_pro_stunde=?,
|
||||
preis_freundschaft=?,preis_normal=?,preis_auftrag=?,updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=? AND user_id=?`)
|
||||
preis_freundschaft=?,preis_normal=?,preis_auftrag=?,image=?,bemerkung=?,
|
||||
updated_at=CURRENT_TIMESTAMP WHERE id=? AND user_id=?`)
|
||||
.run(name, gramm, stunden, farben,
|
||||
materialpreis_pro_gramm, stromverbrauch_kw, strompreis_pro_kwh,
|
||||
druckerpreis, gesamtdruckstunden, verschleiss_pro_stunde,
|
||||
preis_freundschaft, preis_normal, preis_auftrag,
|
||||
preis_freundschaft, preis_normal, preis_auftrag, image, bemerkung,
|
||||
req.params.id, req.user.id);
|
||||
|
||||
res.json(db.prepare('SELECT * FROM calculations WHERE id=?').get(req.params.id));
|
||||
});
|
||||
|
||||
|
||||
155
backend/src/tools/kanban/routes.js
Normal file
@@ -0,0 +1,155 @@
|
||||
const express = require('express');
|
||||
const db = require('../../db');
|
||||
const { authenticate } = require('../../middleware/auth');
|
||||
const router = express.Router();
|
||||
|
||||
// ── DB-Migration ──────────────────────────────────────────────────────────────
|
||||
(function migrate() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS kanban_columns (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
color TEXT NOT NULL DEFAULT '#4ecdc4',
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS kanban_cards (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
column_id INTEGER NOT NULL REFERENCES kanban_columns(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
priority TEXT NOT NULL DEFAULT 'none',
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
|
||||
);
|
||||
`);
|
||||
// Nachrüsten falls Tabelle schon existiert aber color fehlt
|
||||
const cols = db.pragma('table_info(kanban_columns)').map(c => c.name);
|
||||
if (!cols.includes('color')) {
|
||||
db.exec("ALTER TABLE kanban_columns ADD COLUMN color TEXT NOT NULL DEFAULT '#4ecdc4'");
|
||||
}
|
||||
})();
|
||||
|
||||
const uid = req => req.user.id;
|
||||
|
||||
// ── Alle Spalten + Karten laden ───────────────────────────────────────────────
|
||||
router.get('/board', authenticate, (req, res) => {
|
||||
const me = uid(req);
|
||||
const columns = db.prepare(
|
||||
'SELECT * FROM kanban_columns WHERE user_id=? ORDER BY position ASC, id ASC'
|
||||
).all(me);
|
||||
const cards = db.prepare(
|
||||
'SELECT * FROM kanban_cards WHERE user_id=? ORDER BY position ASC, id ASC'
|
||||
).all(me);
|
||||
const colMap = {};
|
||||
for (const c of columns) { c.cards = []; colMap[c.id] = c; }
|
||||
for (const card of cards) {
|
||||
if (colMap[card.column_id]) colMap[card.column_id].cards.push(card);
|
||||
}
|
||||
res.json({ columns });
|
||||
});
|
||||
|
||||
// ── Spalte erstellen ──────────────────────────────────────────────────────────
|
||||
router.post('/columns', authenticate, (req, res) => {
|
||||
const { title, color = '#4ecdc4' } = req.body;
|
||||
if (!title?.trim()) return res.status(400).json({ error: 'Titel fehlt' });
|
||||
const me = uid(req);
|
||||
const maxPos = db.prepare('SELECT MAX(position) as m FROM kanban_columns WHERE user_id=?').get(me);
|
||||
const pos = (maxPos?.m ?? -1) + 1;
|
||||
const r = db.prepare(
|
||||
"INSERT INTO kanban_columns (user_id,title,color,position) VALUES (?,?,?,?)"
|
||||
).run(me, title.trim(), color, pos);
|
||||
res.json(db.prepare('SELECT * FROM kanban_columns WHERE id=?').get(r.lastInsertRowid));
|
||||
});
|
||||
|
||||
// ── Spalte aktualisieren (Titel + Farbe) ─────────────────────────────────────
|
||||
// Feste Route vor :param (Lesson Learned)
|
||||
router.post('/columns/reorder', authenticate, (req, res) => {
|
||||
const { order } = req.body;
|
||||
if (!Array.isArray(order)) return res.status(400).json({ error: 'order fehlt' });
|
||||
const me = uid(req);
|
||||
const upd = db.prepare('UPDATE kanban_columns SET position=? WHERE id=? AND user_id=?');
|
||||
db.transaction(() => { for (let i = 0; i < order.length; i++) upd.run(i, order[i], me); })();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.patch('/columns/:id', authenticate, (req, res) => {
|
||||
const me = uid(req);
|
||||
const col = db.prepare('SELECT * FROM kanban_columns WHERE id=? AND user_id=?').get(req.params.id, me);
|
||||
if (!col) return res.status(404).json({ error: 'Spalte nicht gefunden' });
|
||||
const title = req.body.title !== undefined ? req.body.title.trim() : col.title;
|
||||
const color = req.body.color !== undefined ? req.body.color : col.color;
|
||||
if (!title) return res.status(400).json({ error: 'Titel fehlt' });
|
||||
db.prepare('UPDATE kanban_columns SET title=?, color=? WHERE id=?').run(title, color, col.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.delete('/columns/:id', authenticate, (req, res) => {
|
||||
const me = uid(req);
|
||||
const col = db.prepare('SELECT * FROM kanban_columns WHERE id=? AND user_id=?').get(req.params.id, me);
|
||||
if (!col) return res.status(404).json({ error: 'Spalte nicht gefunden' });
|
||||
db.prepare('DELETE FROM kanban_columns WHERE id=?').run(col.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Karten ────────────────────────────────────────────────────────────────────
|
||||
router.post('/cards', authenticate, (req, res) => {
|
||||
const { column_id, title, description = '', priority = 'none' } = req.body;
|
||||
if (!column_id || !title?.trim()) return res.status(400).json({ error: 'column_id und title erforderlich' });
|
||||
const me = uid(req);
|
||||
const col = db.prepare('SELECT * FROM kanban_columns WHERE id=? AND user_id=?').get(column_id, me);
|
||||
if (!col) return res.status(404).json({ error: 'Spalte nicht gefunden' });
|
||||
const maxPos = db.prepare('SELECT MAX(position) as m FROM kanban_cards WHERE column_id=?').get(column_id);
|
||||
const pos = (maxPos?.m ?? -1) + 1;
|
||||
const r = db.prepare(
|
||||
"INSERT INTO kanban_cards (column_id,user_id,title,description,priority,position) VALUES (?,?,?,?,?,?)"
|
||||
).run(column_id, me, title.trim(), description.trim(), priority, pos);
|
||||
res.json(db.prepare('SELECT * FROM kanban_cards WHERE id=?').get(r.lastInsertRowid));
|
||||
});
|
||||
|
||||
router.patch('/cards/:id', authenticate, (req, res) => {
|
||||
const me = uid(req);
|
||||
const card = db.prepare('SELECT * FROM kanban_cards WHERE id=? AND user_id=?').get(req.params.id, me);
|
||||
if (!card) return res.status(404).json({ error: 'Karte nicht gefunden' });
|
||||
const title = req.body.title !== undefined ? req.body.title.trim() : card.title;
|
||||
const description = req.body.description !== undefined ? req.body.description.trim() : card.description;
|
||||
const priority = req.body.priority !== undefined ? req.body.priority : card.priority;
|
||||
if (!title) return res.status(400).json({ error: 'Titel darf nicht leer sein' });
|
||||
db.prepare(
|
||||
"UPDATE kanban_cards SET title=?,description=?,priority=?,updated_at=datetime('now','localtime') WHERE id=?"
|
||||
).run(title, description, priority, card.id);
|
||||
res.json(db.prepare('SELECT * FROM kanban_cards WHERE id=?').get(card.id));
|
||||
});
|
||||
|
||||
router.delete('/cards/:id', authenticate, (req, res) => {
|
||||
const me = uid(req);
|
||||
const card = db.prepare('SELECT * FROM kanban_cards WHERE id=? AND user_id=?').get(req.params.id, me);
|
||||
if (!card) return res.status(404).json({ error: 'Karte nicht gefunden' });
|
||||
db.prepare('DELETE FROM kanban_cards WHERE id=?').run(card.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post('/cards/:id/move', authenticate, (req, res) => {
|
||||
const { column_id, position } = req.body;
|
||||
const me = uid(req);
|
||||
const card = db.prepare('SELECT * FROM kanban_cards WHERE id=? AND user_id=?').get(req.params.id, me);
|
||||
if (!card) return res.status(404).json({ error: 'Karte nicht gefunden' });
|
||||
const targetColId = column_id !== undefined ? column_id : card.column_id;
|
||||
const col = db.prepare('SELECT * FROM kanban_columns WHERE id=? AND user_id=?').get(targetColId, me);
|
||||
if (!col) return res.status(404).json({ error: 'Zielspalte nicht gefunden' });
|
||||
const siblings = db.prepare(
|
||||
'SELECT id FROM kanban_cards WHERE column_id=? AND id!=? ORDER BY position ASC, id ASC'
|
||||
).all(targetColId, card.id).map(r => r.id);
|
||||
const targetPos = position !== undefined
|
||||
? Math.max(0, Math.min(position, siblings.length))
|
||||
: siblings.length;
|
||||
siblings.splice(targetPos, 0, card.id);
|
||||
const upd = db.prepare('UPDATE kanban_cards SET column_id=?, position=? WHERE id=?');
|
||||
db.transaction(() => { for (let i = 0; i < siblings.length; i++) upd.run(targetColId, i, siblings[i]); })();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
383
backend/src/tools/koepi/routes.js
Normal file
@@ -0,0 +1,383 @@
|
||||
const express = require('express');
|
||||
const https = require('https');
|
||||
const db = require('../../db');
|
||||
const mqttClient = require('../../mqtt');
|
||||
const { authenticate } = require('../../middleware/auth');
|
||||
const router = express.Router();
|
||||
|
||||
// ── Cache ─────────────────────────────────────────────────────────────────────
|
||||
const cache = {};
|
||||
function cacheGet(k) { const e=cache[k]; if(!e||Date.now()>e.ex) { delete cache[k]; return null; } return e.d; }
|
||||
function cacheSet(k,d,ms=60*60*1000) { cache[k]={d,ex:Date.now()+ms}; }
|
||||
|
||||
// ── marktguru Cookie (Standort Duisburg 47259) ────────────────────────────────
|
||||
const MG_SETTINGS = encodeURIComponent(JSON.stringify({
|
||||
location: { name:'Duisburg', uniqueName:'duisburg', longitude:6.7625, latitude:51.4332, zipCodes:['47259'] },
|
||||
locationSource: 'manual', hidePageflipMenu: false, cookieAccept: true, userGroup: 35,
|
||||
}));
|
||||
|
||||
// ── Gewünschte Händler ────────────────────────────────────────────────────────
|
||||
const TARGET_RETAILERS = ['edeka','e center','rewe','netto','kaufland','penny','trinkgut'];
|
||||
const RETAILER_ADDRESSES = {
|
||||
'edeka': 'Düsseldorfer Landstr. 361',
|
||||
'e center': 'Düsseldorfer Landstr. 361',
|
||||
'rewe': 'Mündelheimer Str. 132 / Keniastr. 39',
|
||||
'netto': 'Im Bonnefeld 19-21',
|
||||
'kaufland': 'Auf der Höhe 20',
|
||||
'penny': 'Sittardsberger Allee 10',
|
||||
'trinkgut': 'Keniastraße 39',
|
||||
};
|
||||
function isTargetRetailer(name) {
|
||||
if (!name) return false;
|
||||
const n = name.toLowerCase();
|
||||
return TARGET_RETAILERS.some(r => n.includes(r));
|
||||
}
|
||||
function getAddress(retailer) {
|
||||
if (!retailer) return '';
|
||||
const n = retailer.toLowerCase();
|
||||
for (const [key, addr] of Object.entries(RETAILER_ADDRESSES)) {
|
||||
if (n.includes(key)) return addr;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// ── Angebote via Puppeteer von marktguru ─────────────────────────────────────
|
||||
async function scrapeMarktguru(forceRefresh = false) {
|
||||
const cacheKey = 'koepi:mg';
|
||||
if (!forceRefresh) {
|
||||
const cached = cacheGet(cacheKey);
|
||||
if (cached) return cached;
|
||||
}
|
||||
|
||||
let puppeteer;
|
||||
try { puppeteer = require('puppeteer-core'); }
|
||||
catch(e) { return { offers:[], error:'puppeteer-core nicht installiert', scrapedAt: new Date().toISOString() }; }
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath: '/usr/bin/chromium-browser',
|
||||
args: ['--no-sandbox','--disable-setuid-sandbox','--disable-dev-shm-usage','--disable-gpu','--single-process'],
|
||||
headless: true,
|
||||
});
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
|
||||
await page.setViewport({ width:1280, height:800 });
|
||||
await page.setCookie({ name:'mg_user-settings', value:MG_SETTINGS, domain:'.marktguru.de', path:'/' });
|
||||
|
||||
await page.goto('https://www.marktguru.de/search/k%C3%B6nig%20pilsener?zipCode=47259', {
|
||||
waitUntil: 'networkidle2', timeout: 30000,
|
||||
});
|
||||
// Seite nach unten scrollen damit lazy-loaded Bilder geladen werden
|
||||
await page.evaluate(async () => {
|
||||
await new Promise(resolve => {
|
||||
let totalHeight = 0;
|
||||
const distance = 300;
|
||||
const timer = setInterval(() => {
|
||||
window.scrollBy(0, distance);
|
||||
totalHeight += distance;
|
||||
if (totalHeight >= document.body.scrollHeight) {
|
||||
clearInterval(timer);
|
||||
resolve();
|
||||
}
|
||||
}, 200);
|
||||
});
|
||||
});
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
|
||||
const offers = await page.evaluate(() => {
|
||||
// Exakte Selektoren basierend auf marktguru HTML-Struktur
|
||||
const cards = document.querySelectorAll('li.offer-list-item');
|
||||
return Array.from(cards).map(card => {
|
||||
const name = card.querySelector('h3')?.textContent?.trim() || '';
|
||||
const brand = card.querySelector('dd.brand a')?.textContent?.trim() || '';
|
||||
const price = card.querySelector('span.price')?.textContent?.trim() || '';
|
||||
const oldPrice = card.querySelector('.price-bubble .old-price, .crossed')?.textContent?.trim() || '';
|
||||
const retailer = card.querySelector('dd.retailer-name a')?.textContent?.trim() || '';
|
||||
const dateRange= card.querySelector('dd.valid')?.textContent?.trim() || '';
|
||||
const daysLeft = card.querySelector('dd.time-left span')?.textContent?.trim() || '';
|
||||
const daysText = card.querySelector('dd.time-left')?.textContent?.trim() || '';
|
||||
const validity = daysLeft ? `Noch ${daysLeft} Tag${daysLeft==='1'?'':'e'}` :
|
||||
card.querySelector('dt.time-left')?.textContent?.includes('Brandneu') ? 'Neu' : '';
|
||||
const descEl = card.querySelector('.info div, .info');
|
||||
const description = descEl?.textContent?.trim() || '';
|
||||
const img = card.querySelector('img.offer-list-item-img');
|
||||
const badge = card.querySelector('.badge, .discount-badge')?.textContent?.trim() || '';
|
||||
const imgSrc = img?.src?.replace('/medium.webp', '/large.webp') || img?.src || null;
|
||||
return { name, brand, price, oldPrice, retailer, dateRange, validity, description, badge, address: '', image: imgSrc };
|
||||
});
|
||||
});
|
||||
|
||||
// Lokale Bilder auswählen basierend auf Beschreibung
|
||||
for (const offer of offers) {
|
||||
const desc = (offer.description || '').toLowerCase();
|
||||
console.log('DESC:', JSON.stringify(desc), '| RETAILER:', offer.retailer);
|
||||
if (desc.includes('steini')) {
|
||||
offer.imageLocal = '/koepi/kasten_steini.png';
|
||||
} else if (desc.includes('11 x 0,5') || desc.includes('11x0,5')) {
|
||||
offer.imageLocal = '/koepi/kasten_lang.png';
|
||||
} else if (desc.includes('6 x') || desc.includes('6x') || (desc.includes('flasche') && !desc.includes('kasten'))) {
|
||||
offer.imageLocal = '/koepi/traeger.png';
|
||||
} else if (desc.includes('24 x 0,33') || desc.includes('24x0,33') || desc.includes('24 x 0,33')) {
|
||||
offer.imageLocal = '/koepi/kasten_033.png';
|
||||
} else if (desc.includes('0,5')) {
|
||||
offer.imageLocal = '/koepi/kasten_050.png';
|
||||
} else if (desc.includes('0,33')) {
|
||||
offer.imageLocal = '/koepi/kasten_033.png';
|
||||
} else {
|
||||
offer.imageLocal = '/koepi/kasten_050.png';
|
||||
}
|
||||
offer.image = null; // CDN nicht mehr nötig
|
||||
console.log('IMAGE:', offer.imageLocal, '| RETAILER:', offer.retailer);
|
||||
}
|
||||
|
||||
// Filtern auf Ziel-Händler + nur König Pilsener
|
||||
const filtered = offers.filter(o => {
|
||||
const isKP = (o.name + ' ' + o.brand + ' ' + o.description).toLowerCase();
|
||||
const hasKP = isKP.includes('pilsen') || isKP.includes('pilsener');
|
||||
return hasKP && isTargetRetailer(o.retailer);
|
||||
});
|
||||
|
||||
// Händler normalisieren: E center = EDEKA
|
||||
for (const o of filtered) {
|
||||
if (o.retailer?.toLowerCase().includes('e center')) o.retailerDisplay = o.retailer;
|
||||
else o.retailerDisplay = o.retailer;
|
||||
}
|
||||
|
||||
// Deduplizieren: gleicher normalisierter Händler + gleicher Preis → ein Eintrag
|
||||
// Kürzeste Beschreibung nehmen (sauberste)
|
||||
const merged = new Map();
|
||||
for (const o of filtered) {
|
||||
const normalizedRetailer = o.retailer?.toLowerCase().replace('e center','edeka') || '';
|
||||
const key = `${normalizedRetailer}|${o.price}`;
|
||||
if (merged.has(key)) {
|
||||
const existing = merged.get(key);
|
||||
// Kürzere Beschreibung bevorzugen
|
||||
if (o.description && (!existing.description || o.description.length < existing.description.length)) {
|
||||
existing.description = o.description;
|
||||
}
|
||||
} else {
|
||||
merged.set(key, { ...o });
|
||||
}
|
||||
}
|
||||
const unique = [...merged.values()].map(o => ({ ...o, address: getAddress(o.retailer) }));
|
||||
|
||||
const result = { offers: unique, total: unique.length, scrapedAt: new Date().toISOString(), source: 'marktguru.de' };
|
||||
cacheSet(cacheKey, result);
|
||||
return result;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Prospekte von kaufda ──────────────────────────────────────────────────────
|
||||
function fetchUrl(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.get(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept': 'text/html', 'Accept-Language': 'de-DE,de;q=0.9',
|
||||
}
|
||||
}, res => {
|
||||
let d = '';
|
||||
res.on('data', c => d += c);
|
||||
res.on('end', () => resolve({ status: res.statusCode, body: d }));
|
||||
});
|
||||
req.setTimeout(15000, () => { req.destroy(); reject(new Error('Timeout')); });
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function extractNextData(html) {
|
||||
const marker = 'application/json">';
|
||||
const ni = html.indexOf('NEXT_DATA');
|
||||
if (ni === -1) return null;
|
||||
const st = html.indexOf(marker, ni) + marker.length;
|
||||
const en = html.indexOf('</script>', st);
|
||||
try { return JSON.parse(html.slice(st, en)); } catch { return null; }
|
||||
}
|
||||
|
||||
async function scrapeProspekte() {
|
||||
const cacheKey = 'koepi:prospekte';
|
||||
const cached = cacheGet(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const { body } = await fetchUrl(
|
||||
'https://www.kaufda.de/shelf?query=K%C3%B6nig+Pilsener&lat=51.3750&lng=6.7680&zip=47259&city=Duisburg'
|
||||
);
|
||||
const nd = extractNextData(body);
|
||||
const contents = nd?.props?.pageProps?.pageInformation?.shelfContents?.contents || [];
|
||||
|
||||
const seen = new Map();
|
||||
for (const item of contents) {
|
||||
const c = item.content;
|
||||
if (!c || item.contentType !== 'brochure') continue;
|
||||
const id = c.contentId;
|
||||
if (!id || seen.has(id)) continue;
|
||||
const pub = c.publisher?.name || '';
|
||||
const store = c.closestStore;
|
||||
seen.set(id, {
|
||||
id, publisher: pub,
|
||||
title: c.title || '',
|
||||
store: store?.name || pub,
|
||||
city: store?.city || 'Duisburg',
|
||||
street: store ? `${store.street||''} ${store.streetNumber||''}`.trim() : '',
|
||||
zip: store?.zip || '47259',
|
||||
image: c.brochureImages?.find(i => i.size === '260x270')?.url || c.brochureImage?.url || null,
|
||||
validFrom: c.validFrom || null,
|
||||
validTo: c.validUntil || null,
|
||||
pageCount: c.pageCount || null,
|
||||
url: `https://www.kaufda.de/Geschaefte/${encodeURIComponent(pub.replace(/\s+/g,'-'))}`,
|
||||
isTarget: isTargetRetailer(pub),
|
||||
badges: c.contentBadges?.map(b => b.name) || [],
|
||||
});
|
||||
}
|
||||
|
||||
const all = [...seen.values()];
|
||||
const result = {
|
||||
targeted: all.filter(b => b.isTarget),
|
||||
others: all.filter(b => !b.isTarget),
|
||||
scrapedAt: new Date().toISOString(),
|
||||
};
|
||||
cacheSet(cacheKey, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Bild-Proxy (für kaufda Bilder) ───────────────────────────────────────────
|
||||
router.get('/img', authenticate, (req, res) => {
|
||||
const url = req.query.url;
|
||||
if (!url || !url.startsWith('https://')) return res.status(400).end();
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const allowed = ['content-media.bonial.biz','publisher-media.bonial.biz'];
|
||||
if (!allowed.some(h => parsed.hostname === h)) return res.status(403).end();
|
||||
const r = https.get(url, { headers: {'User-Agent':'Mozilla/5.0'} }, imgRes => {
|
||||
res.setHeader('Content-Type', imgRes.headers['content-type'] || 'image/jpeg');
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||
imgRes.pipe(res);
|
||||
});
|
||||
r.on('error', () => res.status(502).end());
|
||||
} catch { res.status(400).end(); }
|
||||
});
|
||||
|
||||
// ── Tages-Cron: neue/geänderte Angebote per Pushover melden ─────────────────
|
||||
const KOEPI_SETTINGS_KEY = 'koepi_last_offers';
|
||||
|
||||
// Vergleichbare, stabile Signatur eines Angebots (Reihenfolge-unabhängig).
|
||||
// Bewusst NUR dateRange (echtes Datum), NICHT validity ("Noch X Tage") — der
|
||||
// Countdown-Text ändert sich täglich von allein und würde sonst jeden Tag
|
||||
// eine Pushover-Benachrichtigung auslösen, auch wenn sich am Angebot nichts
|
||||
// geändert hat.
|
||||
function offerSignature(offers) {
|
||||
return offers
|
||||
.map(o => `${(o.retailer || '').toLowerCase().trim()}|${(o.price || '').trim()}|${(o.dateRange || '').trim()}`)
|
||||
.sort()
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function formatOfferMessage(offers) {
|
||||
if (!offers.length) return 'Aktuell keine König Pilsener Angebote gefunden.';
|
||||
return offers
|
||||
.map(o => `${o.retailer || '?'}: ${o.price || '?'} (${o.dateRange || 'Gültigkeit unbekannt'})`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
async function sendKoepiPushover(offers) {
|
||||
const recipients = db.prepare(`
|
||||
SELECT user_key, app_token FROM pushover_settings
|
||||
WHERE user_key IS NOT NULL AND app_token IS NOT NULL
|
||||
`).all();
|
||||
const message = formatOfferMessage(offers);
|
||||
for (const r of recipients) {
|
||||
try {
|
||||
await fetch('https://api.pushover.net/1/messages.json', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
token: r.app_token,
|
||||
user: r.user_key,
|
||||
title: '🍺 KöPi Angebote',
|
||||
message,
|
||||
priority: 0,
|
||||
}),
|
||||
});
|
||||
} catch (e) { console.error('KöPi Pushover Fehler:', e.message); }
|
||||
}
|
||||
}
|
||||
|
||||
// Reiner Anzeige-Refresh für den HA-Button "KöPi Check jetzt" — scraped frisch
|
||||
// und aktualisiert nur die MQTT-Entitäten. Sendet NIE Pushover und rührt den
|
||||
// in admin_settings gespeicherten Vergleichsstand nicht an (der bleibt exklusiv
|
||||
// dem 06:00-Cron vorbehalten, damit dessen Diff-Erkennung korrekt bleibt).
|
||||
async function refreshOffersOnly() {
|
||||
try {
|
||||
const { offers } = await scrapeMarktguru(true);
|
||||
const currentSig = offerSignature(offers);
|
||||
const lastSig = db.prepare('SELECT value FROM admin_settings WHERE key=?').get(KOEPI_SETTINGS_KEY)?.value || '';
|
||||
const changed = currentSig !== lastSig; // nur zur Anzeige im "geändert"-Sensor, löst nichts aus
|
||||
mqttClient.publishKoepiState({ offers, changed });
|
||||
console.log(`🍺 KöPi-Refresh (HA-Button): ${offers.length} Angebot(e) angezeigt, keine Pushover.`);
|
||||
return { changed, sent: false, offerCount: offers.length };
|
||||
} catch (e) {
|
||||
console.error('KöPi Refresh Fehler:', e.message);
|
||||
return { error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
// Wird täglich um 06:00 vom Scheduler in index.js aufgerufen.
|
||||
// forceNotify=true (manueller Admin-Test): Pushover wird auch ohne Änderung verschickt.
|
||||
async function runDailyCheck(forceNotify = false) {
|
||||
try {
|
||||
const { offers } = await scrapeMarktguru(true);
|
||||
const currentSig = offerSignature(offers);
|
||||
const lastSig = db.prepare('SELECT value FROM admin_settings WHERE key=?').get(KOEPI_SETTINGS_KEY)?.value || '';
|
||||
const changed = currentSig !== lastSig;
|
||||
|
||||
if (!changed && !forceNotify) {
|
||||
console.log('🍺 KöPi-Check: keine Änderungen an den Angeboten.');
|
||||
mqttClient.publishKoepiState({ offers, changed: false });
|
||||
return { changed: false, sent: false, offerCount: offers.length };
|
||||
}
|
||||
|
||||
await sendKoepiPushover(offers);
|
||||
db.prepare('INSERT OR REPLACE INTO admin_settings (key, value) VALUES (?, ?)')
|
||||
.run(KOEPI_SETTINGS_KEY, currentSig);
|
||||
console.log(`🍺 KöPi-Check: ${offers.length} Angebot(e)${changed ? ' geändert' : ' (Test, unverändert)'} – Pushover versendet.`);
|
||||
mqttClient.publishKoepiState({ offers, changed });
|
||||
return { changed, sent: true, offerCount: offers.length };
|
||||
} catch (e) {
|
||||
console.error('KöPi Tages-Check Fehler:', e.message);
|
||||
return { error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Routes ────────────────────────────────────────────────────────────────────
|
||||
router.get('/offers', authenticate, async (req, res) => {
|
||||
try { res.json(await scrapeMarktguru()); }
|
||||
catch(e) { console.error('KöPi offers:', e.message); res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.get('/prospekte', authenticate, async (req, res) => {
|
||||
try { res.json(await scrapeProspekte()); }
|
||||
catch(e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.post('/clear-cache', authenticate, (req, res) => {
|
||||
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
['koepi:mg','koepi:prospekte'].forEach(k => delete cache[k]);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Manueller Test des Tages-Checks (Admin) — sendet Pushover auch ohne echte Änderung
|
||||
router.post('/run-daily-check', authenticate, async (req, res) => {
|
||||
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
try {
|
||||
const result = await runDailyCheck(true);
|
||||
if (result.error) return res.status(500).json({ error: result.error });
|
||||
res.json(result);
|
||||
} catch(e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports.runDailyCheck = runDailyCheck;
|
||||
module.exports.refreshOffersOnly = refreshOffersOnly;
|
||||
212
backend/src/tools/linkliste/routes.js
Normal file
@@ -0,0 +1,212 @@
|
||||
const express = require('express');
|
||||
const db = require('../../db');
|
||||
const { authenticate } = require('../../middleware/auth');
|
||||
const router = express.Router();
|
||||
|
||||
// ── Ordner ────────────────────────────────────────────────────────────────────
|
||||
router.get('/folders', authenticate, (req, res) => {
|
||||
res.json(db.prepare('SELECT * FROM link_list_folders WHERE user_id=? ORDER BY sort_order,id').all(req.user.id));
|
||||
});
|
||||
|
||||
router.post('/folders', authenticate, (req, res) => {
|
||||
const uid = req.user.id;
|
||||
const { name, icon='📁' } = req.body;
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'Name erforderlich' });
|
||||
const max = db.prepare('SELECT MAX(sort_order) m FROM link_list_folders WHERE user_id=?').get(uid);
|
||||
const r = db.prepare(`INSERT INTO link_list_folders (user_id,name,icon,sort_order,created_at) VALUES (?,?,?,?,datetime('now','localtime'))`)
|
||||
.run(uid, name.trim(), icon, (max?.m ?? -1) + 1);
|
||||
res.json(db.prepare('SELECT * FROM link_list_folders WHERE id=?').get(r.lastInsertRowid));
|
||||
});
|
||||
|
||||
router.put('/folders/:id', authenticate, (req, res) => {
|
||||
const f = db.prepare('SELECT * FROM link_list_folders WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!f) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const { name=f.name, icon=f.icon, in_quickaccess } = req.body;
|
||||
const qa = in_quickaccess !== undefined ? (in_quickaccess ? 1 : 0) : f.in_quickaccess;
|
||||
db.prepare('UPDATE link_list_folders SET name=?, icon=?, in_quickaccess=? WHERE id=?').run(name, icon, qa, f.id);
|
||||
res.json(db.prepare('SELECT * FROM link_list_folders WHERE id=?').get(f.id));
|
||||
});
|
||||
|
||||
router.delete('/folders/:id', authenticate, (req, res) => {
|
||||
const f = db.prepare('SELECT * FROM link_list_folders WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!f) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
// Links aus Ordner herauslösen
|
||||
db.prepare('UPDATE link_list SET folder_id=NULL WHERE folder_id=? AND user_id=?').run(f.id, req.user.id);
|
||||
db.prepare('DELETE FROM link_list_folders WHERE id=?').run(f.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Ordner-Inhalt abrufen (für Folder-Modal im Dashboard)
|
||||
router.get('/folders/:id/links', authenticate, (req, res) => {
|
||||
const f = db.prepare('SELECT * FROM link_list_folders WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!f) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const links = db.prepare('SELECT * FROM link_list WHERE folder_id=? AND user_id=? ORDER BY sort_order,id').all(f.id, req.user.id);
|
||||
res.json({ folder: f, links });
|
||||
});
|
||||
|
||||
// ── Sortierung ────────────────────────────────────────────────────────────────
|
||||
router.put('/sort', authenticate, (req, res) => {
|
||||
const uid = req.user.id;
|
||||
const { links=[], folders=[] } = req.body;
|
||||
const upLink = db.prepare('UPDATE link_list SET sort_order=? WHERE id=? AND user_id=?');
|
||||
const upFolder = db.prepare('UPDATE link_list_folders SET sort_order=? WHERE id=? AND user_id=?');
|
||||
db.transaction(() => {
|
||||
links.forEach((id, i) => upLink.run(i, id, uid));
|
||||
folders.forEach((id, i) => upFolder.run(i, id, uid));
|
||||
})();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Eigene Links ──────────────────────────────────────────────────────────────
|
||||
router.get('/', authenticate, (req, res) => {
|
||||
const uid = req.user.id;
|
||||
const own = db.prepare(`
|
||||
SELECT l.*, 0 as is_shared, NULL as owner_name,
|
||||
(SELECT COUNT(*) FROM link_list_shares WHERE link_id=l.id) as share_count
|
||||
FROM link_list l WHERE l.user_id=? ORDER BY l.folder_id IS NULL DESC, l.folder_id, l.sort_order, l.id
|
||||
`).all(uid);
|
||||
|
||||
const folders = db.prepare('SELECT * FROM link_list_folders WHERE user_id=? ORDER BY sort_order,id').all(uid);
|
||||
|
||||
const shared = db.prepare(`
|
||||
SELECT l.*, 1 as is_shared, u.username as owner_name, 0 as share_count
|
||||
FROM link_list l
|
||||
JOIN link_list_shares s ON s.link_id=l.id
|
||||
JOIN users u ON u.id=l.user_id
|
||||
WHERE s.shared_with=?
|
||||
ORDER BY l.created_at DESC
|
||||
`).all(uid);
|
||||
|
||||
const sharedByMe = db.prepare(`
|
||||
SELECT l.*, 0 as is_shared, u.username as shared_with_name
|
||||
FROM link_list l
|
||||
JOIN link_list_shares s ON s.link_id=l.id
|
||||
JOIN users u ON u.id=s.shared_with
|
||||
WHERE l.user_id=?
|
||||
ORDER BY l.title ASC
|
||||
`).all(uid);
|
||||
|
||||
// Ordner die mit mir geteilt wurden (inkl. deren Links)
|
||||
const sharedFolders = db.prepare(`
|
||||
SELECT f.*, u.username as owner_name
|
||||
FROM link_list_folders f
|
||||
JOIN link_folder_shares s ON s.folder_id=f.id
|
||||
JOIN users u ON u.id=f.user_id
|
||||
WHERE s.shared_with=?
|
||||
ORDER BY f.name ASC
|
||||
`).all(uid);
|
||||
|
||||
const sharedFolderLinks = sharedFolders.map(folder => ({
|
||||
...folder,
|
||||
links: db.prepare('SELECT * FROM link_list WHERE folder_id=? ORDER BY sort_order,id').all(folder.id)
|
||||
}));
|
||||
|
||||
// Ordner die ich geteilt habe
|
||||
const sharedFoldersByMe = db.prepare(`
|
||||
SELECT f.*, u.username as shared_with_name
|
||||
FROM link_list_folders f
|
||||
JOIN link_folder_shares s ON s.folder_id=f.id
|
||||
JOIN users u ON u.id=s.shared_with
|
||||
WHERE f.user_id=?
|
||||
ORDER BY f.name ASC
|
||||
`).all(uid);
|
||||
|
||||
res.json({ own, folders, shared, sharedByMe, sharedFolders: sharedFolderLinks, sharedFoldersByMe });
|
||||
});
|
||||
|
||||
router.post('/', authenticate, (req, res) => {
|
||||
const uid = req.user.id;
|
||||
const { title, url, icon = '🔗', description = '', folder_id = null } = req.body;
|
||||
if (!title?.trim() || !url?.trim()) return res.status(400).json({ error: 'Titel und URL erforderlich' });
|
||||
const max = db.prepare('SELECT MAX(sort_order) m FROM link_list WHERE user_id=?').get(uid);
|
||||
const r = db.prepare(`
|
||||
INSERT INTO link_list (user_id, title, url, icon, description, folder_id, sort_order, created_at)
|
||||
VALUES (?,?,?,?,?,?,?,datetime('now','localtime'))
|
||||
`).run(uid, title.trim(), url.trim(), icon, description.trim(), folder_id || null, (max?.m ?? -1) + 1);
|
||||
res.json(db.prepare('SELECT * FROM link_list WHERE id=?').get(r.lastInsertRowid));
|
||||
});
|
||||
|
||||
router.put('/:id', authenticate, (req, res) => {
|
||||
const link = db.prepare('SELECT * FROM link_list WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!link) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const { title, url, icon, description, folder_id, in_quickaccess } = req.body;
|
||||
db.prepare('UPDATE link_list SET title=?, url=?, icon=?, description=?, folder_id=?, in_quickaccess=? WHERE id=?')
|
||||
.run(
|
||||
title ?? link.title,
|
||||
url ?? link.url,
|
||||
icon ?? link.icon,
|
||||
description ?? link.description,
|
||||
folder_id !== undefined ? (folder_id || null) : link.folder_id,
|
||||
in_quickaccess !== undefined ? (in_quickaccess ? 1 : 0) : link.in_quickaccess,
|
||||
link.id
|
||||
);
|
||||
res.json(db.prepare('SELECT * FROM link_list WHERE id=?').get(link.id));
|
||||
});
|
||||
|
||||
router.delete('/:id', authenticate, (req, res) => {
|
||||
const r = db.prepare('DELETE FROM link_list WHERE id=? AND user_id=?').run(req.params.id, req.user.id);
|
||||
if (!r.changes) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Shares ────────────────────────────────────────────────────────────────────
|
||||
router.get('/:id/shares', authenticate, (req, res) => {
|
||||
const link = db.prepare('SELECT * FROM link_list WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!link) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
res.json(db.prepare(`
|
||||
SELECT u.id, u.username, s.created_at as shared_at
|
||||
FROM link_list_shares s JOIN users u ON u.id=s.shared_with
|
||||
WHERE s.link_id=?
|
||||
`).all(link.id));
|
||||
});
|
||||
|
||||
router.post('/:id/share', authenticate, (req, res) => {
|
||||
const link = db.prepare('SELECT * FROM link_list WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!link) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const target = db.prepare('SELECT * FROM users WHERE username=?').get(req.body.username);
|
||||
if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
|
||||
if (target.id === req.user.id) return res.status(400).json({ error: 'Kann nicht mit dir selbst teilen' });
|
||||
db.prepare(`INSERT OR IGNORE INTO link_list_shares (link_id, shared_by, shared_with, created_at) VALUES (?,?,?,datetime('now','localtime'))`)
|
||||
.run(link.id, req.user.id, target.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.delete('/:id/share/:userId', authenticate, (req, res) => {
|
||||
const link = db.prepare('SELECT * FROM link_list WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!link) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
db.prepare('DELETE FROM link_list_shares WHERE link_id=? AND shared_with=?').run(link.id, req.params.userId);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Ordner-Shares ─────────────────────────────────────────────────────────────
|
||||
router.get('/folders/:id/shares', authenticate, (req, res) => {
|
||||
const f = db.prepare('SELECT * FROM link_list_folders WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!f) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const shares = db.prepare(`
|
||||
SELECT u.id, u.username, s.created_at as shared_at
|
||||
FROM link_folder_shares s JOIN users u ON u.id=s.shared_with
|
||||
WHERE s.folder_id=?
|
||||
`).all(f.id);
|
||||
res.json(shares);
|
||||
});
|
||||
|
||||
router.post('/folders/:id/share', authenticate, (req, res) => {
|
||||
const f = db.prepare('SELECT * FROM link_list_folders WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!f) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const target = db.prepare('SELECT * FROM users WHERE username=?').get(req.body.username);
|
||||
if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
|
||||
if (target.id === req.user.id) return res.status(400).json({ error: 'Kann nicht mit dir selbst teilen' });
|
||||
db.prepare(`INSERT OR IGNORE INTO link_folder_shares (folder_id, shared_by, shared_with, created_at) VALUES (?,?,?,datetime('now','localtime'))`)
|
||||
.run(f.id, req.user.id, target.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.delete('/folders/:id/share/:userId', authenticate, (req, res) => {
|
||||
const f = db.prepare('SELECT * FROM link_list_folders WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!f) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
db.prepare('DELETE FROM link_folder_shares WHERE folder_id=? AND shared_with=?').run(f.id, req.params.userId);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
|
||||
module.exports = router;
|
||||
1250
backend/src/tools/media/routes.js
Normal file
210
backend/src/tools/nachrichten/routes.js
Normal file
@@ -0,0 +1,210 @@
|
||||
const express = require('express');
|
||||
const db = require('../../db');
|
||||
const { authenticate } = require('../../middleware/auth');
|
||||
const router = express.Router();
|
||||
|
||||
// ── Pushover Helper ───────────────────────────────────────────────────────────
|
||||
async function sendPushover(userKey, appToken, title, message, opts = {}) {
|
||||
try {
|
||||
const params = { token: appToken, user: userKey, title, message };
|
||||
if (opts.retry && opts.expire) {
|
||||
params.priority = 2;
|
||||
params.retry = opts.retry;
|
||||
params.expire = opts.expire;
|
||||
}
|
||||
await fetch('https://api.pushover.net/1/messages.json', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams(params),
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ── Public Keys ───────────────────────────────────────────────────────────────
|
||||
router.get('/keys/:userId', authenticate, (req, res) => {
|
||||
const row = db.prepare('SELECT public_key FROM user_keys WHERE user_id = ?').get(req.params.userId);
|
||||
res.json({ public_key: row?.public_key || null });
|
||||
});
|
||||
|
||||
router.post('/keys', authenticate, (req, res) => {
|
||||
const { public_key } = req.body;
|
||||
if (!public_key) return res.status(400).json({ error: 'Kein Schlüssel' });
|
||||
db.prepare(`
|
||||
INSERT INTO user_keys (user_id, public_key, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(user_id) DO UPDATE SET public_key=excluded.public_key, updated_at=CURRENT_TIMESTAMP
|
||||
`).run(req.user.id, public_key);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Users ─────────────────────────────────────────────────────────────────────
|
||||
// Presence: User ist gerade aktiv im Chat
|
||||
router.post('/presence', authenticate, (req, res) => {
|
||||
const { active } = req.body;
|
||||
if (active) {
|
||||
db.prepare("UPDATE users SET chat_active_at=datetime('now','localtime') WHERE id=?").run(req.user.id);
|
||||
} else {
|
||||
db.prepare("UPDATE users SET chat_active_at=NULL WHERE id=?").run(req.user.id);
|
||||
}
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.get('/users', authenticate, (req, res) => {
|
||||
const me = req.user.id;
|
||||
const isAdmin = req.user.role === 'admin';
|
||||
|
||||
// Admins sehen alle. Normale User sehen:
|
||||
// - keine hidden User
|
||||
// - wenn sie selbst hidden sind: nur Admins
|
||||
const meUser = db.prepare('SELECT hidden FROM users WHERE id=?').get(me);
|
||||
const isMeHidden = !!meUser?.hidden;
|
||||
|
||||
let whereExtra = '';
|
||||
if (!isAdmin && isMeHidden) {
|
||||
// Ich bin hidden → sehe nur Admins
|
||||
whereExtra = "AND u.role = 'admin'";
|
||||
} else if (!isAdmin) {
|
||||
// Normaler User → sieht keine hidden User
|
||||
whereExtra = "AND (u.hidden = 0 OR u.hidden IS NULL)";
|
||||
}
|
||||
// Admin → kein Filter
|
||||
|
||||
const users = db.prepare(`
|
||||
SELECT u.id, u.username, uk.public_key,
|
||||
(SELECT COUNT(*) FROM messages
|
||||
WHERE sender_id = u.id AND recipient_id = ? AND read_by_recipient = 0) AS unread,
|
||||
(SELECT MAX(created_at) FROM messages
|
||||
WHERE (sender_id = u.id AND recipient_id = ?)
|
||||
OR (sender_id = ? AND recipient_id = u.id)) AS last_message_at
|
||||
FROM users u
|
||||
LEFT JOIN user_keys uk ON uk.user_id = u.id
|
||||
WHERE u.id != ? ${whereExtra}
|
||||
ORDER BY last_message_at DESC, u.username ASC
|
||||
`).all(me, me, me, me);
|
||||
res.json(users);
|
||||
});
|
||||
|
||||
// ── Unread Count ──────────────────────────────────────────────────────────────
|
||||
router.get('/unread', authenticate, (req, res) => {
|
||||
const row = db.prepare(
|
||||
'SELECT COUNT(*) AS count FROM messages WHERE recipient_id = ? AND read_by_recipient = 0'
|
||||
).get(req.user.id);
|
||||
res.json({ count: row.count });
|
||||
});
|
||||
|
||||
// ── Messages ──────────────────────────────────────────────────────────────────
|
||||
router.get('/messages/:userId', authenticate, (req, res) => {
|
||||
const me = req.user.id;
|
||||
const other = parseInt(req.params.userId);
|
||||
|
||||
db.prepare(`
|
||||
UPDATE messages SET read_by_recipient = 1
|
||||
WHERE recipient_id = ? AND sender_id = ? AND read_by_recipient = 0
|
||||
`).run(me, other);
|
||||
|
||||
const msgs = db.prepare(`
|
||||
SELECT id, sender_id, encrypted_content, iv, created_at,
|
||||
(sender_id = ?) AS is_mine,
|
||||
read_by_recipient
|
||||
FROM messages
|
||||
WHERE (sender_id = ? AND recipient_id = ?)
|
||||
OR (recipient_id = ? AND sender_id = ?)
|
||||
ORDER BY created_at ASC
|
||||
`).all(me, me, other, me, other);
|
||||
|
||||
res.json(msgs);
|
||||
});
|
||||
|
||||
router.post('/messages/:userId', authenticate, async (req, res) => {
|
||||
const me = req.user.id;
|
||||
const recipId = parseInt(req.params.userId);
|
||||
const { encrypted_content, iv } = req.body;
|
||||
if (!encrypted_content || !iv) return res.status(400).json({ error: 'Fehlende Felder' });
|
||||
|
||||
const recipient = db.prepare('SELECT id FROM users WHERE id = ?').get(recipId);
|
||||
if (!recipient) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
|
||||
|
||||
const result = db.prepare(
|
||||
"INSERT INTO messages (sender_id, recipient_id, encrypted_content, iv, created_at) VALUES (?, ?, ?, ?, datetime('now', 'localtime'))"
|
||||
).run(me, recipId, encrypted_content, iv);
|
||||
|
||||
const senderName = db.prepare('SELECT username FROM users WHERE id = ?').get(me)?.username || 'Jemand';
|
||||
const pushover = db.prepare('SELECT * FROM pushover_settings WHERE user_id = ?').get(recipId);
|
||||
if (pushover) {
|
||||
// Kein Push wenn Empfänger gerade im Chat ist (aktiv in letzten 45 Sekunden)
|
||||
const presence = db.prepare(`
|
||||
SELECT chat_active_at FROM users WHERE id=? AND chat_active_at > datetime('now','localtime','-45 seconds')
|
||||
`).get(recipId);
|
||||
if (!presence) {
|
||||
await sendPushover(pushover.user_key, pushover.app_token, 'DickenDock', `Neue Nachricht von ${senderName}`,
|
||||
{ retry: pushover.retry, expire: pushover.expire });
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ id: result.lastInsertRowid });
|
||||
});
|
||||
|
||||
// DELETE einzelne Nachricht – hard delete, beide Seiten dürfen löschen
|
||||
router.delete('/messages/:messageId', authenticate, (req, res) => {
|
||||
const me = req.user.id;
|
||||
const mid = parseInt(req.params.messageId);
|
||||
db.prepare(
|
||||
'DELETE FROM messages WHERE id = ? AND (sender_id = ? OR recipient_id = ?)'
|
||||
).run(mid, me, me);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// DELETE gesamte Konversation – hard delete
|
||||
router.delete('/conversation/:userId', authenticate, (req, res) => {
|
||||
const me = req.user.id;
|
||||
const other = parseInt(req.params.userId);
|
||||
db.prepare(`
|
||||
DELETE FROM messages
|
||||
WHERE (sender_id = ? AND recipient_id = ?) OR (sender_id = ? AND recipient_id = ?)
|
||||
`).run(me, other, other, me);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// DELETE alle eigenen Nachrichten (für Schlüssel-Reset)
|
||||
router.delete('/my-messages', authenticate, (req, res) => {
|
||||
db.prepare('DELETE FROM messages WHERE sender_id = ? OR recipient_id = ?')
|
||||
.run(req.user.id, req.user.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Pushover Settings ─────────────────────────────────────────────────────────
|
||||
router.get('/pushover', authenticate, (req, res) => {
|
||||
const row = db.prepare('SELECT user_key, app_token, retry, expire FROM pushover_settings WHERE user_id = ?').get(req.user.id);
|
||||
res.json(row || { user_key: '', app_token: '', retry: null, expire: null });
|
||||
});
|
||||
|
||||
router.post('/pushover', authenticate, (req, res) => {
|
||||
const { user_key, app_token, retry, expire } = req.body;
|
||||
if (!user_key || !app_token) return res.status(400).json({ error: 'Beide Felder erforderlich' });
|
||||
db.prepare(`
|
||||
INSERT INTO pushover_settings (user_id, user_key, app_token, retry, expire)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET user_key=excluded.user_key, app_token=excluded.app_token,
|
||||
retry=excluded.retry, expire=excluded.expire
|
||||
`).run(req.user.id, user_key, app_token, retry || null, expire || null);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.delete('/pushover', authenticate, (req, res) => {
|
||||
db.prepare('DELETE FROM pushover_settings WHERE user_id = ?').run(req.user.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post('/pushover/test', authenticate, async (req, res) => {
|
||||
const row = db.prepare('SELECT * FROM pushover_settings WHERE user_id = ?').get(req.user.id);
|
||||
if (!row) return res.status(400).json({ error: 'Keine Pushover-Einstellungen gespeichert' });
|
||||
const useEmergency = req.body?.emergency === true && row.retry && row.expire;
|
||||
const msg = useEmergency
|
||||
? `🚨 Emergency-Test! Wiederholt alle ${row.retry}s für max. ${row.expire}s. Bitte in Pushover quittieren.`
|
||||
: 'Pushover-Verbindung erfolgreich! 🎉';
|
||||
await sendPushover(row.user_key, row.app_token, 'DickenDock', msg,
|
||||
useEmergency ? { retry: row.retry, expire: row.expire } : {});
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
5
backend/src/tools/paywallkiller/routes.js
Normal file
@@ -0,0 +1,5 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
// Paywall-Killer: gesamter Flow läuft im Browser des Users.
|
||||
// Kein aktiver Backend-Endpoint nötig.
|
||||
module.exports = router;
|
||||
41
backend/src/tools/qrcodes/routes.js
Normal file
@@ -0,0 +1,41 @@
|
||||
const express = require('express');
|
||||
const db = require('../../db');
|
||||
const { authenticate } = require('../../middleware/auth');
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/', authenticate, (req, res) => {
|
||||
res.json(db.prepare('SELECT * FROM qr_codes WHERE user_id=? ORDER BY created_at DESC').all(req.user.id));
|
||||
});
|
||||
|
||||
router.post('/', authenticate, (req, res) => {
|
||||
const uid = req.user.id;
|
||||
const { label='', url, size=256, fg_color='#000000', bg_color='#ffffff', margin=4,
|
||||
dot_style='square', corner_style='square', caption='', caption_pos='bottom',
|
||||
caption_color='#000000', caption_size=14 } = req.body;
|
||||
if (!url?.trim()) return res.status(400).json({ error: 'URL erforderlich' });
|
||||
const r = db.prepare(`
|
||||
INSERT INTO qr_codes (user_id,label,url,size,fg_color,bg_color,margin,dot_style,corner_style,caption,caption_pos,caption_color,caption_size,created_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,datetime('now','localtime'))
|
||||
`).run(uid, label.trim(), url.trim(), size, fg_color, bg_color, margin, dot_style, corner_style, caption.trim(), caption_pos, caption_color, caption_size);
|
||||
res.json(db.prepare('SELECT * FROM qr_codes WHERE id=?').get(r.lastInsertRowid));
|
||||
});
|
||||
|
||||
router.put('/:id', authenticate, (req, res) => {
|
||||
const qr = db.prepare('SELECT * FROM qr_codes WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!qr) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const fields = ['label','url','size','fg_color','bg_color','margin','dot_style','corner_style','caption','caption_pos','caption_color','caption_size'];
|
||||
const updates = {};
|
||||
fields.forEach(f => { if (req.body[f] !== undefined) updates[f] = req.body[f]; });
|
||||
if (!Object.keys(updates).length) return res.json(qr);
|
||||
const sets = Object.keys(updates).map(f=>`${f}=?`).join(',');
|
||||
db.prepare(`UPDATE qr_codes SET ${sets} WHERE id=?`).run(...Object.values(updates), qr.id);
|
||||
res.json(db.prepare('SELECT * FROM qr_codes WHERE id=?').get(qr.id));
|
||||
});
|
||||
|
||||
router.delete('/:id', authenticate, (req, res) => {
|
||||
const r = db.prepare('DELETE FROM qr_codes WHERE id=? AND user_id=?').run(req.params.id, req.user.id);
|
||||
if (!r.changes) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
607
backend/src/tools/schocken/routes.js
Normal file
@@ -0,0 +1,607 @@
|
||||
const express = require('express');
|
||||
const db = require('../../db');
|
||||
const { authenticate } = require('../../middleware/auth');
|
||||
const router = express.Router();
|
||||
|
||||
// ── DB-Migration ──────────────────────────────────────────────────────────────
|
||||
(function migrate() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS schocken_games (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
players TEXT NOT NULL, -- JSON: [{id, username, order}]
|
||||
status TEXT NOT NULL DEFAULT 'lobby',
|
||||
-- status: lobby | half1 | half2 | endkampf | finished
|
||||
phase_status TEXT NOT NULL DEFAULT 'playing',
|
||||
-- phase_status: playing | finished
|
||||
stock INTEGER NOT NULL DEFAULT 15, -- Scheiben auf dem Stock
|
||||
player_chips TEXT NOT NULL DEFAULT '{}', -- JSON: {userId: chipCount}
|
||||
has_16th INTEGER, -- userId der die 16. Scheibe hat
|
||||
loser_h1 INTEGER, -- userId Verlierer Hälfte 1
|
||||
loser_h2 INTEGER, -- userId Verlierer Hälfte 2
|
||||
current_round TEXT, -- JSON: aktueller Rundenstand
|
||||
round_number INTEGER NOT NULL DEFAULT 1,
|
||||
beginner_id INTEGER, -- wer fängt die Runde an
|
||||
current_player_idx INTEGER NOT NULL DEFAULT 0,
|
||||
max_rolls INTEGER, -- vom Beginner bestimmt
|
||||
first_round INTEGER NOT NULL DEFAULT 1, -- 1 = erste Runde der Hälfte
|
||||
drink_losses TEXT NOT NULL DEFAULT '{}', -- {userId: count}
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
|
||||
);
|
||||
`);
|
||||
})();
|
||||
|
||||
// ── Würfellogik ───────────────────────────────────────────────────────────────
|
||||
function rollDice(count = 3) {
|
||||
return Array.from({ length: count }, () => Math.floor(Math.random() * 6) + 1);
|
||||
}
|
||||
|
||||
// Würfel klassifizieren
|
||||
function classifyDice(dice) {
|
||||
const sorted = [...dice].sort((a,b) => a-b);
|
||||
const [a,b,c] = sorted;
|
||||
|
||||
// Schock aus: 1-1-1
|
||||
if (a===1 && b===1 && c===1) return { type:'schock_aus', scheiben: 0, label:'Schock aus! 💀' };
|
||||
|
||||
// Julchen: 4-2-1 (unsortiert erkannt)
|
||||
if (dice.includes(1) && dice.includes(2) && dice.includes(4)) return { type:'julchen', scheiben:7, label:'Julchen! 🎉' };
|
||||
|
||||
// General: 3x gleich (außer 3x1)
|
||||
if (a===b && b===c) return { type:'general', scheiben:3, label:`General ${a}er! 🎲`, value: a };
|
||||
|
||||
// Schock: 2x Eins + andere
|
||||
if (a===1 && b===1) {
|
||||
const schockVal = c;
|
||||
const label = schockVal === 2 ? 'Schock doof 😅' : `Schock ${schockVal}!`;
|
||||
return { type:'schock', scheiben: schockVal, label, value: schockVal };
|
||||
}
|
||||
|
||||
// Straße: aufeinanderfolgende Zahlen (nur wenn alle 3 auf einmal gewürfelt)
|
||||
if (c-a===2 && b-a===1) return { type:'strasse', scheiben:2, label:`Straße ${a}-${b}-${c}!`, value: a };
|
||||
|
||||
// Normale Zahl
|
||||
// Normale Zahlen: absteigend sortiert als dreistellige Zahl (z.B. 6-2-1 = 621, 5-5-4 = 554)
|
||||
const desc = [...sorted].reverse(); // absteigend
|
||||
const normalValue = desc[0]*100 + desc[1]*10 + desc[2];
|
||||
return { type:'normal', scheiben:1, label:`${desc[0]}-${desc[1]}-${desc[2]}`, value: normalValue };
|
||||
}
|
||||
|
||||
// Wertungsvergleich: wer hat schlechtere Würfel (bekommt Scheiben)
|
||||
// Gibt -1 wenn a schlechter, 1 wenn b schlechter, 0 bei echtem Gleichstand
|
||||
const TYPE_ORDER = ['normal','strasse','general','schock','julchen','schock_aus'];
|
||||
function compareResults(a, b) {
|
||||
const ai = TYPE_ORDER.indexOf(a.result.type);
|
||||
const bi = TYPE_ORDER.indexOf(b.result.type);
|
||||
if (ai !== bi) return ai < bi ? -1 : 1; // niedrigerer Typ = schlechter
|
||||
|
||||
// Gleicher Typ — Detailvergleich
|
||||
if (a.result.type === 'normal') {
|
||||
// Niedrigere Augensumme = schlechter
|
||||
if (a.result.value !== b.result.value) return a.result.value < b.result.value ? -1 : 1;
|
||||
return 0;
|
||||
}
|
||||
if (a.result.type === 'strasse') {
|
||||
if (a.result.value !== b.result.value) return a.result.value < b.result.value ? -1 : 1;
|
||||
return 0;
|
||||
}
|
||||
if (a.result.type === 'schock') {
|
||||
if (a.result.value !== b.result.value) return a.result.value < b.result.value ? -1 : 1;
|
||||
return 0;
|
||||
}
|
||||
if (a.result.type === 'general') {
|
||||
if (a.result.value !== b.result.value) return a.result.value < b.result.value ? -1 : 1;
|
||||
return 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function sendPush(userId, title, message) {
|
||||
const cfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(userId);
|
||||
if (!cfg?.app_token || !cfg?.user_key) return;
|
||||
fetch('https://api.pushover.net/1/messages.json', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ token:cfg.app_token, user:cfg.user_key, title, message, priority:0 }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
const uid = req => {
|
||||
// Testmodus: Admin kann als anderen User agieren
|
||||
const testAs = req.headers['x-schocken-test-as'];
|
||||
if (testAs && req.user?.role === 'admin') {
|
||||
const testId = parseInt(testAs);
|
||||
if (!isNaN(testId)) return testId;
|
||||
}
|
||||
return req.user.id;
|
||||
};
|
||||
|
||||
// ── Alle Spiele des Users ─────────────────────────────────────────────────────
|
||||
router.get('/', authenticate, (req, res) => {
|
||||
const me = uid(req);
|
||||
const games = db.prepare(`
|
||||
SELECT * FROM schocken_games
|
||||
WHERE players LIKE ? ORDER BY updated_at DESC LIMIT 20
|
||||
`).all(`%"id":${me}%`);
|
||||
res.json(games.map(g => ({
|
||||
...g,
|
||||
players: JSON.parse(g.players),
|
||||
player_chips: JSON.parse(g.player_chips),
|
||||
drink_losses: JSON.parse(g.drink_losses),
|
||||
current_round: g.current_round ? JSON.parse(g.current_round) : null,
|
||||
})));
|
||||
});
|
||||
|
||||
// ── Users für Einladen ────────────────────────────────────────────────────────
|
||||
router.get('/users', authenticate, (req, res) => {
|
||||
const me = uid(req);
|
||||
const isAdmin = req.user?.role === 'admin';
|
||||
const users = isAdmin
|
||||
? db.prepare('SELECT id, username FROM users WHERE id != ? ORDER BY username').all(me)
|
||||
: db.prepare('SELECT id, username FROM users WHERE id != ? AND hidden != 1 ORDER BY username').all(me);
|
||||
res.json(users);
|
||||
});
|
||||
|
||||
// ── Einzelnes Spiel ───────────────────────────────────────────────────────────
|
||||
router.get('/:id', authenticate, (req, res) => {
|
||||
const me = uid(req);
|
||||
const game = db.prepare('SELECT * FROM schocken_games WHERE id=?').get(req.params.id);
|
||||
if (!game) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const players = JSON.parse(game.players);
|
||||
if (!players.find(p => p.id === me)) return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
res.json({
|
||||
...game,
|
||||
players,
|
||||
player_chips: JSON.parse(game.player_chips),
|
||||
drink_losses: JSON.parse(game.drink_losses),
|
||||
current_round: game.current_round ? JSON.parse(game.current_round) : null,
|
||||
});
|
||||
});
|
||||
|
||||
// ── Neues Spiel erstellen ─────────────────────────────────────────────────────
|
||||
router.post('/', authenticate, (req, res) => {
|
||||
const me = uid(req);
|
||||
const { player_ids = [] } = req.body;
|
||||
if (player_ids.length < 1) return res.status(400).json({ error: 'Mindestens 1 weiterer Spieler' });
|
||||
|
||||
const allIds = [me, ...player_ids.filter(id => id !== me)];
|
||||
const userRows = db.prepare(`SELECT id, username FROM users WHERE id IN (${allIds.map(()=>'?').join(',')}) ORDER BY username`).all(...allIds);
|
||||
|
||||
// Reihenfolge: Ersteller zuerst, dann die anderen
|
||||
const ordered = [
|
||||
userRows.find(u => u.id === me),
|
||||
...allIds.slice(1).map(id => userRows.find(u => u.id === id)).filter(Boolean),
|
||||
];
|
||||
const players = ordered.map((u, i) => ({ id: u.id, username: u.username, order: i }));
|
||||
const playerChips = Object.fromEntries(players.map(p => [p.id, 0]));
|
||||
const drinkLosses = Object.fromEntries(players.map(p => [p.id, 0]));
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO schocken_games (players, stock, player_chips, drink_losses, beginner_id, status)
|
||||
VALUES (?, 15, ?, ?, ?, 'lobby')
|
||||
`).run(JSON.stringify(players), JSON.stringify(playerChips), JSON.stringify(drinkLosses), me);
|
||||
|
||||
// Pushover an andere Spieler
|
||||
const myName = ordered[0].username;
|
||||
for (const p of players.slice(1)) {
|
||||
sendPush(p.id, '🎲 Schocken', `${myName} lädt dich zu einer Runde Schocken ein!`);
|
||||
}
|
||||
|
||||
res.json({ id: result.lastInsertRowid });
|
||||
});
|
||||
|
||||
// ── Spiel starten (Lobby → Hälfte 1) ─────────────────────────────────────────
|
||||
router.post('/:id/start', authenticate, (req, res) => {
|
||||
const me = uid(req);
|
||||
const game = db.prepare('SELECT * FROM schocken_games WHERE id=?').get(req.params.id);
|
||||
if (!game) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const players = JSON.parse(game.players);
|
||||
if (players[0].id !== me) return res.status(403).json({ error: 'Nur der Ersteller kann starten' });
|
||||
if (game.status !== 'lobby') return res.status(400).json({ error: 'Spiel läuft schon' });
|
||||
|
||||
db.prepare(`UPDATE schocken_games SET status='half1', first_round=1, beginner_id=?,
|
||||
current_player_idx=0, updated_at=datetime('now','localtime') WHERE id=?`).run(me, game.id);
|
||||
|
||||
for (const p of players.slice(1)) {
|
||||
sendPush(p.id, '🎲 Schocken', 'Das Spiel beginnt! Du bist dran.');
|
||||
}
|
||||
|
||||
res.json(getGame(game.id, me));
|
||||
});
|
||||
|
||||
// ── Würfeln ───────────────────────────────────────────────────────────────────
|
||||
router.post('/:id/roll', authenticate, (req, res) => {
|
||||
const me = uid(req);
|
||||
const { keep_dice = [], flip_sixes = false, go_dark = false } = req.body;
|
||||
// keep_dice: array of indices (0-2) der Würfel die stehen bleiben
|
||||
// flip_sixes: zwei Sechsen zu Eins umdrehen
|
||||
// go_dark: dunkel legen nach diesem Wurf
|
||||
|
||||
const game = db.prepare('SELECT * FROM schocken_games WHERE id=?').get(req.params.id);
|
||||
if (!game) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
|
||||
const players = JSON.parse(game.players);
|
||||
const activePlayers = getActivePlayers(game);
|
||||
const currentPlayer = activePlayers[game.current_player_idx % activePlayers.length];
|
||||
|
||||
if (!currentPlayer || currentPlayer.id !== me)
|
||||
return res.status(400).json({ error: 'Nicht dein Zug' });
|
||||
|
||||
let round = game.current_round ? JSON.parse(game.current_round) : initRound(game, activePlayers);
|
||||
const myState = round.playerStates[me];
|
||||
|
||||
if (myState.done) return res.status(400).json({ error: 'Du hast schon gewürfelt' });
|
||||
if (myState.dark) return res.status(400).json({ error: 'Du bist dunkel' });
|
||||
|
||||
// Flip sixes: nur wenn noch Würfe übrig und nicht letzter Wurf
|
||||
if (flip_sixes) {
|
||||
const sixes = myState.dice.reduce((acc, v, i) => v === 6 ? [...acc, i] : acc, []);
|
||||
if (sixes.length >= 2) {
|
||||
myState.dice[sixes[0]] = 1;
|
||||
myState.dice[sixes[1]] = 1;
|
||||
// Kostet keinen Wurf, braucht aber noch einen
|
||||
}
|
||||
}
|
||||
|
||||
// Würfeln
|
||||
const rollCount = myState.roll_count;
|
||||
const isFirstRound = !!game.first_round;
|
||||
|
||||
// Neue Würfel für nicht-stehen-gelassene
|
||||
if (!isFirstRound || rollCount === 0) {
|
||||
const newDice = rollDice(3 - keep_dice.length);
|
||||
let newIdx = 0;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (!keep_dice.includes(i)) myState.dice[i] = newDice[newIdx++];
|
||||
}
|
||||
}
|
||||
|
||||
myState.roll_count++;
|
||||
|
||||
// Straße nur gültig wenn im ersten Wurf (keine stehen gelassenen Würfel)
|
||||
const classified = classifyDice(myState.dice);
|
||||
if (classified.type === 'strasse' && (keep_dice.length > 0 || myState.roll_count > 1)) {
|
||||
// Straße ungültig — als normal werten
|
||||
classified.type = 'normal';
|
||||
classified.scheiben = 1;
|
||||
classified.label = 'Normal (Straße ungültig)';
|
||||
}
|
||||
|
||||
// Max rolls durch Beginner bestimmt
|
||||
const maxRolls = round.max_rolls || 3;
|
||||
const isDark = go_dark || myState.roll_count >= maxRolls || isFirstRound;
|
||||
const isDone = isDark || myState.roll_count >= maxRolls || isFirstRound;
|
||||
|
||||
myState.dark = go_dark || (myState.roll_count >= maxRolls && !isDone) || isFirstRound;
|
||||
myState.done = isDone;
|
||||
myState.result = isDone && !go_dark ? classified : null; // bei dunkel: result erst am Ende
|
||||
|
||||
// Wenn Beginner (erster Spieler): max_rolls setzen
|
||||
if (currentPlayer.id === round.beginner_id && round.max_rolls === null) {
|
||||
round.max_rolls = myState.roll_count;
|
||||
}
|
||||
|
||||
// Nächsten Spieler
|
||||
const nextIdx = (game.current_player_idx + 1) % activePlayers.length;
|
||||
const allDone = activePlayers.every(p => round.playerStates[p.id]?.done);
|
||||
|
||||
round.playerStates[me] = myState;
|
||||
|
||||
if (allDone) {
|
||||
// Alle dunkel aufdecken
|
||||
for (const p of activePlayers) {
|
||||
const ps = round.playerStates[p.id];
|
||||
if (!ps.result) {
|
||||
ps.result = classifyDice(ps.dice);
|
||||
// Straße bei dunkel-legen auch ungültig wenn keep_dice vorhanden (nicht trackbar nachträglich)
|
||||
}
|
||||
}
|
||||
round.phase = 'reveal';
|
||||
}
|
||||
|
||||
db.prepare(`UPDATE schocken_games SET current_round=?, current_player_idx=?,
|
||||
updated_at=datetime('now','localtime') WHERE id=?`)
|
||||
.run(JSON.stringify(round), allDone ? game.current_player_idx : nextIdx, game.id);
|
||||
|
||||
// Pushover an nächsten Spieler
|
||||
if (!allDone) {
|
||||
const next = activePlayers[nextIdx];
|
||||
if (next && next.id !== me) {
|
||||
sendPush(next.id, '🎲 Schocken', `${currentPlayer.username} hat gewürfelt — du bist dran!`);
|
||||
}
|
||||
}
|
||||
|
||||
res.json(getGame(game.id, me));
|
||||
});
|
||||
|
||||
// ── Runde auswerten ───────────────────────────────────────────────────────────
|
||||
router.post('/:id/evaluate', authenticate, (req, res) => {
|
||||
const me = uid(req);
|
||||
const game = db.prepare('SELECT * FROM schocken_games WHERE id=?').get(req.params.id);
|
||||
if (!game) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
|
||||
const players = JSON.parse(game.players);
|
||||
const activePlayers = getActivePlayers(game);
|
||||
if (players[0].id !== me && activePlayers[0]?.id !== me)
|
||||
return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
|
||||
const round = JSON.parse(game.current_round);
|
||||
if (round.phase !== 'reveal') return res.status(400).json({ error: 'Noch nicht alle fertig' });
|
||||
|
||||
const playerChips = JSON.parse(game.player_chips);
|
||||
let stock = game.stock;
|
||||
|
||||
// Schock aus prüfen
|
||||
const schockAus = activePlayers.find(p => round.playerStates[p.id]?.result?.type === 'schock_aus');
|
||||
if (schockAus) {
|
||||
return handleSchockAus(game, round, activePlayers, res, me);
|
||||
}
|
||||
|
||||
// Bestes Ergebnis finden (höchster Typ)
|
||||
const results = activePlayers.map(p => ({
|
||||
player: p,
|
||||
state: round.playerStates[p.id],
|
||||
result: round.playerStates[p.id]?.result,
|
||||
rolls: round.playerStates[p.id]?.roll_count || 1,
|
||||
})).filter(r => r.result);
|
||||
|
||||
// Schlechtestes Ergebnis = bekommt Scheiben
|
||||
results.sort((a, b) => {
|
||||
const cmp = compareResults(a, b);
|
||||
if (cmp !== 0) return cmp; // -1 wenn a schlechter
|
||||
// Gleicher Typ: mehr Würfe = schlechter (vorne in der Liste = Index 0 = Verlierer)
|
||||
if (a.rolls !== b.rolls) return a.rolls - b.rolls; // mehr Würfe = schlechter (vorne)
|
||||
// Nachleger: später in Reihenfolge = schlechter (vorne)
|
||||
return activePlayers.indexOf(a.player) - activePlayers.indexOf(b.player);
|
||||
});
|
||||
|
||||
const loser = results[0]; // schlechtestes Ergebnis (Index 0 nach sort)
|
||||
const winner = results[results.length - 1]; // bestes Ergebnis
|
||||
// Scheiben die verteilt werden = Wert des BESTEN Ergebnisses der Runde
|
||||
const scheiben = winner.result.scheiben;
|
||||
|
||||
// Scheiben verteilen
|
||||
let fromStock = 0;
|
||||
let newStock = stock;
|
||||
if (stock >= scheiben) {
|
||||
fromStock = scheiben;
|
||||
newStock = stock - scheiben;
|
||||
} else {
|
||||
fromStock = stock;
|
||||
newStock = 0;
|
||||
}
|
||||
|
||||
const fromOthers = scheiben - fromStock;
|
||||
playerChips[loser.player.id] = (playerChips[loser.player.id] || 0) + scheiben;
|
||||
|
||||
// Wenn vom Stock nicht genug: Rest von aktivsten Spielern nehmen (noch nicht implementiert vereinfacht)
|
||||
if (fromOthers > 0) {
|
||||
// Scheiben von anderen Spielern umverteilen (von denen mit meisten Scheiben)
|
||||
const donors = activePlayers
|
||||
.filter(p => p.id !== loser.player.id && playerChips[p.id] > 0)
|
||||
.sort((a,b) => playerChips[b.id] - playerChips[a.id]);
|
||||
let remaining = fromOthers;
|
||||
for (const donor of donors) {
|
||||
if (remaining <= 0) break;
|
||||
const take = Math.min(playerChips[donor.id], remaining);
|
||||
playerChips[donor.id] -= take;
|
||||
remaining -= take;
|
||||
}
|
||||
}
|
||||
|
||||
// Hälfte/Endkampf vorbei? Prüfen ob Stock leer und nur einer Scheiben hat
|
||||
const playersWithChips = activePlayers.filter(p => playerChips[p.id] > 0);
|
||||
const phaseOver = newStock === 0 && playersWithChips.length <= 1;
|
||||
|
||||
let newStatus = game.status;
|
||||
let newPhaseStatus = 'playing';
|
||||
let loserH1 = game.loser_h1;
|
||||
let loserH2 = game.loser_h2;
|
||||
let has16th = game.has_16th;
|
||||
let eventMsg = null;
|
||||
|
||||
if (phaseOver) {
|
||||
const phaseLoser = playersWithChips[0] || loser.player;
|
||||
|
||||
if (game.status === 'half1') {
|
||||
// Verlierer H1 bestimmt
|
||||
loserH1 = phaseLoser.id;
|
||||
has16th = phaseLoser.id;
|
||||
newStatus = 'half2';
|
||||
// Chips reset, Stock reset
|
||||
Object.keys(playerChips).forEach(k => { playerChips[k] = 0; });
|
||||
newStock = 15;
|
||||
eventMsg = `${phaseLoser.username} hat die erste Hälfte verloren. Die erste Hälfte kostet nur Nerven! 😅`;
|
||||
// Pushover
|
||||
for (const p of players) {
|
||||
sendPush(p.id, '🎲 Schocken', eventMsg);
|
||||
}
|
||||
} else if (game.status === 'half2') {
|
||||
loserH2 = phaseLoser.id;
|
||||
if (loserH1 === loserH2) {
|
||||
// Doppelfeige!
|
||||
newStatus = 'finished';
|
||||
newPhaseStatus = 'finished';
|
||||
const dl = JSON.parse(game.drink_losses);
|
||||
dl[phaseLoser.id] = (dl[phaseLoser.id] || 0) + 1;
|
||||
eventMsg = `${phaseLoser.username} ist DOPPELFEIGE! 🪶 Die Runde ist vorbei.`;
|
||||
db.prepare(`UPDATE schocken_games SET status=?, phase_status=?, loser_h2=?, has_16th=?,
|
||||
player_chips=?, stock=?, drink_losses=?, current_round=NULL,
|
||||
updated_at=datetime('now','localtime') WHERE id=?`)
|
||||
.run(newStatus, newPhaseStatus, loserH2, has16th, JSON.stringify(playerChips),
|
||||
newStock, dl, game.id);
|
||||
for (const p of players) sendPush(p.id, '🎲 Schocken', eventMsg);
|
||||
return res.json(getGame(game.id, me));
|
||||
}
|
||||
// Endkampf
|
||||
newStatus = 'endkampf';
|
||||
Object.keys(playerChips).forEach(k => { playerChips[k] = 0; });
|
||||
newStock = 15;
|
||||
eventMsg = `${phaseLoser.username} verliert die zweite Hälfte! Endkampf: ${players.find(p=>p.id===loserH1)?.username} vs ${phaseLoser.username}`;
|
||||
for (const p of players) sendPush(p.id, '🎲 Schocken', eventMsg);
|
||||
} else if (game.status === 'endkampf') {
|
||||
newStatus = 'finished';
|
||||
newPhaseStatus = 'finished';
|
||||
const dl = JSON.parse(game.drink_losses);
|
||||
dl[phaseLoser.id] = (dl[phaseLoser.id] || 0) + 1;
|
||||
eventMsg = `${phaseLoser.username} hat den Endkampf verloren und muss die nächste Runde zahlen! 🍺`;
|
||||
db.prepare(`UPDATE schocken_games SET status=?, phase_status=?, loser_h2=?,
|
||||
player_chips=?, stock=?, drink_losses=?, current_round=NULL,
|
||||
updated_at=datetime('now','localtime') WHERE id=?`)
|
||||
.run(newStatus, newPhaseStatus, loserH2, JSON.stringify(playerChips), newStock, JSON.stringify(dl), game.id);
|
||||
for (const p of players) sendPush(p.id, '🎲 Schocken', eventMsg);
|
||||
return res.json(getGame(game.id, me));
|
||||
}
|
||||
}
|
||||
|
||||
// Nächste Runde vorbereiten
|
||||
const nextBeginner = loser.player;
|
||||
db.prepare(`UPDATE schocken_games SET player_chips=?, stock=?, status=?, loser_h1=?, loser_h2=?,
|
||||
has_16th=?, beginner_id=?, current_round=NULL, current_player_idx=0, first_round=0,
|
||||
updated_at=datetime('now','localtime') WHERE id=?`)
|
||||
.run(JSON.stringify(playerChips), newStock, newStatus, loserH1, loserH2,
|
||||
has16th, nextBeginner.id, game.id);
|
||||
|
||||
// Pushover an Verlierer der Runde (er fängt an)
|
||||
sendPush(loser.player.id, '🎲 Schocken', `Du hast die Runde verloren und fängst die nächste an! (${scheiben} Scheibe${scheiben!==1?'n':''})`);
|
||||
|
||||
res.json({ ...getGame(game.id, me), round_result: { loser: loser.player, scheiben, eventMsg } });
|
||||
});
|
||||
|
||||
// ── Becher umdrehen (Runde bereit signalisieren) ──────────────────────────────
|
||||
router.post('/:id/ready', authenticate, (req, res) => {
|
||||
const me = uid(req);
|
||||
const game = db.prepare('SELECT * FROM schocken_games WHERE id=?').get(req.params.id);
|
||||
if (!game) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
|
||||
const players = JSON.parse(game.players);
|
||||
const playerChips = JSON.parse(game.player_chips);
|
||||
const activePlayers = getActivePlayers(game);
|
||||
|
||||
// Spieler der eigentlich raus wäre aber trotzdem ready drückt → wird wieder aktiv (Falle!)
|
||||
const isActuallyActive = activePlayers.find(p => p.id === me);
|
||||
const isOut = !isActuallyActive && playerChips[me] === 0 && game.stock === 0;
|
||||
|
||||
let round = game.current_round ? JSON.parse(game.current_round) : null;
|
||||
if (!round) {
|
||||
round = initRound(game, isOut ? [...activePlayers, players.find(p=>p.id===me)] : activePlayers);
|
||||
}
|
||||
|
||||
round.ready = round.ready || {};
|
||||
round.ready[me] = true;
|
||||
|
||||
// Wenn isOut und drückt trotzdem: wird als "gefangen" markiert
|
||||
if (isOut) {
|
||||
round.trapped = round.trapped || [];
|
||||
if (!round.trapped.includes(me)) round.trapped.push(me);
|
||||
}
|
||||
|
||||
db.prepare(`UPDATE schocken_games SET current_round=?, updated_at=datetime('now','localtime') WHERE id=?`)
|
||||
.run(JSON.stringify(round), game.id);
|
||||
|
||||
res.json(getGame(game.id, me));
|
||||
});
|
||||
|
||||
// ── Hilfsfunktionen ───────────────────────────────────────────────────────────
|
||||
function getActivePlayers(game) {
|
||||
const players = JSON.parse(game.players);
|
||||
const playerChips = JSON.parse(game.player_chips);
|
||||
const stock = game.stock;
|
||||
|
||||
if (game.status === 'endkampf') {
|
||||
return players.filter(p => p.id === game.loser_h1 || p.id === game.loser_h2);
|
||||
}
|
||||
|
||||
// Wenn Stock noch voll oder erste Runde: alle mitspielen
|
||||
if (stock > 0) return players;
|
||||
|
||||
// Stock leer: nur Spieler mit Scheiben
|
||||
return players.filter(p => playerChips[p.id] > 0);
|
||||
}
|
||||
|
||||
function initRound(game, activePlayers) {
|
||||
const playerStates = {};
|
||||
for (const p of activePlayers) {
|
||||
playerStates[p.id] = {
|
||||
dice: [null, null, null],
|
||||
roll_count: 0,
|
||||
done: false,
|
||||
dark: false,
|
||||
result: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
phase: 'rolling',
|
||||
beginner_id: game.beginner_id,
|
||||
max_rolls: game.first_round ? 1 : null,
|
||||
ready: {},
|
||||
trapped: [],
|
||||
playerStates,
|
||||
};
|
||||
}
|
||||
|
||||
function handleSchockAus(game, round, activePlayers, res, me) {
|
||||
const players = JSON.parse(game.players);
|
||||
const playerChips = JSON.parse(game.player_chips);
|
||||
|
||||
// Alle Scheiben (Stock + Spieler) → schlechtester Spieler
|
||||
const allChips = Object.values(playerChips).reduce((s,v) => s+v, 0) + game.stock;
|
||||
|
||||
// Wer hat die wenigsten Punkte (außer dem der Schock aus hat)?
|
||||
const schockAusPlayer = activePlayers.find(p => round.playerStates[p.id]?.result?.type === 'schock_aus');
|
||||
const others = activePlayers.filter(p => p.id !== schockAusPlayer?.id);
|
||||
|
||||
// Vereinfacht: Schock aus Spieler hat gewonnen, alle Scheiben gehen an den schlechtesten der anderen
|
||||
// (Schock aus steht über allem)
|
||||
const newChips = Object.fromEntries(Object.keys(playerChips).map(k => [k, 0]));
|
||||
|
||||
// Verlierer = wer die wenigsten Punkte hat unter den anderen
|
||||
// Bei nur einem anderen: der verliert
|
||||
const phaseLoser = others.length > 0 ? others[others.length - 1] : schockAusPlayer;
|
||||
newChips[phaseLoser.id] = allChips;
|
||||
|
||||
let newStatus = game.status;
|
||||
let loserH1 = game.loser_h1;
|
||||
let has16th = game.has_16th;
|
||||
|
||||
if (game.status === 'half1') {
|
||||
loserH1 = phaseLoser.id;
|
||||
has16th = phaseLoser.id;
|
||||
newStatus = 'half2';
|
||||
Object.keys(newChips).forEach(k => { newChips[k] = 0; });
|
||||
const eventMsg = `SCHOCK AUS! ${phaseLoser.username} verliert die erste Hälfte! Die erste Hälfte kostet nur Nerven! 😅`;
|
||||
for (const p of players) sendPush(p.id, '🎲 Schocken', eventMsg);
|
||||
db.prepare(`UPDATE schocken_games SET player_chips=?, stock=15, status=?, loser_h1=?, has_16th=?,
|
||||
beginner_id=?, current_round=NULL, current_player_idx=0, first_round=1,
|
||||
updated_at=datetime('now','localtime') WHERE id=?`)
|
||||
.run(JSON.stringify(newChips), newStatus, loserH1, has16th, phaseLoser.id, game.id);
|
||||
}
|
||||
|
||||
res.json({ ...getGame(game.id, me), schock_aus: true, phase_loser: phaseLoser });
|
||||
}
|
||||
|
||||
function getGame(id, requesterId) {
|
||||
const game = db.prepare('SELECT * FROM schocken_games WHERE id=?').get(id);
|
||||
if (!game) return null;
|
||||
return {
|
||||
...game,
|
||||
players: JSON.parse(game.players),
|
||||
player_chips: JSON.parse(game.player_chips),
|
||||
drink_losses: JSON.parse(game.drink_losses),
|
||||
current_round: game.current_round ? JSON.parse(game.current_round) : null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Spiel abbrechen (Admin only) — wird komplett gelöscht ───────────────────
|
||||
router.post('/:id/cancel', authenticate, (req, res) => {
|
||||
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Nur Admin' });
|
||||
const game = db.prepare('SELECT * FROM schocken_games WHERE id=?').get(req.params.id);
|
||||
if (!game) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const players = JSON.parse(game.players);
|
||||
db.prepare('DELETE FROM schocken_games WHERE id=?').run(game.id);
|
||||
for (const p of players) {
|
||||
sendPush(p.id, '🎲 Schocken', 'Das Spiel wurde vom Admin abgebrochen.');
|
||||
}
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
142
backend/src/tools/snippets/routes.js
Normal file
@@ -0,0 +1,142 @@
|
||||
const express = require('express');
|
||||
const db = require('../../db');
|
||||
const { authenticate } = require('../../middleware/auth');
|
||||
const router = express.Router();
|
||||
|
||||
const uid = req => req.user.id;
|
||||
|
||||
const getTags = id => db.prepare('SELECT tag FROM snippet_tags WHERE snippet_id=?').all(id).map(r=>r.tag);
|
||||
|
||||
const enrich = (s) => ({ ...s, tags: getTags(s.id) });
|
||||
|
||||
// ── Alle Snippets (eigene + geteilt) ────────────────────────────────────────
|
||||
router.get('/', authenticate, (req, res) => {
|
||||
const me = uid(req);
|
||||
const own = db.prepare(`
|
||||
SELECT s.*, u.username as owner
|
||||
FROM snippets s JOIN users u ON u.id=s.user_id
|
||||
WHERE s.user_id=? ORDER BY s.updated_at DESC, s.created_at DESC
|
||||
`).all(me).map(enrich);
|
||||
|
||||
const shared = db.prepare(`
|
||||
SELECT s.*, u.username as owner, 1 as is_shared
|
||||
FROM snippets s
|
||||
JOIN snippet_shares sh ON sh.snippet_id=s.id
|
||||
JOIN users u ON u.id=s.user_id
|
||||
WHERE sh.shared_with=?
|
||||
ORDER BY s.updated_at DESC
|
||||
`).all(me).map(enrich);
|
||||
|
||||
const sharedByMe = db.prepare(`
|
||||
SELECT s.*, u.username as shared_with_name
|
||||
FROM snippets s
|
||||
JOIN snippet_shares sh ON sh.snippet_id=s.id
|
||||
JOIN users u ON u.id=sh.shared_with
|
||||
WHERE s.user_id=?
|
||||
ORDER BY s.title ASC
|
||||
`).all(me).map(enrich);
|
||||
|
||||
res.json({ own, shared, sharedByMe });
|
||||
});
|
||||
|
||||
// ── Einzelnes Snippet ────────────────────────────────────────────────────────
|
||||
router.get('/:id', authenticate, (req, res) => {
|
||||
const s = db.prepare('SELECT s.*, u.username as owner FROM snippets s JOIN users u ON u.id=s.user_id WHERE s.id=?').get(req.params.id);
|
||||
if (!s) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const isOwner = s.user_id === uid(req);
|
||||
const isShared = db.prepare('SELECT id FROM snippet_shares WHERE snippet_id=? AND shared_with=?').get(s.id, uid(req));
|
||||
if (!isOwner && !isShared) return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
res.json(enrich(s));
|
||||
});
|
||||
|
||||
// ── History ──────────────────────────────────────────────────────────────────
|
||||
router.get('/:id/history', authenticate, (req, res) => {
|
||||
const s = db.prepare('SELECT * FROM snippets WHERE id=?').get(req.params.id);
|
||||
if (!s) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const isOwner = s.user_id === uid(req);
|
||||
const isShared = db.prepare('SELECT id FROM snippet_shares WHERE snippet_id=? AND shared_with=?').get(s.id, uid(req));
|
||||
if (!isOwner && !isShared) return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
res.json(db.prepare('SELECT * FROM snippet_history WHERE snippet_id=? ORDER BY saved_at DESC').all(s.id));
|
||||
});
|
||||
|
||||
// ── Erstellen ────────────────────────────────────────────────────────────────
|
||||
router.post('/', authenticate, (req, res) => {
|
||||
const { title, code='', language='text', description='', tags=[] } = req.body;
|
||||
if (!title?.trim()) return res.status(400).json({ error: 'Titel erforderlich' });
|
||||
const r = db.prepare(`
|
||||
INSERT INTO snippets (user_id, title, code, language, description, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,datetime('now','localtime'),datetime('now','localtime'))
|
||||
`).run(uid(req), title.trim(), code, language, description);
|
||||
const id = r.lastInsertRowid;
|
||||
const insertTag = db.prepare('INSERT OR IGNORE INTO snippet_tags (snippet_id, tag) VALUES (?,?)');
|
||||
db.transaction(()=>{ tags.forEach(t => insertTag.run(id, t)); })();
|
||||
res.json(enrich(db.prepare('SELECT s.*, u.username as owner FROM snippets s JOIN users u ON u.id=s.user_id WHERE s.id=?').get(id)));
|
||||
});
|
||||
|
||||
// ── Aktualisieren ────────────────────────────────────────────────────────────
|
||||
router.put('/:id', authenticate, (req, res) => {
|
||||
const s = db.prepare('SELECT * FROM snippets WHERE id=? AND user_id=?').get(req.params.id, uid(req));
|
||||
if (!s) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const { title, code, language, description, tags } = req.body;
|
||||
|
||||
// History-Eintrag wenn Code geändert
|
||||
if (code !== undefined && code !== s.code) {
|
||||
db.prepare(`INSERT INTO snippet_history (snippet_id, code, language, saved_at) VALUES (?,?,?,datetime('now','localtime'))`)
|
||||
.run(s.id, s.code, s.language);
|
||||
}
|
||||
|
||||
db.prepare(`UPDATE snippets SET title=?,code=?,language=?,description=?,updated_at=datetime('now','localtime') WHERE id=?`)
|
||||
.run(title??s.title, code??s.code, language??s.language, description??s.description, s.id);
|
||||
|
||||
if (Array.isArray(tags)) {
|
||||
db.prepare('DELETE FROM snippet_tags WHERE snippet_id=?').run(s.id);
|
||||
const ins = db.prepare('INSERT OR IGNORE INTO snippet_tags (snippet_id, tag) VALUES (?,?)');
|
||||
db.transaction(()=>{ tags.forEach(t => ins.run(s.id, t)); })();
|
||||
}
|
||||
|
||||
res.json(enrich(db.prepare('SELECT s.*, u.username as owner FROM snippets s JOIN users u ON u.id=s.user_id WHERE s.id=?').get(s.id)));
|
||||
});
|
||||
|
||||
// ── Löschen ──────────────────────────────────────────────────────────────────
|
||||
router.delete('/:id/history/:hid', authenticate, (req, res) => {
|
||||
const s = db.prepare('SELECT * FROM snippets WHERE id=? AND user_id=?').get(req.params.id, uid(req));
|
||||
if (!s) return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
db.prepare('DELETE FROM snippet_history WHERE id=? AND snippet_id=?').run(req.params.hid, s.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.delete('/:id', authenticate, (req, res) => {
|
||||
const r = db.prepare('DELETE FROM snippets WHERE id=? AND user_id=?').run(req.params.id, uid(req));
|
||||
if (!r.changes) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Shares ───────────────────────────────────────────────────────────────────
|
||||
router.get('/:id/shares', authenticate, (req, res) => {
|
||||
const s = db.prepare('SELECT * FROM snippets WHERE id=? AND user_id=?').get(req.params.id, uid(req));
|
||||
if (!s) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
res.json(db.prepare(`
|
||||
SELECT u.id, u.username, sh.created_at as shared_at
|
||||
FROM snippet_shares sh JOIN users u ON u.id=sh.shared_with WHERE sh.snippet_id=?
|
||||
`).all(s.id));
|
||||
});
|
||||
|
||||
router.post('/:id/share', authenticate, (req, res) => {
|
||||
const s = db.prepare('SELECT * FROM snippets WHERE id=? AND user_id=?').get(req.params.id, uid(req));
|
||||
if (!s) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const target = db.prepare('SELECT * FROM users WHERE username=?').get(req.body.username);
|
||||
if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
|
||||
if (target.id === uid(req)) return res.status(400).json({ error: 'Kann nicht mit dir selbst teilen' });
|
||||
db.prepare(`INSERT OR IGNORE INTO snippet_shares (snippet_id,shared_by,shared_with,created_at) VALUES (?,?,?,datetime('now','localtime'))`)
|
||||
.run(s.id, uid(req), target.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.delete('/:id/share/:userId', authenticate, (req, res) => {
|
||||
const s = db.prepare('SELECT * FROM snippets WHERE id=? AND user_id=?').get(req.params.id, uid(req));
|
||||
if (!s) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
db.prepare('DELETE FROM snippet_shares WHERE snippet_id=? AND shared_with=?').run(s.id, req.params.userId);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
266
backend/src/tools/statistik/routes.js
Normal file
@@ -0,0 +1,266 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const db = require('../../db');
|
||||
const { authenticate } = require('../../middleware/auth');
|
||||
|
||||
const uid = req => req.user.id;
|
||||
|
||||
// ── Ausgaben CRUD ──────────────────────────────────────────────────────────
|
||||
router.get('/expenses', authenticate, (req, res) => {
|
||||
const { from, to } = req.query;
|
||||
let q = 'SELECT * FROM expenses WHERE user_id=?';
|
||||
const p = [uid(req)];
|
||||
if (from) { q += ' AND date>=?'; p.push(from); }
|
||||
if (to) { q += ' AND date<=?'; p.push(to); }
|
||||
res.json(db.prepare(q + ' ORDER BY date DESC, id DESC').all(...p));
|
||||
});
|
||||
|
||||
router.post('/expenses', authenticate, (req, res) => {
|
||||
const { date, category, description, amount } = req.body;
|
||||
if (!date || !description || !amount) return res.status(400).json({ error: 'Fehlende Felder' });
|
||||
const r = db.prepare('INSERT INTO expenses (user_id,date,category,description,amount) VALUES (?,?,?,?,?)')
|
||||
.run(uid(req), date, category||'Sonstiges', description, parseFloat(amount));
|
||||
res.json(db.prepare('SELECT * FROM expenses WHERE id=?').get(r.lastInsertRowid));
|
||||
});
|
||||
|
||||
router.delete('/expenses/:id', authenticate, (req, res) => {
|
||||
const e = db.prepare('SELECT id FROM expenses WHERE id=? AND user_id=?').get(req.params.id, uid(req));
|
||||
if (!e) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
db.prepare('DELETE FROM expenses WHERE id=?').run(e.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Revenue eines Auftrags ────────────────────────────────────────────────
|
||||
function getOrderRevenue(order) {
|
||||
if (order.custom_price != null && order.custom_price > 0) return parseFloat(order.custom_price);
|
||||
const r = db.prepare(
|
||||
'SELECT COALESCE(SUM(custom_price * stueckzahl), 0) as s FROM order_items WHERE order_id=? AND custom_price IS NOT NULL'
|
||||
).get(order.id);
|
||||
return r?.s || 0;
|
||||
}
|
||||
|
||||
function getOrderBaseCost(orderId) {
|
||||
const r = db.prepare(`
|
||||
SELECT COALESCE(SUM(COALESCE(c.preis_freundschaft, oi.preis_freundschaft, 0) * oi.stueckzahl), 0) as s
|
||||
FROM order_items oi
|
||||
LEFT JOIN calculations c ON c.id = oi.calculation_id
|
||||
WHERE oi.order_id = ?
|
||||
`).get(orderId);
|
||||
return r?.s || 0;
|
||||
}
|
||||
|
||||
function round2(n) { return Math.round((n||0) * 100) / 100; }
|
||||
|
||||
// ── Statistik-Übersicht ────────────────────────────────────────────────────
|
||||
router.get('/overview', authenticate, (req, res) => {
|
||||
const { from, to } = req.query;
|
||||
const u = uid(req);
|
||||
|
||||
// Datums-Vergleich: ISO-String slice(0,10) = YYYY-MM-DD
|
||||
const inRange = (dateStr) => {
|
||||
if (!dateStr) return !from && !to; // kein Datum + kein Filter = ja; kein Datum + Filter = nein
|
||||
const d = dateStr.slice(0, 10);
|
||||
if (from && d < from) return false;
|
||||
if (to && d > to) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
// Alle Aufträge
|
||||
const allOrders = db.prepare('SELECT * FROM orders WHERE user_id=? ORDER BY created_at DESC').all(u);
|
||||
|
||||
// Zeitraum-Filterung:
|
||||
// - Bezahlte Aufträge: Datum = bezahlt_am (wann wurde Geld erhalten)
|
||||
// - Offene Aufträge (noch nicht bezahlt): Datum = created_at (wann erstellt)
|
||||
const filteredOrders = allOrders.filter(o => {
|
||||
if (!from && !to) return true; // kein Filter → alle
|
||||
if (o.bezahlt) {
|
||||
// Bezahlt → nach bezahlt_am wenn vorhanden, sonst created_at
|
||||
// Wenn bezahlt_am NULL ist (alte Aufträge vor dem Feature): created_at nutzen
|
||||
const d = o.bezahlt_am || o.created_at;
|
||||
return inRange(d);
|
||||
} else {
|
||||
return inRange(o.created_at);
|
||||
}
|
||||
});
|
||||
|
||||
const paidOrders = filteredOrders.filter(o => !!o.bezahlt);
|
||||
const openOrders = filteredOrders.filter(o => !o.bezahlt && o.status !== 'warteliste');
|
||||
|
||||
// KPI-Werte für den gewählten Zeitraum
|
||||
const totalRevenue = paidOrders.reduce((s,o) => s + getOrderRevenue(o), 0);
|
||||
const openRevenue = openOrders.reduce((s,o) => s + getOrderRevenue(o), 0);
|
||||
const totalBaseCost = paidOrders.reduce((s,o) => s + getOrderBaseCost(o.id), 0);
|
||||
const totalProfit = totalRevenue - totalBaseCost;
|
||||
|
||||
// Ausgaben im Zeitraum
|
||||
let eq = 'SELECT * FROM expenses WHERE user_id=?';
|
||||
const ep = [u];
|
||||
if (from) { eq += ' AND date>=?'; ep.push(from); }
|
||||
if (to) { eq += ' AND date<=?'; ep.push(to); }
|
||||
const expenses = db.prepare(eq + ' ORDER BY date DESC').all(...ep);
|
||||
const totalExpenses = expenses.reduce((s,e) => s + e.amount, 0);
|
||||
const netProfit = totalProfit - totalExpenses;
|
||||
|
||||
// Status-Verteilung im Zeitraum
|
||||
const byStatus = { warteliste:0, in_arbeit:0, fertig:0, bezahlt:0, abgeschlossen:0 };
|
||||
for (const o of filteredOrders) {
|
||||
if (!!o.bezahlt && !!o.abgeholt) byStatus.abgeschlossen++;
|
||||
else if (!!o.bezahlt) byStatus.bezahlt++;
|
||||
else if (o.status in byStatus) byStatus[o.status]++;
|
||||
}
|
||||
|
||||
// Ausgaben nach Kategorie (Zeitraum)
|
||||
const byCategory = {};
|
||||
for (const e of expenses) {
|
||||
byCategory[e.category] = (byCategory[e.category] || 0) + e.amount;
|
||||
}
|
||||
|
||||
// ── Diagramm-Daten: immer nach dem relevanten Datum gruppiert ────────────
|
||||
// Für Diagramme: alle Aufträge (ungefiltert) in Monatsbuckets einordnen
|
||||
// damit das Balkendiagramm den vollen Kontext zeigt (letzte 12 Monate)
|
||||
// ABER: wenn ein konkreter Zeitraum gewählt ist, zeigen wir nur diesen Zeitraum
|
||||
// aufgeteilt in Tages/Wochen/Monats-Buckets je nach Länge
|
||||
|
||||
// Granularität bestimmen
|
||||
let granularity = 'month'; // Standard
|
||||
if (from && to) {
|
||||
const days = (new Date(to) - new Date(from)) / 86400000;
|
||||
if (days <= 14) granularity = 'day';
|
||||
else if (days <= 31) granularity = 'week';
|
||||
// > 31 Tage → month (Monatsauswahl, Jahresauswahl, Gesamt)
|
||||
}
|
||||
|
||||
function getBucketKey(dateStr) {
|
||||
if (!dateStr) return null;
|
||||
const d = dateStr.slice(0, 10); // YYYY-MM-DD, immer lokal
|
||||
if (granularity === 'day') return d;
|
||||
if (granularity === 'week') {
|
||||
// Montag der Woche – rein mit String-Arithmetik (kein UTC-Bug)
|
||||
const [y, m, dd] = d.split('-').map(Number);
|
||||
const dt = new Date(y, m - 1, dd); // lokaler Konstruktor
|
||||
const day = dt.getDay() || 7; // 1=Mo … 7=So
|
||||
dt.setDate(dt.getDate() - day + 1);
|
||||
const py = dt.getFullYear();
|
||||
const pm = String(dt.getMonth() + 1).padStart(2, '0');
|
||||
const pd = String(dt.getDate()).padStart(2, '0');
|
||||
return `${py}-${pm}-${pd}`;
|
||||
}
|
||||
return d.slice(0, 7); // YYYY-MM
|
||||
}
|
||||
|
||||
// Buckets aus gefilterten Aufträgen (damit der Zeitraum stimmt)
|
||||
const buckets = {};
|
||||
|
||||
// Auch alle Aufträge für Diagramm wenn kein Filter (zeige letzte 12 Monate)
|
||||
const diagramOrders = (from || to) ? filteredOrders : allOrders;
|
||||
|
||||
for (const o of diagramOrders) {
|
||||
const dateStr = o.bezahlt ? (o.bezahlt_am || o.created_at) : o.created_at;
|
||||
const key = getBucketKey(dateStr);
|
||||
if (!key) continue;
|
||||
if (!buckets[key]) buckets[key] = { month:key, revenue:0, expenses:0, orders:0 };
|
||||
if (o.bezahlt) {
|
||||
buckets[key].revenue += getOrderRevenue(o);
|
||||
buckets[key].base_cost = (buckets[key].base_cost||0) + getOrderBaseCost(o.id);
|
||||
}
|
||||
buckets[key].orders++;
|
||||
}
|
||||
|
||||
// Ausgaben in Buckets (gefiltert)
|
||||
const diagramExpenses = (from || to) ? expenses : db.prepare('SELECT * FROM expenses WHERE user_id=?').all(u);
|
||||
for (const e of diagramExpenses) {
|
||||
const key = getBucketKey(e.date);
|
||||
if (!key) continue;
|
||||
if (!buckets[key]) buckets[key] = { month:key, revenue:0, expenses:0, orders:0 };
|
||||
buckets[key].expenses += e.amount;
|
||||
}
|
||||
|
||||
// Leere Buckets auffüllen – lokaler Date-Konstruktor um UTC-Bug zu vermeiden
|
||||
if (from || to) {
|
||||
const parseLocal = s => { const [y,m,d] = s.split('-').map(Number); return new Date(y, m-1, d); };
|
||||
const fmtLocal = dt => {
|
||||
const y = dt.getFullYear(), m = String(dt.getMonth()+1).padStart(2,'0'), d = String(dt.getDate()).padStart(2,'0');
|
||||
return `${y}-${m}-${d}`;
|
||||
};
|
||||
const firstBucket = Object.keys(buckets).sort()[0];
|
||||
const startD = parseLocal(from || firstBucket || to);
|
||||
const endD = parseLocal(to || fmtLocal(new Date()));
|
||||
const cur = new Date(startD);
|
||||
while (cur <= endD) {
|
||||
const key = getBucketKey(fmtLocal(cur));
|
||||
if (key && !buckets[key]) buckets[key] = { month:key, revenue:0, base_cost:0, expenses:0, orders:0 };
|
||||
if (granularity === 'day') cur.setDate(cur.getDate() + 1);
|
||||
else if (granularity === 'week') cur.setDate(cur.getDate() + 7);
|
||||
else cur.setMonth(cur.getMonth() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Sortiert + ohne Filter auf letzte 12 Monate begrenzen
|
||||
let monthlyData = Object.values(buckets)
|
||||
.sort((a,b) => a.month.localeCompare(b.month))
|
||||
.map(m => ({ ...m, revenue: round2(m.revenue), base_cost: round2(m.base_cost||0), expenses: round2(m.expenses) }));
|
||||
|
||||
if (!from && !to) monthlyData = monthlyData.slice(-12);
|
||||
|
||||
// Kumulierter Nettogewinn (pro Bucket)
|
||||
let cum = 0;
|
||||
const cumulativeData = monthlyData.map(m => {
|
||||
cum += (m.revenue||0) - (m.base_cost||0) - (m.expenses||0);
|
||||
return { month: m.month, value: round2(cum) };
|
||||
});
|
||||
|
||||
// Aufträge-Tabelle mit korrekten Werten
|
||||
const recentOrders = filteredOrders.slice(0, 50).map(o => ({
|
||||
id: o.id, name: o.name, status: o.status,
|
||||
bezahlt: o.bezahlt, abgeholt: o.abgeholt,
|
||||
created_at: o.created_at, bezahlt_am: o.bezahlt_am,
|
||||
revenue: round2(getOrderRevenue(o)),
|
||||
base_cost: round2(getOrderBaseCost(o.id)),
|
||||
}));
|
||||
|
||||
const paidOrdersOut = paidOrders.slice(0,50).map(o => ({
|
||||
id: o.id, name: o.name, status: o.status,
|
||||
bezahlt: o.bezahlt, abgeholt: o.abgeholt,
|
||||
created_at: o.created_at, bezahlt_am: o.bezahlt_am,
|
||||
revenue: round2(getOrderRevenue(o)),
|
||||
base_cost: round2(getOrderBaseCost(o.id)),
|
||||
}));
|
||||
|
||||
res.json({
|
||||
totalRevenue: round2(totalRevenue),
|
||||
openRevenue: round2(openRevenue),
|
||||
totalBaseCost: round2(totalBaseCost),
|
||||
totalProfit: round2(totalProfit),
|
||||
totalExpenses: round2(totalExpenses),
|
||||
netProfit: round2(netProfit),
|
||||
totalOrders: filteredOrders.length,
|
||||
granularity,
|
||||
byStatus, byCategory, monthlyData, cumulativeData,
|
||||
expenses: expenses.slice(0, 100),
|
||||
recentOrders,
|
||||
paidOrders: paidOrdersOut,
|
||||
});
|
||||
});
|
||||
|
||||
// Bestellungen nach created_at (für "Bestellungen diesen Monat" Tab)
|
||||
router.get('/month-orders', authenticate, (req, res) => {
|
||||
const { from, to } = req.query;
|
||||
const u = uid(req);
|
||||
let q = 'SELECT * FROM orders WHERE user_id=?';
|
||||
const p = [u];
|
||||
if (from) { q += ' AND DATE(created_at)>=?'; p.push(from); }
|
||||
if (to) { q += ' AND DATE(created_at)<=?'; p.push(to); }
|
||||
q += ' ORDER BY created_at DESC';
|
||||
const orders = db.prepare(q).all(...p);
|
||||
res.json(orders.map(o => ({
|
||||
id: o.id, name: o.name, status: o.status,
|
||||
bezahlt: o.bezahlt, abgeholt: o.abgeholt,
|
||||
created_at: o.created_at, bezahlt_am: o.bezahlt_am,
|
||||
revenue: round2(getOrderRevenue(o)),
|
||||
base_cost: round2(getOrderBaseCost(o.id)),
|
||||
})));
|
||||
});
|
||||
|
||||
|
||||
|
||||
module.exports = router;
|
||||
251
backend/src/tools/whiteboard/routes.js
Normal file
@@ -0,0 +1,251 @@
|
||||
const express = require('express');
|
||||
const db = require('../../db');
|
||||
const { authenticate, requireAdmin } = require('../../middleware/auth');
|
||||
const router = express.Router();
|
||||
|
||||
// ── DB-Migration ──────────────────────────────────────────────────────────────
|
||||
(function migrate() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS whiteboards (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT 'Neues Whiteboard',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS whiteboard_data (
|
||||
whiteboard_id INTEGER PRIMARY KEY REFERENCES whiteboards(id) ON DELETE CASCADE,
|
||||
elements TEXT NOT NULL DEFAULT '[]',
|
||||
viewport TEXT NOT NULL DEFAULT '{"x":0,"y":0,"zoom":1}'
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS whiteboard_permissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
whiteboard_id INTEGER NOT NULL REFERENCES whiteboards(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'edit',
|
||||
UNIQUE(whiteboard_id, user_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS whiteboard_unread (
|
||||
whiteboard_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
reason TEXT NOT NULL DEFAULT 'updated',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||
PRIMARY KEY (whiteboard_id, user_id)
|
||||
);
|
||||
`);
|
||||
})();
|
||||
|
||||
const uid = req => req.user.id;
|
||||
|
||||
function getAccessUsers(whiteboardId, exceptUserId) {
|
||||
const wb = db.prepare('SELECT owner_id FROM whiteboards WHERE id=?').get(whiteboardId);
|
||||
if (!wb) return [];
|
||||
const users = new Set();
|
||||
if (wb.owner_id !== exceptUserId) users.add(wb.owner_id);
|
||||
const perms = db.prepare('SELECT user_id FROM whiteboard_permissions WHERE whiteboard_id=?').all(whiteboardId);
|
||||
for (const p of perms) if (p.user_id !== exceptUserId) users.add(p.user_id);
|
||||
return [...users];
|
||||
}
|
||||
|
||||
function markUnread(whiteboardId, userIds, reason = 'updated') {
|
||||
const upsert = db.prepare(`
|
||||
INSERT INTO whiteboard_unread (whiteboard_id, user_id, reason, created_at)
|
||||
VALUES (?, ?, ?, datetime('now','localtime'))
|
||||
ON CONFLICT(whiteboard_id, user_id) DO UPDATE SET reason=excluded.reason, created_at=excluded.created_at
|
||||
`);
|
||||
for (const u of userIds) upsert.run(whiteboardId, u, reason);
|
||||
}
|
||||
|
||||
function markRead(whiteboardId, userId) {
|
||||
db.prepare('DELETE FROM whiteboard_unread WHERE whiteboard_id=? AND user_id=?').run(whiteboardId, userId);
|
||||
}
|
||||
|
||||
// Hilfsfunktion: Hat User Zugriff? Gibt 'owner'|'edit'|'view'|null zurück
|
||||
function access(whiteboardId, userId) {
|
||||
const wb = db.prepare('SELECT owner_id FROM whiteboards WHERE id=?').get(whiteboardId);
|
||||
if (!wb) return null;
|
||||
if (wb.owner_id === userId) return 'owner';
|
||||
const perm = db.prepare('SELECT role FROM whiteboard_permissions WHERE whiteboard_id=? AND user_id=?').get(whiteboardId, userId);
|
||||
return perm?.role || null;
|
||||
}
|
||||
|
||||
// ── Unread count ──────────────────────────────────────────────────────────────
|
||||
router.get('/unread', authenticate, (req, res) => {
|
||||
const me = uid(req);
|
||||
const { count } = db.prepare('SELECT COUNT(*) as count FROM whiteboard_unread WHERE user_id=?').get(me);
|
||||
res.json({ count });
|
||||
});
|
||||
|
||||
// ── Unread IDs (welche Whiteboards konkret) ───────────────────────────────────
|
||||
router.get('/unread-ids', authenticate, (req, res) => {
|
||||
const me = uid(req);
|
||||
const rows = db.prepare('SELECT whiteboard_id FROM whiteboard_unread WHERE user_id=?').all(me);
|
||||
res.json({ ids: rows.map(r => r.whiteboard_id) });
|
||||
});
|
||||
|
||||
// ── Liste ─────────────────────────────────────────────────────────────────────
|
||||
router.get('/', authenticate, (req, res) => {
|
||||
const me = uid(req);
|
||||
// Eigene + geteilte Whiteboards
|
||||
const own = db.prepare(`
|
||||
SELECT w.*, 'owner' as role, u.username as owner_name
|
||||
FROM whiteboards w JOIN users u ON u.id=w.owner_id
|
||||
WHERE w.owner_id=? ORDER BY w.updated_at DESC
|
||||
`).all(me);
|
||||
const shared = db.prepare(`
|
||||
SELECT w.*, p.role, u.username as owner_name
|
||||
FROM whiteboards w
|
||||
JOIN whiteboard_permissions p ON p.whiteboard_id=w.id AND p.user_id=?
|
||||
JOIN users u ON u.id=w.owner_id
|
||||
ORDER BY w.updated_at DESC
|
||||
`).all(me);
|
||||
res.json({ whiteboards: [...own, ...shared] });
|
||||
});
|
||||
|
||||
// ── Erstellen ─────────────────────────────────────────────────────────────────
|
||||
router.post('/', authenticate, (req, res) => {
|
||||
const { title = 'Neues Whiteboard' } = req.body;
|
||||
const me = uid(req);
|
||||
const r = db.prepare("INSERT INTO whiteboards (owner_id, title) VALUES (?,?)").run(me, title.trim() || 'Neues Whiteboard');
|
||||
db.prepare("INSERT INTO whiteboard_data (whiteboard_id) VALUES (?)").run(r.lastInsertRowid);
|
||||
res.json(db.prepare('SELECT * FROM whiteboards WHERE id=?').get(r.lastInsertRowid));
|
||||
});
|
||||
|
||||
// ── Umbenennen ────────────────────────────────────────────────────────────────
|
||||
router.patch('/:id/title', authenticate, (req, res) => {
|
||||
const { title } = req.body;
|
||||
if (!title?.trim()) return res.status(400).json({ error: 'Titel fehlt' });
|
||||
const me = uid(req);
|
||||
const role = access(req.params.id, me);
|
||||
if (!role || role === 'view') return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
db.prepare("UPDATE whiteboards SET title=?, updated_at=datetime('now','localtime') WHERE id=?").run(title.trim(), req.params.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Löschen ───────────────────────────────────────────────────────────────────
|
||||
router.delete('/:id', authenticate, (req, res) => {
|
||||
const me = uid(req);
|
||||
const wb = db.prepare('SELECT * FROM whiteboards WHERE id=?').get(req.params.id);
|
||||
if (!wb) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
if (wb.owner_id !== me) return res.status(403).json({ error: 'Nur der Ersteller kann löschen' });
|
||||
db.prepare('DELETE FROM whiteboards WHERE id=?').run(wb.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Canvas laden ──────────────────────────────────────────────────────────────
|
||||
router.get('/:id/data', authenticate, (req, res) => {
|
||||
const me = uid(req);
|
||||
const role = access(req.params.id, me);
|
||||
if (!role) return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
const data = db.prepare('SELECT * FROM whiteboard_data WHERE whiteboard_id=?').get(req.params.id);
|
||||
const wb = db.prepare('SELECT * FROM whiteboards WHERE id=?').get(req.params.id);
|
||||
const perms = db.prepare(`
|
||||
SELECT p.*, u.username FROM whiteboard_permissions p
|
||||
JOIN users u ON u.id=p.user_id WHERE p.whiteboard_id=?
|
||||
`).all(req.params.id);
|
||||
const owner = db.prepare('SELECT username FROM users WHERE id=?').get(wb.owner_id);
|
||||
markRead(req.params.id, me);
|
||||
res.json({ ...data, role, title: wb.title, owner: owner.username, permissions: perms });
|
||||
});
|
||||
|
||||
// ── Canvas speichern ──────────────────────────────────────────────────────────
|
||||
// Feste Route vor :id-Routen
|
||||
router.post('/:id/save', authenticate, async (req, res) => {
|
||||
const me = uid(req);
|
||||
const role = access(req.params.id, me);
|
||||
if (!role || role === 'view') return res.status(403).json({ error: 'Kein Schreibzugriff' });
|
||||
const { elements, viewport } = req.body;
|
||||
db.prepare(`
|
||||
UPDATE whiteboard_data SET elements=?, viewport=? WHERE whiteboard_id=?
|
||||
`).run(JSON.stringify(elements || []), JSON.stringify(viewport || {x:0,y:0,zoom:1}), req.params.id);
|
||||
db.prepare("UPDATE whiteboards SET updated_at=datetime('now','localtime') WHERE id=?").run(req.params.id);
|
||||
res.json({ ok: true });
|
||||
|
||||
// Pushover an alle anderen mit Zugriff (fire & forget)
|
||||
try {
|
||||
const wb = db.prepare('SELECT * FROM whiteboards WHERE id=?').get(req.params.id);
|
||||
const saver = db.prepare('SELECT username FROM users WHERE id=?').get(me);
|
||||
const message = `${saver?.username || 'Jemand'} hat das Whiteboard "${wb?.title || ''}" gespeichert.`;
|
||||
|
||||
// Alle User mit Zugriff: Owner + alle Permissions — außer dem Speichernden selbst
|
||||
const notify = getAccessUsers(req.params.id, me);
|
||||
markUnread(req.params.id, notify, 'updated');
|
||||
|
||||
for (const userId of notify) {
|
||||
const poCfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(userId);
|
||||
if (!poCfg?.app_token || !poCfg?.user_key) continue;
|
||||
fetch('https://api.pushover.net/1/messages.json', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: poCfg.app_token,
|
||||
user: poCfg.user_key,
|
||||
title: '🖊 Whiteboard aktualisiert',
|
||||
message,
|
||||
priority: 0,
|
||||
}),
|
||||
}).catch(() => {});
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
|
||||
// ── Berechtigungen: alle User für Share-Modal ─────────────────────────────────
|
||||
router.get('/users-list', authenticate, (req, res) => {
|
||||
const me = uid(req);
|
||||
const users = db.prepare('SELECT id, username FROM users WHERE id!=? ORDER BY username').all(me);
|
||||
res.json({ users });
|
||||
});
|
||||
|
||||
// ── Berechtigungen setzen ─────────────────────────────────────────────────────
|
||||
router.put('/:id/permissions', authenticate, async (req, res) => {
|
||||
const me = uid(req);
|
||||
const wb = db.prepare('SELECT * FROM whiteboards WHERE id=?').get(req.params.id);
|
||||
if (!wb) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
if (wb.owner_id !== me) return res.status(403).json({ error: 'Nur der Ersteller kann Berechtigungen vergeben' });
|
||||
const { permissions } = req.body; // [{user_id, role: 'edit'|'view'|null}]
|
||||
if (!Array.isArray(permissions)) return res.status(400).json({ error: 'permissions fehlt' });
|
||||
|
||||
// Vorherigen Stand merken um neue User zu erkennen
|
||||
const before = db.prepare('SELECT user_id FROM whiteboard_permissions WHERE whiteboard_id=?').all(req.params.id).map(r => r.user_id);
|
||||
|
||||
const upsert = db.prepare("INSERT INTO whiteboard_permissions (whiteboard_id,user_id,role) VALUES (?,?,?) ON CONFLICT(whiteboard_id,user_id) DO UPDATE SET role=excluded.role");
|
||||
const remove = db.prepare("DELETE FROM whiteboard_permissions WHERE whiteboard_id=? AND user_id=?");
|
||||
db.transaction(() => {
|
||||
for (const p of permissions) {
|
||||
if (!p.user_id) continue;
|
||||
if (p.role === null || p.role === 'none') remove.run(req.params.id, p.user_id);
|
||||
else upsert.run(req.params.id, p.user_id, p.role);
|
||||
}
|
||||
})();
|
||||
|
||||
const owner = db.prepare('SELECT username FROM users WHERE id=?').get(me);
|
||||
const newlyAdded = permissions.filter(p => p.role && p.role !== 'none' && !before.includes(p.user_id));
|
||||
if (newlyAdded.length) markUnread(req.params.id, newlyAdded.map(p => p.user_id), 'shared');
|
||||
|
||||
for (const p of newlyAdded) {
|
||||
try {
|
||||
const poCfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(p.user_id);
|
||||
if (!poCfg?.app_token || !poCfg?.user_key) continue;
|
||||
const roleLabel = p.role === 'edit' ? 'bearbeiten' : 'ansehen';
|
||||
fetch('https://api.pushover.net/1/messages.json', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: poCfg.app_token,
|
||||
user: poCfg.user_key,
|
||||
title: '🖊 Whiteboard geteilt',
|
||||
message: `${owner.username} hat das Whiteboard "${wb.title}" mit dir geteilt (${roleLabel}).`,
|
||||
priority: 0,
|
||||
}),
|
||||
}).catch(() => {});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const updated = db.prepare(`
|
||||
SELECT p.*, u.username FROM whiteboard_permissions p
|
||||
JOIN users u ON u.id=p.user_id WHERE p.whiteboard_id=?
|
||||
`).all(req.params.id);
|
||||
res.json({ permissions: updated });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
238
backend/upload-page.html
Normal file
@@ -0,0 +1,238 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Datei hochladen – DickenDock</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{background:#0d0d0f;color:#fff;font-family:'Courier New',monospace;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px}
|
||||
.card{background:#1a1a1e;border:1px solid rgba(255,255,255,0.1);border-radius:16px;padding:28px;width:100%;max-width:440px}
|
||||
h1{font-size:18px;margin-bottom:6px;color:#4ecdc4}
|
||||
.sub{color:rgba(255,255,255,0.4);font-size:12px;margin-bottom:20px}
|
||||
label{display:block;color:rgba(255,255,255,0.5);font-size:10px;letter-spacing:2px;margin-bottom:4px;margin-top:12px}
|
||||
input[type=password],input[type=file]{width:100%;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.12);border-radius:8px;padding:10px 12px;color:#fff;font-family:inherit;font-size:14px;outline:none;transition:border 0.2s}
|
||||
input[type=password]:focus{border-color:#4ecdc4}
|
||||
input[type=file]{cursor:pointer;padding:8px}
|
||||
input[type=file]::file-selector-button{background:#4ecdc4;border:none;border-radius:6px;color:#0d0d0f;cursor:pointer;font-family:inherit;font-weight:700;padding:4px 12px;margin-right:10px}
|
||||
.btn{width:100%;background:#4ecdc4;border:none;border-radius:8px;color:#0d0d0f;cursor:pointer;font-family:inherit;font-size:14px;font-weight:700;padding:12px;margin-top:12px;transition:opacity 0.2s}
|
||||
.btn:hover:not(:disabled){opacity:0.85}
|
||||
.btn:disabled{opacity:0.4;cursor:default}
|
||||
.info{background:rgba(78,205,196,0.08);border:1px solid rgba(78,205,196,0.2);border-radius:8px;padding:10px 12px;font-size:12px;color:rgba(255,255,255,0.6);margin-bottom:16px;margin-top:4px}
|
||||
.msg{border-radius:8px;padding:10px 12px;font-size:13px;margin-top:10px}
|
||||
.msg.ok{background:rgba(78,205,196,0.12);border:1px solid rgba(78,205,196,0.3);color:#4ecdc4}
|
||||
.msg.err{background:rgba(255,107,157,0.12);border:1px solid rgba(255,107,157,0.3);color:#ff6b9d}
|
||||
.msg.warn{background:rgba(255,230,109,0.12);border:1px solid rgba(255,230,109,0.3);color:#ffe66d}
|
||||
.hidden{display:none}
|
||||
.done-list{margin-top:10px;font-size:12px;color:rgba(255,255,255,0.5)}
|
||||
.done-list li{padding:2px 0;list-style:none}
|
||||
.done-list li::before{content:'✓ ';color:#4ecdc4}
|
||||
#progress{height:4px;background:rgba(78,205,196,0.15);border-radius:2px;margin-top:10px;overflow:hidden}
|
||||
#progress-bar{height:100%;background:#4ecdc4;width:0;transition:width 0.15s}
|
||||
#progress-label{font-size:11px;color:rgba(255,255,255,0.4);margin-top:4px;font-family:monospace;min-height:14px}
|
||||
.attempts{color:#ffe66d;font-size:11px;margin-top:6px;font-family:monospace}
|
||||
#drop-zone{border:2px dashed rgba(255,255,255,0.15);border-radius:10px;padding:18px 12px;text-align:center;
|
||||
color:rgba(255,255,255,0.45);font-size:12px;cursor:pointer;transition:all 0.15s;margin-bottom:4px}
|
||||
#drop-zone.drag{border-color:#4ecdc4;background:rgba(78,205,196,0.08);color:#4ecdc4}
|
||||
#file-input{display:none}
|
||||
#file-label{display:block;font-size:12px;color:rgba(255,255,255,0.55);margin-top:6px;word-break:break-all}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>📤 Datei hochladen</h1>
|
||||
<p class="sub" id="sub">Dateien senden</p>
|
||||
|
||||
<div id="pw-section">
|
||||
<div class="info" id="share-info">⏳ Link wird geprüft…</div>
|
||||
<label>PASSWORT</label>
|
||||
<input type="password" id="pw" placeholder="Passwort eingeben" autocomplete="off" onkeydown="if(event.key==='Enter')verify()"/>
|
||||
<button class="btn" id="pw-btn" onclick="verify()">Zugang bestätigen</button>
|
||||
<div id="pw-msg" class="hidden"></div>
|
||||
<div id="attempts-info" class="attempts hidden"></div>
|
||||
</div>
|
||||
|
||||
<div id="upload-section" class="hidden">
|
||||
<div class="info" id="upload-info"></div>
|
||||
<label>DATEI(EN) AUSWÄHLEN</label>
|
||||
<div id="drop-zone">📁 Dateien hierher ziehen oder klicken</div>
|
||||
<div id="file-label"></div>
|
||||
<input type="file" id="file-input" multiple/>
|
||||
<div id="progress"><div id="progress-bar"></div></div>
|
||||
<div id="progress-label"></div>
|
||||
<button class="btn" id="upload-btn" onclick="uploadFiles()">⬆ Hochladen</button>
|
||||
<div id="upload-msg" class="hidden"></div>
|
||||
<ul class="done-list" id="done-list"></ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const token = location.pathname.split('/u/')[1] || '';
|
||||
let password = '';
|
||||
let maxMb = 10;
|
||||
|
||||
async function init() {
|
||||
if (!token) return showFatal('Ungültiger Link-Aufbau');
|
||||
try {
|
||||
const r = await fetch('/api/upload-shares/public/' + encodeURIComponent(token));
|
||||
const d = await r.json();
|
||||
if (!r.ok) return showFatal(d.error || 'Link ungültig');
|
||||
maxMb = d.max_size_mb;
|
||||
const exp = new Date(d.expires_at).toLocaleString('de-DE',{timeZone:'Europe/Berlin',day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'});
|
||||
document.getElementById('share-info').textContent = '🔐 Gültig bis ' + exp + ' · max ' + d.max_size_mb + ' MB pro Datei';
|
||||
} catch(e) {
|
||||
showFatal('Verbindung fehlgeschlagen: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function showFatal(msg) {
|
||||
document.getElementById('pw-section').innerHTML = '<div class="msg err">' + msg + '</div>';
|
||||
}
|
||||
|
||||
async function verify() {
|
||||
const pw = document.getElementById('pw').value;
|
||||
if (!pw) { document.getElementById('pw').focus(); return; }
|
||||
const btn = document.getElementById('pw-btn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '…';
|
||||
const msg = document.getElementById('pw-msg');
|
||||
try {
|
||||
const r = await fetch('/api/upload-shares/public/' + encodeURIComponent(token) + '/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: pw })
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!r.ok) {
|
||||
msg.className = 'msg err';
|
||||
msg.textContent = d.error;
|
||||
msg.classList.remove('hidden');
|
||||
if (d.locked) {
|
||||
showFatal(d.error);
|
||||
return;
|
||||
}
|
||||
if (d.attempts_left !== undefined) {
|
||||
const ai = document.getElementById('attempts-info');
|
||||
ai.textContent = '⚠ Noch ' + d.attempts_left + ' Versuch' + (d.attempts_left===1?'':'e') + ' übrig';
|
||||
ai.classList.remove('hidden');
|
||||
}
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Zugang bestätigen';
|
||||
return;
|
||||
}
|
||||
password = pw;
|
||||
maxMb = d.max_size_mb;
|
||||
document.getElementById('pw-section').classList.add('hidden');
|
||||
const us = document.getElementById('upload-section');
|
||||
us.classList.remove('hidden');
|
||||
const exp = new Date(d.expires_at).toLocaleString('de-DE',{timeZone:'Europe/Berlin',day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'});
|
||||
document.getElementById('upload-info').textContent = '✓ Zugang bestätigt · Gültig bis ' + exp + ' · max ' + d.max_size_mb + ' MB pro Datei';
|
||||
document.getElementById('sub').textContent = 'Bereit zum Hochladen';
|
||||
} catch(e) {
|
||||
msg.className = 'msg err';
|
||||
msg.textContent = 'Verbindung fehlgeschlagen: ' + e.message;
|
||||
msg.classList.remove('hidden');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Zugang bestätigen';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Drag & Drop ──────────────────────────────────────────────────────────────
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
const fileInput = document.getElementById('file-input');
|
||||
|
||||
dropZone.addEventListener('click', () => fileInput.click());
|
||||
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag'); });
|
||||
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag'));
|
||||
dropZone.addEventListener('drop', e => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('drag');
|
||||
if (e.dataTransfer.files.length) {
|
||||
fileInput.files = e.dataTransfer.files;
|
||||
updateFileLabel();
|
||||
}
|
||||
});
|
||||
fileInput.addEventListener('change', updateFileLabel);
|
||||
|
||||
function updateFileLabel() {
|
||||
const files = fileInput.files;
|
||||
const label = document.getElementById('file-label');
|
||||
if (!files.length) { label.textContent = ''; return; }
|
||||
label.textContent = files.length === 1
|
||||
? files[0].name
|
||||
: files.length + ' Dateien ausgewählt';
|
||||
}
|
||||
|
||||
// ── Upload mit echtem Byte-Fortschritt (XMLHttpRequest statt fetch,
|
||||
// da fetch() keine Upload-Progress-Events liefert) ─────────────────────────
|
||||
function uploadOne(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/api/upload-shares/public/' + encodeURIComponent(token) + '/upload');
|
||||
xhr.setRequestHeader('x-upload-password', password);
|
||||
xhr.upload.onprogress = e => {
|
||||
if (e.lengthComputable) setProgress(e.loaded / e.total * 100, file.name);
|
||||
};
|
||||
xhr.onload = () => {
|
||||
let d = {};
|
||||
try { d = JSON.parse(xhr.responseText); } catch {}
|
||||
if (xhr.status >= 200 && xhr.status < 300) resolve(d);
|
||||
else reject(new Error(d.error || 'Upload fehlgeschlagen'));
|
||||
};
|
||||
xhr.onerror = () => reject(new Error('Verbindung fehlgeschlagen'));
|
||||
xhr.send(fd);
|
||||
});
|
||||
}
|
||||
|
||||
function setProgress(percent, label) {
|
||||
document.getElementById('progress-bar').style.width = percent + '%';
|
||||
document.getElementById('progress-label').textContent = label ? Math.round(percent) + '% · ' + label : '';
|
||||
}
|
||||
|
||||
async function uploadFiles() {
|
||||
const files = document.getElementById('file-input').files;
|
||||
if (!files.length) { document.getElementById('file-input').click(); return; }
|
||||
const btn = document.getElementById('upload-btn');
|
||||
btn.disabled = true;
|
||||
const msg = document.getElementById('upload-msg');
|
||||
const list = document.getElementById('done-list');
|
||||
msg.classList.add('hidden');
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
if (file.size > maxMb * 1024 * 1024) {
|
||||
msg.className = 'msg warn';
|
||||
msg.textContent = '"' + file.name + '" ist zu groß (max ' + maxMb + ' MB)';
|
||||
msg.classList.remove('hidden');
|
||||
continue;
|
||||
}
|
||||
setProgress(0, file.name);
|
||||
try {
|
||||
await uploadOne(file);
|
||||
setProgress(100, file.name);
|
||||
const li = document.createElement('li');
|
||||
li.textContent = file.name;
|
||||
list.appendChild(li);
|
||||
} catch(e) {
|
||||
msg.className = 'msg err';
|
||||
msg.textContent = file.name + ': ' + e.message;
|
||||
msg.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
setTimeout(() => setProgress(0, ''), 1200);
|
||||
if (list.children.length > 0) {
|
||||
msg.className = 'msg ok';
|
||||
msg.textContent = list.children.length + ' Datei' + (list.children.length !== 1 ? 'en' : '') + ' erfolgreich hochgeladen';
|
||||
msg.classList.remove('hidden');
|
||||
}
|
||||
btn.disabled = false;
|
||||
document.getElementById('file-input').value = '';
|
||||
updateFileLabel();
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,14 +1,16 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
dickendock:
|
||||
image: 192.168.1.26:3000/admin/dickendock:latest
|
||||
build:
|
||||
context: "http://192.168.1.26:3000/Dicken/dickendock.git#main"
|
||||
dockerfile: Dockerfile
|
||||
container_name: dickendock
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- JWT_SECRET=BITTE-AENDERN-langerZufallsstring
|
||||
- GITEA_URL=http://192.168.1.26:3000
|
||||
- GITEA_REPO=admin/dickendock
|
||||
- GITEA_REPO=Dicken/dickendock
|
||||
- TZ=Europe/Berlin
|
||||
volumes:
|
||||
- ./data:/data
|
||||
ports:
|
||||
|
||||
12
env.example
Normal file
@@ -0,0 +1,12 @@
|
||||
# Kopiere diese Datei zu .env und passe die Werte an
|
||||
# .env wird NICHT ins Git eingecheckt
|
||||
|
||||
JWT_SECRET=ersetze-mich-mit-einem-langen-zufaelligen-string
|
||||
|
||||
# Home Assistant / Mosquitto MQTT (optional — ohne MQTT_HOST bleibt die
|
||||
# Integration deaktiviert, der Rest der App läuft unverändert)
|
||||
MQTT_HOST=192.168.1.222
|
||||
MQTT_PORT=1883
|
||||
MQTT_USERNAME=dickendock
|
||||
MQTT_PASSWORD=ersetze-mich
|
||||
MQTT_BASE_TOPIC=dickendock
|
||||
106
frontend/generate-icons.mjs
Normal file
@@ -0,0 +1,106 @@
|
||||
// Generates icon-192.png and icon-512.png without external dependencies
|
||||
// Uses a minimal PNG encoder written in pure JS
|
||||
import { writeFileSync } from 'fs';
|
||||
import { deflateSync } from 'zlib';
|
||||
|
||||
function createPNG(size) {
|
||||
const r = Math.round(size * 0.22);
|
||||
|
||||
// Draw pixels
|
||||
const pixels = new Uint8Array(size * size * 4);
|
||||
|
||||
const setPixel = (x, y, R, G, B, A = 255) => {
|
||||
if (x < 0 || x >= size || y < 0 || y >= size) return;
|
||||
const i = (y * size + x) * 4;
|
||||
pixels[i] = R; pixels[i+1] = G; pixels[i+2] = B; pixels[i+3] = A;
|
||||
};
|
||||
|
||||
// Gradient background with rounded corners
|
||||
for (let y = 0; y < size; y++) {
|
||||
for (let x = 0; x < size; x++) {
|
||||
// Rounded corner check
|
||||
const inCornerTL = x < r && y < r && (x-r)**2 + (y-r)**2 > r**2;
|
||||
const inCornerTR = x >= size-r && y < r && (x-(size-r))**2 + (y-r)**2 > r**2;
|
||||
const inCornerBL = x < r && y >= size-r && (x-r)**2 + (y-(size-r))**2 > r**2;
|
||||
const inCornerBR = x >= size-r && y >= size-r && (x-(size-r))**2 + (y-(size-r))**2 > r**2;
|
||||
if (inCornerTL || inCornerTR || inCornerBL || inCornerBR) continue;
|
||||
|
||||
// Linear gradient: top-left #4ecdc4 → bottom-right #ff6b9d
|
||||
const t = (x + y) / (size * 2);
|
||||
const R = Math.round(0x4e + (0xff - 0x4e) * t);
|
||||
const G = Math.round(0xcd + (0x6b - 0xcd) * t);
|
||||
const B = Math.round(0xc4 + (0x9d - 0xc4) * t);
|
||||
setPixel(x, y, R, G, B);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw ⚒ as a simple hammer shape (dark color on gradient)
|
||||
const cx = Math.floor(size / 2);
|
||||
const cy = Math.floor(size / 2);
|
||||
const s = Math.floor(size * 0.18);
|
||||
|
||||
// Hammer head (rectangle)
|
||||
for (let dy = -s; dy <= 0; dy++) {
|
||||
for (let dx = -s; dx <= s; dx++) {
|
||||
setPixel(cx + dx, cy + dy, 13, 13, 15);
|
||||
}
|
||||
}
|
||||
// Handle
|
||||
for (let dy = 0; dy <= s * 2; dy++) {
|
||||
for (let dx = -Math.floor(s*0.25); dx <= Math.floor(s*0.25); dx++) {
|
||||
setPixel(cx + dx, cy + dy, 13, 13, 15);
|
||||
}
|
||||
}
|
||||
|
||||
// Encode as PNG
|
||||
const width = size, height = size;
|
||||
const signature = Buffer.from([137,80,78,71,13,10,26,10]);
|
||||
|
||||
// IHDR
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4);
|
||||
ihdr[8] = 8; ihdr[9] = 2; // 8-bit RGB... wait we need RGBA
|
||||
// Use RGBA (color type 6)
|
||||
ihdr[9] = 6;
|
||||
const ihdrChunk = makeChunk('IHDR', ihdr);
|
||||
|
||||
// IDAT - raw image data with filter bytes
|
||||
const raw = Buffer.alloc(height * (1 + width * 4));
|
||||
for (let y = 0; y < height; y++) {
|
||||
raw[y * (1 + width * 4)] = 0; // filter type None
|
||||
for (let x = 0; x < width; x++) {
|
||||
const si = (y * width + x) * 4;
|
||||
const di = y * (1 + width * 4) + 1 + x * 4;
|
||||
raw[di] = pixels[si];
|
||||
raw[di+1] = pixels[si+1];
|
||||
raw[di+2] = pixels[si+2];
|
||||
raw[di+3] = pixels[si+3] || (pixels[si] || pixels[si+1] || pixels[si+2] ? 255 : 0);
|
||||
}
|
||||
}
|
||||
const compressed = deflateSync(raw);
|
||||
const idatChunk = makeChunk('IDAT', compressed);
|
||||
const iendChunk = makeChunk('IEND', Buffer.alloc(0));
|
||||
|
||||
return Buffer.concat([signature, ihdrChunk, idatChunk, iendChunk]);
|
||||
}
|
||||
|
||||
function makeChunk(type, data) {
|
||||
const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
|
||||
const typeB = Buffer.from(type);
|
||||
const crc = crc32(Buffer.concat([typeB, data]));
|
||||
const crcB = Buffer.alloc(4); crcB.writeUInt32BE(crc >>> 0);
|
||||
return Buffer.concat([len, typeB, data, crcB]);
|
||||
}
|
||||
|
||||
function crc32(buf) {
|
||||
let crc = 0xFFFFFFFF;
|
||||
for (const b of buf) {
|
||||
crc ^= b;
|
||||
for (let i = 0; i < 8; i++) crc = (crc >>> 1) ^ (crc & 1 ? 0xEDB88320 : 0);
|
||||
}
|
||||
return (crc ^ 0xFFFFFFFF) >>> 0;
|
||||
}
|
||||
|
||||
writeFileSync('public/icon-192.png', createPNG(192));
|
||||
writeFileSync('public/icon-512.png', createPNG(512));
|
||||
console.log('Icons generated.');
|
||||
@@ -1,8 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0d0d0f"><style>html,body{background:#0d0d0f;margin:0}</style>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||
<meta name="theme-color" content="#0d0d0f" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Dicken Dock" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="apple-touch-icon" href="/icon-192.png" />
|
||||
<title>Dicken Dock</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
"name": "dickendock-frontend",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"prebuild": "node generate-icons.mjs",
|
||||
"dev": "vite",
|
||||
"build": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
"react-dom": "^18.2.0",
|
||||
"recharts": "^2.12.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
|
||||
10
frontend/public/favicon.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#4ecdc4"/>
|
||||
<stop offset="100%" stop-color="#ff6b9d"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="32" height="32" rx="7" fill="url(#g)"/>
|
||||
<text x="16" y="22" text-anchor="middle" font-size="18" fill="#0d0d0f">⚒</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 408 B |
BIN
frontend/public/icon-192.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
frontend/public/icon-512.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
7
frontend/public/icon.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="80" fill="#0d0d0f"/>
|
||||
<rect x="40" y="40" width="432" height="432" rx="60" fill="#111114"/>
|
||||
<text x="256" y="200" text-anchor="middle" font-family="monospace" font-weight="bold" font-size="140" fill="#4ecdc4">DD</text>
|
||||
<text x="256" y="340" text-anchor="middle" font-family="monospace" font-size="52" fill="rgba(255,255,255,0.35)">DOCK</text>
|
||||
<rect x="100" y="370" width="312" height="3" rx="2" fill="#4ecdc4" opacity="0.4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 540 B |
BIN
frontend/public/koepi/kasten_033.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
frontend/public/koepi/kasten_050.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
frontend/public/koepi/kasten_lang.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
frontend/public/koepi/kasten_steini.png
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
frontend/public/koepi/traeger.png
Normal file
|
After Width: | Height: | Size: 129 KiB |
28
frontend/public/manifest.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "Dicken Dock",
|
||||
"short_name": "DickenDock",
|
||||
"description": "Dein 3D-Druck Verwaltungstool",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"background_color": "#0d0d0f",
|
||||
"theme_color": "#4ecdc4",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon.svg",
|
||||
"type": "image/svg+xml",
|
||||
"sizes": "any",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
]
|
||||
}
|
||||
55
frontend/public/sw.js
Normal file
@@ -0,0 +1,55 @@
|
||||
// Service Worker – Version wird zur Build-Zeit injiziert via Dockerfile
|
||||
// Dadurch erkennt der Browser bei jedem Deploy einen neuen SW → Cache-Purge
|
||||
const VERSION = '__BUILD_VERSION__'; // wird im Dockerfile ersetzt
|
||||
const CACHE_NAME = 'dickendock-' + VERSION;
|
||||
|
||||
self.addEventListener('install', () => {
|
||||
self.skipWaiting(); // neuen SW sofort übernehmen
|
||||
});
|
||||
|
||||
self.addEventListener('activate', e => {
|
||||
e.waitUntil((async () => {
|
||||
// alle alten Caches löschen
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)));
|
||||
await self.clients.claim();
|
||||
// allen offenen Clients sagen: neue Version aktiv → reload
|
||||
const clients = await self.clients.matchAll({ type: 'window' });
|
||||
clients.forEach(c => c.postMessage({ type: 'SW_ACTIVATED', version: VERSION }));
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', e => {
|
||||
const url = new URL(e.request.url);
|
||||
|
||||
// Cross-Origin-Requests nicht abfangen
|
||||
if (url.origin !== self.location.origin) return;
|
||||
|
||||
// API immer direkt ans Netz
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
e.respondWith(fetch(e.request));
|
||||
return;
|
||||
}
|
||||
|
||||
// sw.js selbst nie cachen
|
||||
if (url.pathname === '/sw.js') {
|
||||
e.respondWith(fetch(e.request, { cache: 'no-store' }));
|
||||
return;
|
||||
}
|
||||
|
||||
// HTML-Navigation: IMMER frisch vom Netzwerk
|
||||
if (e.request.mode === 'navigate') {
|
||||
e.respondWith(
|
||||
fetch(e.request, { cache: 'reload' })
|
||||
.catch(() => caches.match('/index.html'))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Same-Origin Assets: durchreichen
|
||||
e.respondWith(fetch(e.request).catch(() => caches.match(e.request)));
|
||||
});
|
||||
|
||||
self.addEventListener('message', e => {
|
||||
if (e.data === 'SKIP_WAITING') self.skipWaiting();
|
||||
});
|
||||
10
frontend/public/vite.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: { proxy: { '/api': 'http://localhost:4000' } },
|
||||
define: {
|
||||
__BUILD_TIME__: JSON.stringify(new Date().toISOString()),
|
||||
},
|
||||
})
|
||||
4163
frontend/src/App.jsx
401
frontend/src/calendar.jsx
Normal file
@@ -0,0 +1,401 @@
|
||||
// ── Kalender Widget mit iCal-Unterstützung ────────────────────────────────────
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { api, S } from './lib.js';
|
||||
|
||||
// iCal-Parser – unterstützt gefaltete Zeilen, TZID, VALUE=DATE
|
||||
function parseIcal(raw) {
|
||||
// 1. Gefaltete Zeilen (RFC 5545) zusammenführen
|
||||
const text = raw.replace(/\r\n[ \t]/g, '').replace(/\r/g, '');
|
||||
|
||||
// 2. Alle Properties als Map extrahieren
|
||||
function getProps(block) {
|
||||
// VALARM-Blöcke entfernen (haben eigene SUMMARY/DESCRIPTION die nicht zum Termin gehören)
|
||||
const stripped = block.replace(/BEGIN:VALARM[\s\S]*?END:VALARM/g, '');
|
||||
const props = {};
|
||||
for (const line of stripped.split('\n')) {
|
||||
const col = line.indexOf(':');
|
||||
if (col < 0) continue;
|
||||
const namepart = line.slice(0, col).toUpperCase();
|
||||
const val = line.slice(col + 1).trim();
|
||||
// Basisname ohne Parameter
|
||||
const name = namepart.split(';')[0];
|
||||
// TZID-Parameter extrahieren
|
||||
const tzidM = namepart.match(/TZID=([^;:]+)/);
|
||||
props[name] = { val, raw: namepart, tzid: tzidM?.[1] || null };
|
||||
}
|
||||
return props;
|
||||
}
|
||||
|
||||
// 3. Datum parsen – Ortszeit wenn TZID gesetzt, UTC wenn Z-Suffix
|
||||
function parseDate(prop, isEnd) {
|
||||
if (!prop) return null;
|
||||
const s = prop.val;
|
||||
if (!s) return null;
|
||||
const allDay = s.length === 8 || prop.raw.includes('VALUE=DATE');
|
||||
if (allDay) {
|
||||
// DTEND bei all-day ist exklusiv → einen Tag abziehen
|
||||
const d = new Date(+s.slice(0,4), +s.slice(4,6)-1, +s.slice(6,8));
|
||||
if (isEnd) d.setDate(d.getDate() - 1);
|
||||
return { date: d, allDay: true };
|
||||
}
|
||||
const utc = s.endsWith('Z');
|
||||
const y=+s.slice(0,4), mo=+s.slice(4,6)-1, d=+s.slice(6,8);
|
||||
const h=+s.slice(9,11)||0, mi=+s.slice(11,13)||0, sec=+s.slice(13,15)||0;
|
||||
const date = utc
|
||||
? new Date(Date.UTC(y,mo,d,h,mi,sec))
|
||||
: new Date(y,mo,d,h,mi,sec); // Ortszeit des Browsers (beste Annäherung)
|
||||
return { date, allDay: false };
|
||||
}
|
||||
|
||||
const events = [];
|
||||
const blocks = text.split('BEGIN:VEVENT');
|
||||
for (let i = 1; i < blocks.length; i++) {
|
||||
const end = blocks[i].indexOf('END:VEVENT');
|
||||
const block = end >= 0 ? blocks[i].slice(0, end) : blocks[i];
|
||||
const p = getProps(block);
|
||||
|
||||
const startObj = parseDate(p['DTSTART']);
|
||||
const endObj = parseDate(p['DTEND'], true) || startObj;
|
||||
if (!startObj || isNaN(startObj.date)) continue;
|
||||
|
||||
events.push({
|
||||
summary: (p['SUMMARY']?.val || '(kein Titel)').replace(/\\n/g,'\n').replace(/\\,/g,','),
|
||||
location: (p['LOCATION']?.val || '').replace(/\\n/g,'\n').replace(/\\,/g,','),
|
||||
dtstart: startObj.date,
|
||||
dtend: endObj.date,
|
||||
allDay: startObj.allDay,
|
||||
});
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
function sameDay(a, b) {
|
||||
return a.getFullYear()===b.getFullYear() && a.getMonth()===b.getMonth() && a.getDate()===b.getDate();
|
||||
}
|
||||
|
||||
function inRange(event, day) {
|
||||
const d = new Date(day); d.setHours(0,0,0,0);
|
||||
const s = new Date(event.dtstart); s.setHours(0,0,0,0);
|
||||
const e = new Date(event.dtend); e.setHours(0,0,0,0);
|
||||
return d >= s && d <= e;
|
||||
}
|
||||
|
||||
const WEEKDAYS = ['Mo','Di','Mi','Do','Fr','Sa','So'];
|
||||
const MONTHS = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'];
|
||||
|
||||
export default function CalendarWidget() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [today] = useState(new Date());
|
||||
const [cur, setCur] = useState({ y: today.getFullYear(), m: today.getMonth() });
|
||||
const [events, setEvents] = useState([]);
|
||||
const [feeds, setFeeds] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selected, setSelected] = useState(new Date()); // default: heute
|
||||
|
||||
const [errors, setErrors] = useState([]);
|
||||
|
||||
// Load all feeds
|
||||
const loadFeeds = async (feedList) => {
|
||||
if (!feedList.length) { setEvents([]); setDebug(['Keine Feeds konfiguriert']); return; }
|
||||
setLoading(true);
|
||||
setErrors([]);
|
||||
const all = [];
|
||||
const errs = [];
|
||||
for (const f of feedList) {
|
||||
try {
|
||||
const res = await fetch(`/api/calendar/fetch?url=${encodeURIComponent(f.url)}`, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('sk_token')}` }
|
||||
});
|
||||
if (res.ok) {
|
||||
const text = await res.text();
|
||||
const evs = parseIcal(text).map(e => ({ ...e, color: f.color, feedName: f.name }));
|
||||
all.push(...evs);
|
||||
} else {
|
||||
const d = await res.json().catch(()=>({error:'Unbekannter Fehler'}));
|
||||
errs.push(`${f.name}: ${d.error}`);
|
||||
}
|
||||
} catch(e) { errs.push(`${f.name}: ${e.message}`); }
|
||||
}
|
||||
setEvents(all);
|
||||
setErrors(errs);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const refresh = () => {
|
||||
api('/calendar/feeds').then(f=>{ setFeeds(f); loadFeeds(f); }).catch(()=>{});
|
||||
// Kalender-Cache für Suche mitsynchronisieren
|
||||
api('/search/sync-calendar', { method:'POST', body:{ force:true } }).catch(()=>{});
|
||||
};
|
||||
// Load on mount so next event shows without opening
|
||||
useEffect(() => { refresh(); }, []);
|
||||
// Reload when opened
|
||||
useEffect(() => { if (isOpen) refresh(); }, [isOpen]);
|
||||
|
||||
// Nächster oder aktueller Termin (für zugeklappte Ansicht)
|
||||
const nextEvent = useMemo(() => {
|
||||
const now = new Date();
|
||||
const upcoming = events
|
||||
.filter(e => e.dtend >= now)
|
||||
.sort((a,b) => a.dtstart - b.dtstart);
|
||||
return upcoming[0] || null;
|
||||
}, [events]);
|
||||
|
||||
// Calendar grid
|
||||
const days = useMemo(() => {
|
||||
const first = new Date(cur.y, cur.m, 1);
|
||||
const dow = (first.getDay() + 6) % 7; // 0=Mo
|
||||
const total = new Date(cur.y, cur.m+1, 0).getDate();
|
||||
const grid = [];
|
||||
for (let i = 0; i < dow; i++) grid.push(null);
|
||||
for (let d = 1; d <= total; d++) grid.push(new Date(cur.y, cur.m, d));
|
||||
return grid;
|
||||
}, [cur]);
|
||||
|
||||
const eventsOnDay = (day) => day ? events.filter(e => inRange(e, day)) : [];
|
||||
const selectedEvents = selected ? events.filter(e => inRange(e, selected)) : [];
|
||||
|
||||
const prevMonth = () => setCur(p => p.m === 0 ? { y:p.y-1, m:11 } : { y:p.y, m:p.m-1 });
|
||||
const nextMonth = () => setCur(p => p.m === 11 ? { y:p.y+1, m:0 } : { y:p.y, m:p.m+1 });
|
||||
|
||||
return (
|
||||
<div style={{ ...S.card, marginBottom:10 }}>
|
||||
{/* Header */}
|
||||
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'center' }}>
|
||||
<button onClick={()=>setIsOpen(v=>!v)} style={{ flex:1, background:'transparent', border:'none',
|
||||
display:'flex', justifyContent:'space-between', alignItems:'center', cursor:'pointer', padding:0, minWidth:0 }}>
|
||||
<div style={{ display:'flex', alignItems:'center', gap:10, flex:1, minWidth:0 }}>
|
||||
<div style={{...S.head, marginBottom:0, flexShrink:0}}>KALENDER</div>
|
||||
{!isOpen && nextEvent && (
|
||||
<div style={{ display:'flex', alignItems:'center', gap:6, flex:1, minWidth:0, overflow:'hidden' }}>
|
||||
<span style={{ width:6, height:6, borderRadius:'50%', background:nextEvent.color||'#4ecdc4',
|
||||
flexShrink:0, display:'inline-block' }}/>
|
||||
<span style={{ color:'rgba(255,255,255,0.6)', fontFamily:'monospace', fontSize:10,
|
||||
overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
|
||||
{nextEvent.summary}
|
||||
</span>
|
||||
<span style={{ color:'rgba(255,255,255,0.35)', fontFamily:'monospace', fontSize:9, flexShrink:0 }}>
|
||||
{nextEvent.allDay
|
||||
? nextEvent.dtstart.toLocaleDateString('de-DE',{day:'numeric',month:'short'})
|
||||
: nextEvent.dtstart.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'})
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span style={{ color:'rgba(255,255,255,0.4)', fontSize:11, display:'inline-block', flexShrink:0,
|
||||
transform:isOpen?'rotate(90deg)':'rotate(0)', transition:'transform 0.2s' }}>▶</span>
|
||||
</button>
|
||||
{isOpen && feeds.length > 0 && (
|
||||
<button onClick={refresh} disabled={loading}
|
||||
style={{ background:'transparent', border:'none', color:'rgba(255,255,255,0.4)',
|
||||
cursor:'pointer', fontSize:13, padding:'0 0 0 8px', marginLeft:8 }}
|
||||
title="Neu laden">
|
||||
{loading ? '⏳' : '↻'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{isOpen && (
|
||||
<div style={{ marginTop:12 }}>
|
||||
{/* Fehler */}
|
||||
{errors.length > 0 && (
|
||||
<div style={{ marginBottom:10 }}>
|
||||
{errors.map((e,i) => (
|
||||
<div key={i} style={{ color:'#ff6b9d', fontFamily:'monospace', fontSize:10,
|
||||
background:'rgba(255,107,157,0.08)', borderRadius:6, padding:'4px 8px', marginBottom:3 }}>
|
||||
✕ {e}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Monat Nav */}
|
||||
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:10 }}>
|
||||
<button onClick={prevMonth} style={{ background:'transparent', border:'none', color:'rgba(255,255,255,0.5)',
|
||||
cursor:'pointer', fontSize:18, padding:'0 6px' }}>‹</button>
|
||||
<div style={{ display:'flex', alignItems:'center', gap:8 }}>
|
||||
<span style={{ color:'#fff', fontFamily:"'Space Mono',monospace", fontSize:13, fontWeight:700 }}>
|
||||
{MONTHS[cur.m]} {cur.y}
|
||||
</span>
|
||||
{(!selected || !sameDay(selected, today) || cur.m !== today.getMonth() || cur.y !== today.getFullYear()) && (
|
||||
<button onClick={() => { setCur({ y:today.getFullYear(), m:today.getMonth() }); setSelected(new Date()); }}
|
||||
style={{ background:'rgba(78,205,196,0.15)', border:'1px solid rgba(78,205,196,0.3)',
|
||||
borderRadius:6, color:'#4ecdc4', fontFamily:'monospace', fontSize:9,
|
||||
cursor:'pointer', padding:'2px 8px' }}>Heute</button>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={nextMonth} style={{ background:'transparent', border:'none', color:'rgba(255,255,255,0.5)',
|
||||
cursor:'pointer', fontSize:18, padding:'0 6px' }}>›</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: window.innerWidth>=768 ? 'flex' : 'block', gap:16, alignItems:'flex-start' }}>
|
||||
{/* Kalender-Grid */}
|
||||
<div style={{ flex:'0 0 auto', width: window.innerWidth>=768 ? 'min(300px,50%)' : '100%' }}>
|
||||
|
||||
{/* Wochentage */}
|
||||
<div style={{ display:'grid', gridTemplateColumns:'repeat(7,1fr)', gap:2, marginBottom:4 }}>
|
||||
{WEEKDAYS.map(d => (
|
||||
<div key={d} style={{ textAlign:'center', color:'rgba(255,255,255,0.4)', fontFamily:'monospace', fontSize:9 }}>{d}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tage */}
|
||||
<div style={{ display:'grid', gridTemplateColumns:'repeat(7,1fr)', gap:2 }}>
|
||||
{days.map((day, i) => {
|
||||
const evs = eventsOnDay(day);
|
||||
const isToday = day && sameDay(day, today);
|
||||
const isSel = day && selected && sameDay(day, selected);
|
||||
return (
|
||||
<button key={i} onClick={()=>day&&setSelected(isSel?null:day)} style={{
|
||||
aspectRatio:'1', borderRadius:6, border:'none', cursor:day?'pointer':'default',
|
||||
background: isSel ? 'rgba(78,205,196,0.25)' : isToday ? 'rgba(78,205,196,0.1)' : 'transparent',
|
||||
outline: isToday ? '1px solid rgba(78,205,196,0.4)' : 'none',
|
||||
position:'relative', padding:0, display:'flex', flexDirection:'column',
|
||||
alignItems:'center', justifyContent:'center',
|
||||
}}>
|
||||
{day && <>
|
||||
<span style={{ color: isSel?'#4ecdc4':isToday?'#4ecdc4':'rgba(255,255,255,0.75)',
|
||||
fontFamily:'monospace', fontSize:11, fontWeight:isToday?700:400 }}>
|
||||
{day.getDate()}
|
||||
</span>
|
||||
{evs.length > 0 && (
|
||||
<div style={{ display:'flex', gap:1, flexWrap:'wrap', justifyContent:'center', marginTop:1 }}>
|
||||
{evs.slice(0,3).map((e,j) => (
|
||||
<span key={j} style={{ width:4, height:4, borderRadius:'50%',
|
||||
background: e.color || '#4ecdc4', display:'inline-block' }}/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
|
||||
</div>{/* Ende Kalender-Grid */}
|
||||
|
||||
{/* Termine */}
|
||||
<div style={{ flex:1, minWidth:0, width:'100%', marginTop: window.innerWidth>=768 ? 0 : 10 }}>
|
||||
{/* Ausgewählter Tag Events */}
|
||||
{selected && (
|
||||
<div style={{ marginTop:10, borderTop:'1px solid rgba(255,255,255,0.07)', paddingTop:10 }}>
|
||||
<div style={{ color:'rgba(255,255,255,0.55)', fontFamily:'monospace', fontSize:10, marginBottom:6 }}>
|
||||
{selected.toLocaleDateString('de-DE',{weekday:'long',day:'numeric',month:'long'})}
|
||||
</div>
|
||||
{selectedEvents.length === 0
|
||||
? <div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:11 }}>Keine Termine</div>
|
||||
: selectedEvents.map((e,i) => (
|
||||
<div key={i} style={{ display:'flex', gap:8, padding:'6px 0',
|
||||
borderBottom:'1px solid rgba(255,255,255,0.05)' }}>
|
||||
<span style={{ width:3, borderRadius:2, background:e.color||'#4ecdc4', flexShrink:0 }}/>
|
||||
<div>
|
||||
<div style={{ color:'#fff', fontFamily:'monospace', fontSize:12 }}>{e.summary}</div>
|
||||
{e.location && <div style={{ color:'rgba(255,255,255,0.4)', fontFamily:'monospace', fontSize:10 }}>{e.location}</div>}
|
||||
{!e.allDay && <div style={{ color:'rgba(255,255,255,0.4)', fontFamily:'monospace', fontSize:10 }}>
|
||||
{e.dtstart.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'})}
|
||||
{' – '}{e.dtend.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'})}
|
||||
</div>}
|
||||
<div style={{ color:'rgba(255,255,255,0.25)', fontFamily:'monospace', fontSize:9 }}>{e.feedName}</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>{/* Ende Termine */}
|
||||
</div>{/* Ende Flex-Layout */}
|
||||
|
||||
{feeds.length === 0 && (
|
||||
<div style={{ marginTop:10, color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:11, textAlign:'center' }}>
|
||||
Noch keine Kalender abonniert. Füge Kalender in den Einstellungen hinzu.
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── iCal-Einstellungen (für Einstellungen-Seite) ──────────────────────────────
|
||||
export function CalendarSettings({ toast }) {
|
||||
const [feeds, setFeeds] = useState([]);
|
||||
const [form, setForm] = useState({ name:'', url:'', color:'#4ecdc4' });
|
||||
const [testing, setTesting] = useState(false);
|
||||
|
||||
useEffect(() => { api('/calendar/feeds').then(setFeeds).catch(()=>{}); }, []);
|
||||
|
||||
const add = async () => {
|
||||
if (!form.name.trim() || !form.url.trim()) { toast('Name und URL erforderlich', 'error'); return; }
|
||||
setTesting(true);
|
||||
try {
|
||||
const res = await fetch(`/api/calendar/fetch?url=${encodeURIComponent(form.url)}`, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('sk_token')}` }
|
||||
});
|
||||
if (!res.ok) { const d=await res.json(); toast(d.error||'Feed-Fehler','error'); return; }
|
||||
const newFeed = await api('/calendar/feeds', { body: { name:form.name.trim(), url:form.url.trim(), color:form.color } });
|
||||
setFeeds(p => [...p, newFeed]);
|
||||
setForm({ name:'', url:'', color:'#4ecdc4' });
|
||||
toast('Kalender hinzugefügt ✓');
|
||||
} catch(e) { toast(e.message,'error'); } finally { setTesting(false); }
|
||||
};
|
||||
|
||||
const remove = async (id) => {
|
||||
try { await api(`/calendar/feeds/${id}`, {method:'DELETE'}); setFeeds(p=>p.filter(f=>f.id!==id)); toast('Entfernt'); }
|
||||
catch(e) { toast(e.message,'error'); }
|
||||
};
|
||||
|
||||
const COLORS = ['#4ecdc4','#ff6b9d','#ffe66d','#6bcb77','#a78bfa','#60a5fa','#fb923c'];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom:12 }}>
|
||||
<label style={{ ...S.head, display:'block', marginBottom:4 }}>NAME</label>
|
||||
<input value={form.name} onChange={e=>setForm(f=>({...f,name:e.target.value}))}
|
||||
placeholder="z.B. Google Kalender" style={{ ...S.inp, fontSize:15, marginBottom:8 }}/>
|
||||
<label style={{ ...S.head, display:'block', marginBottom:4 }}>ICAL URL</label>
|
||||
<input value={form.url} onChange={e=>setForm(f=>({...f,url:e.target.value}))}
|
||||
placeholder="https://calendar.google.com/calendar/ical/…"
|
||||
style={{ ...S.inp, fontSize:13, marginBottom:8 }}
|
||||
autoCapitalize="none" autoCorrect="off"/>
|
||||
<label style={{ ...S.head, display:'block', marginBottom:6 }}>FARBE</label>
|
||||
<div style={{ display:'flex', gap:6, marginBottom:12 }}>
|
||||
{COLORS.map(col => (
|
||||
<button key={col} onClick={()=>setForm(f=>({...f,color:col}))} style={{
|
||||
width:28, height:28, borderRadius:'50%', background:col, border:'none', cursor:'pointer',
|
||||
outline: form.color===col ? '3px solid #fff' : '2px solid transparent',
|
||||
outlineOffset:2,
|
||||
}}/>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={add} disabled={testing} style={{ ...S.btn('#4ecdc4'), width:'100%', textAlign:'center', padding:'10px 0' }}>
|
||||
{testing ? '⏳ Teste Feed…' : '+ Kalender hinzufügen'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{feeds.length > 0 && (
|
||||
<div>
|
||||
<div style={{ ...S.head, marginBottom:6 }}>ABONNIERTE KALENDER</div>
|
||||
{feeds.map(f => (
|
||||
<div key={f.id} style={{ display:'flex', alignItems:'center', gap:10, padding:'8px 0',
|
||||
borderBottom:'1px solid rgba(255,255,255,0.05)' }}>
|
||||
<span style={{ width:12, height:12, borderRadius:'50%', background:f.color, flexShrink:0 }}/>
|
||||
<div style={{ flex:1, minWidth:0 }}>
|
||||
<div style={{ color:'#fff', fontFamily:'monospace', fontSize:12 }}>{f.name}</div>
|
||||
<div style={{ color:'rgba(255,255,255,0.4)', fontFamily:'monospace', fontSize:9,
|
||||
overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{f.url}</div>
|
||||
</div>
|
||||
<button onClick={()=>remove(f.id)} style={{ background:'transparent', border:'none',
|
||||
color:'rgba(255,107,157,0.7)', cursor:'pointer', fontSize:16, padding:'0 4px' }}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
frontend/src/confirm.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
// ── Globaler Bestätigungs-Dialog ─────────────────────────────────────────────
|
||||
// Verwendung: const { confirm, ConfirmDialog } = useConfirm();
|
||||
// await confirm('Wirklich löschen?') && deleteFn();
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export function useConfirm() {
|
||||
const [state, setState] = useState({ open:false, msg:'', resolve:null });
|
||||
|
||||
const confirm = useCallback((msg='Wirklich fortfahren?') => {
|
||||
return new Promise(resolve => {
|
||||
setState({ open:true, msg, resolve });
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handle = yes => {
|
||||
state.resolve?.(yes);
|
||||
setState({ open:false, msg:'', resolve:null });
|
||||
};
|
||||
|
||||
const ConfirmDialog = () => !state.open ? null : (
|
||||
<div style={{ position:'fixed', inset:0, background:'rgba(0,0,0,0.75)',
|
||||
zIndex:9000, display:'flex',
|
||||
alignItems: window.innerWidth>=768 ? 'center' : 'flex-end',
|
||||
justifyContent:'center', padding: window.innerWidth>=768 ? 24 : 0 }}
|
||||
onClick={() => handle(false)}>
|
||||
<div style={{ background:'#1a1a1e',
|
||||
borderRadius: window.innerWidth>=768 ? 16 : '16px 16px 0 0',
|
||||
width:'100%', maxWidth:500, padding:'20px 20px 32px',
|
||||
border:'1px solid rgba(255,255,255,0.15)',
|
||||
boxShadow: window.innerWidth>=768 ? '0 24px 80px rgba(0,0,0,0.6)' : 'none' }}
|
||||
onClick={e => e.stopPropagation()}>
|
||||
{window.innerWidth < 768 && <div style={{ width:36, height:4, background:'rgba(255,255,255,0.15)',
|
||||
borderRadius:2, margin:'0 auto 18px' }}/>}
|
||||
<p style={{ color:'#fff', fontFamily:'monospace', fontSize:14,
|
||||
textAlign:'center', marginBottom:20, lineHeight:1.6 }}>{state.msg}</p>
|
||||
<div style={{ display:'flex', gap:10 }}>
|
||||
<button onClick={() => handle(false)} style={{
|
||||
flex:1, padding:'12px 0', background:'rgba(255,255,255,0.06)',
|
||||
border:'1px solid rgba(255,255,255,0.1)', borderRadius:10,
|
||||
color:'rgba(255,255,255,0.7)', fontFamily:'monospace', fontSize:13, cursor:'pointer',
|
||||
}}>Abbrechen</button>
|
||||
<button onClick={() => handle(true)} style={{
|
||||
flex:1, padding:'12px 0', background:'rgba(255,107,157,0.15)',
|
||||
border:'1px solid rgba(255,107,157,0.4)', borderRadius:10,
|
||||
color:'#ff6b9d', fontFamily:'monospace', fontSize:13, cursor:'pointer', fontWeight:700,
|
||||
}}>Ja, löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return { confirm, ConfirmDialog };
|
||||
}
|
||||
216
frontend/src/crypto.js
Normal file
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* DickenDock E2E-Verschlüsselung
|
||||
*
|
||||
* Algorithmen:
|
||||
* X25519 → Schlüsselaustausch (WebCrypto; Chrome 113+/Firefox 130+/Safari 17.4+)
|
||||
* AES-256-GCM → Nachrichtenverschlüsselung
|
||||
* PBKDF2-SHA256 → Export-Passwortverschlüsselung (200k Iterationen)
|
||||
* ↳ statt Argon2id: kein WebCrypto-Support ohne WASM-Lib → keine externen Abhängigkeiten
|
||||
*
|
||||
* Private Key: ausschließlich in IndexedDB, niemals an den Server übertragen.
|
||||
*/
|
||||
|
||||
const IDB_NAME = 'dd_e2e_v1';
|
||||
const IDB_STORE = 'keys';
|
||||
const KEY_ID = 'identity';
|
||||
|
||||
// ── IndexedDB helpers ─────────────────────────────────────────────────────────
|
||||
function _openDB() {
|
||||
return new Promise((res, rej) => {
|
||||
const r = indexedDB.open(IDB_NAME, 1);
|
||||
r.onupgradeneeded = e => e.target.result.createObjectStore(IDB_STORE);
|
||||
r.onsuccess = e => res(e.target.result);
|
||||
r.onerror = () => rej(r.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function _idbGet(k) {
|
||||
const db = await _openDB();
|
||||
return new Promise((res, rej) => {
|
||||
const r = db.transaction(IDB_STORE, 'readonly').objectStore(IDB_STORE).get(k);
|
||||
r.onsuccess = () => res(r.result ?? null);
|
||||
r.onerror = () => rej(r.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function _idbSet(k, v) {
|
||||
const db = await _openDB();
|
||||
return new Promise((res, rej) => {
|
||||
const tx = db.transaction(IDB_STORE, 'readwrite');
|
||||
tx.objectStore(IDB_STORE).put(v, k);
|
||||
tx.oncomplete = () => res();
|
||||
tx.onerror = () => rej(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function _idbDel(k) {
|
||||
const db = await _openDB();
|
||||
return new Promise((res, rej) => {
|
||||
const tx = db.transaction(IDB_STORE, 'readwrite');
|
||||
tx.objectStore(IDB_STORE).delete(k);
|
||||
tx.oncomplete = () => res();
|
||||
tx.onerror = () => rej(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Base64 helpers ────────────────────────────────────────────────────────────
|
||||
const b64 = u8 => btoa(String.fromCharCode(...u8));
|
||||
const ub64 = s => Uint8Array.from(atob(s), c => c.charCodeAt(0));
|
||||
|
||||
// ── Internal ──────────────────────────────────────────────────────────────────
|
||||
async function _importJwkPair(pub, priv) {
|
||||
const publicKey = await crypto.subtle.importKey('jwk', pub, { name:'X25519' }, true, []);
|
||||
const privateKey = await crypto.subtle.importKey('jwk', priv, { name:'X25519' }, true, ['deriveKey']);
|
||||
return { publicKey, privateKey };
|
||||
}
|
||||
|
||||
async function _pbkdf2Key(password, salt) {
|
||||
const raw = await crypto.subtle.importKey(
|
||||
'raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveKey']
|
||||
);
|
||||
return crypto.subtle.deriveKey(
|
||||
{ name:'PBKDF2', salt, iterations:200_000, hash:'SHA-256' },
|
||||
raw,
|
||||
{ name:'AES-GCM', length:256 }, false, ['encrypt','decrypt']
|
||||
);
|
||||
}
|
||||
|
||||
// ── Keypair Management ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Lädt Keypair aus IndexedDB.
|
||||
* Migriert altes localStorage-Format (P-256) → löscht es, erzeugt neues X25519-Keypair.
|
||||
* Erstellt neues Keypair falls keins vorhanden.
|
||||
*/
|
||||
export async function getOrCreateKeyPair() {
|
||||
// Migration: altes P-256-Keypair aus localStorage entfernen
|
||||
if (localStorage.getItem('dd_msg_keypair')) {
|
||||
localStorage.removeItem('dd_msg_keypair');
|
||||
// Kein Import möglich (Algorithmuswechsel P-256→X25519), neues Keypair wird unten erzeugt
|
||||
}
|
||||
|
||||
const stored = await _idbGet(KEY_ID);
|
||||
if (stored) return stored;
|
||||
|
||||
// Neues X25519-Keypair erzeugen
|
||||
const kp = await crypto.subtle.generateKey({ name:'X25519' }, true, ['deriveKey']);
|
||||
await _idbSet(KEY_ID, kp);
|
||||
return kp;
|
||||
}
|
||||
|
||||
/** Neues Keypair erzeugen und in IndexedDB speichern (überschreibt vorhandenes). */
|
||||
export async function generateNewKeyPair() {
|
||||
const kp = await crypto.subtle.generateKey({ name:'X25519' }, true, ['deriveKey']);
|
||||
await _idbSet(KEY_ID, kp);
|
||||
return kp;
|
||||
}
|
||||
|
||||
/** Keypair aus IndexedDB löschen. */
|
||||
export async function clearStoredKeyPair() {
|
||||
await _idbDel(KEY_ID);
|
||||
}
|
||||
|
||||
/** Public Key als JWK exportieren (für Server-Registrierung). */
|
||||
export async function getPublicKeyJwk(kp) {
|
||||
return crypto.subtle.exportKey('jwk', kp.publicKey);
|
||||
}
|
||||
|
||||
// ── Fingerprint ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* SHA-256-Hash des Public Key, angezeigt als Kurzformat: ABCD-EF12-3456-CDEF
|
||||
* Kann manuell verglichen werden um Man-in-the-Middle zu erkennen.
|
||||
*/
|
||||
export async function getFingerprint(kp) {
|
||||
const jwk = await getPublicKeyJwk(kp);
|
||||
const enc = new TextEncoder().encode(JSON.stringify(jwk));
|
||||
const hash = await crypto.subtle.digest('SHA-256', enc);
|
||||
const hex = [...new Uint8Array(hash)]
|
||||
.map(b => b.toString(16).padStart(2, '0')).join('').toUpperCase();
|
||||
return `${hex.slice(0,4)}-${hex.slice(4,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}`;
|
||||
}
|
||||
|
||||
// ── Export / Import ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Keypair verschlüsselt als JSON-String exportieren.
|
||||
* Verschlüsselung: PBKDF2-SHA256 (200k) → AES-256-GCM
|
||||
*/
|
||||
export async function exportEncrypted(kp, password) {
|
||||
const pub = await crypto.subtle.exportKey('jwk', kp.publicKey);
|
||||
const priv = await crypto.subtle.exportKey('jwk', kp.privateKey);
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const enc = await crypto.subtle.encrypt(
|
||||
{ name:'AES-GCM', iv },
|
||||
await _pbkdf2Key(password, salt),
|
||||
new TextEncoder().encode(JSON.stringify({ pub, priv }))
|
||||
);
|
||||
return JSON.stringify({
|
||||
v: 1,
|
||||
alg: 'X25519+PBKDF2-SHA256-200k+AES-256-GCM',
|
||||
salt: b64(salt),
|
||||
iv: b64(iv),
|
||||
data: b64(new Uint8Array(enc)),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verschlüsseltes Keypair importieren, entschlüsseln und in IndexedDB speichern.
|
||||
* Wirft bei falschem Passwort oder korrupter Datei einen Fehler.
|
||||
*/
|
||||
export async function importEncrypted(jsonStr, password) {
|
||||
const obj = JSON.parse(jsonStr);
|
||||
if (obj.v !== 1) throw new Error('Unbekanntes Export-Format');
|
||||
let dec;
|
||||
try {
|
||||
dec = await crypto.subtle.decrypt(
|
||||
{ name:'AES-GCM', iv: ub64(obj.iv) },
|
||||
await _pbkdf2Key(password, ub64(obj.salt)),
|
||||
ub64(obj.data)
|
||||
);
|
||||
} catch {
|
||||
throw new Error('Entschlüsselung fehlgeschlagen – falsches Passwort?');
|
||||
}
|
||||
const { pub, priv } = JSON.parse(new TextDecoder().decode(dec));
|
||||
const kp = await _importJwkPair(pub, priv);
|
||||
await _idbSet(KEY_ID, kp);
|
||||
return kp;
|
||||
}
|
||||
|
||||
// ── Message Encryption ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Gemeinsamen AES-256-GCM-Schlüssel aus eigenem Private Key + Public Key des Partners ableiten.
|
||||
* Beide Seiten erhalten denselben Schlüssel (X25519-Eigenschaft).
|
||||
*/
|
||||
export async function deriveSharedKey(myPrivateKey, theirPubJwk) {
|
||||
const theirKey = await crypto.subtle.importKey('jwk', theirPubJwk, { name:'X25519' }, false, []);
|
||||
return crypto.subtle.deriveKey(
|
||||
{ name:'X25519', public: theirKey },
|
||||
myPrivateKey,
|
||||
{ name:'AES-GCM', length:256 }, false, ['encrypt','decrypt']
|
||||
);
|
||||
}
|
||||
|
||||
/** Nachricht mit AES-256-GCM verschlüsseln. */
|
||||
export async function encryptMsg(sharedKey, text) {
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const enc = await crypto.subtle.encrypt(
|
||||
{ name:'AES-GCM', iv }, sharedKey, new TextEncoder().encode(text)
|
||||
);
|
||||
return {
|
||||
encrypted_content: b64(new Uint8Array(enc)),
|
||||
iv: b64(iv),
|
||||
};
|
||||
}
|
||||
|
||||
/** Nachricht entschlüsseln. Wirft bei falschem Schlüssel/korrupten Daten. */
|
||||
export async function decryptMsg(sharedKey, ecB64, ivB64) {
|
||||
const dec = await crypto.subtle.decrypt(
|
||||
{ name:'AES-GCM', iv: ub64(ivB64) },
|
||||
sharedKey,
|
||||
ub64(ecB64)
|
||||
);
|
||||
return new TextDecoder().decode(dec);
|
||||
}
|
||||
219
frontend/src/icons.jsx
Normal file
@@ -0,0 +1,219 @@
|
||||
// ── Icon Library – stroke-basierte SVG Icons ──────────────────────────────────
|
||||
// Verwendung: <Icon name="home" size={20} color="#4ecdc4" />
|
||||
// Oder direkt: <HomeIcon size={20} color="#fff" />
|
||||
|
||||
const base = (path, size, color, sw = 1.75) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"
|
||||
stroke={color} strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
|
||||
{path}
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const HomeIcon = ({size=20,color='currentColor',sw}) => base(<>
|
||||
<path d="M3 9.5L12 3l9 6.5V20a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V9.5z"/>
|
||||
<path d="M9 21V12h6v9"/>
|
||||
</>, size, color, sw);
|
||||
|
||||
export const AppsIcon = ({size=20,color='currentColor',sw}) => base(<>
|
||||
<rect x="3" y="3" width="7" height="7" rx="1"/>
|
||||
<rect x="14" y="3" width="7" height="7" rx="1"/>
|
||||
<rect x="3" y="14" width="7" height="7" rx="1"/>
|
||||
<rect x="14" y="14" width="7" height="7" rx="1"/>
|
||||
</>, size, color, sw);
|
||||
|
||||
export const CalculatorIcon = ({size=20,color='currentColor',sw}) => base(<>
|
||||
<rect x="4" y="2" width="16" height="20" rx="2"/>
|
||||
<line x1="8" y1="7" x2="16" y2="7"/>
|
||||
<line x1="8" y1="12" x2="10" y2="12"/>
|
||||
<line x1="14" y1="12" x2="16" y2="12"/>
|
||||
<line x1="8" y1="17" x2="10" y2="17"/>
|
||||
<line x1="14" y1="17" x2="16" y2="17"/>
|
||||
</>, size, color, sw);
|
||||
|
||||
export const ArchiveIcon = ({size=20,color='currentColor',sw}) => base(<>
|
||||
<rect x="2" y="3" width="20" height="4" rx="1"/>
|
||||
<path d="M4 7v13a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1V7"/>
|
||||
<line x1="9" y1="12" x2="15" y2="12"/>
|
||||
</>, size, color, sw);
|
||||
|
||||
export const AdminIcon = ({size=20,color='currentColor',sw}) => base(<>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M12 2v2M12 20v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M2 12h2M20 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
|
||||
</>, size, color, sw);
|
||||
|
||||
export const MoreIcon = ({size=20,color='currentColor',sw}) => base(<>
|
||||
<circle cx="5" cy="12" r="1.2" fill={color}/>
|
||||
<circle cx="12" cy="12" r="1.2" fill={color}/>
|
||||
<circle cx="19" cy="12" r="1.2" fill={color}/>
|
||||
</>, size, color, sw);
|
||||
|
||||
export const UserIcon = ({size=20,color='currentColor',sw}) => base(<>
|
||||
<circle cx="12" cy="8" r="4"/>
|
||||
<path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/>
|
||||
</>, size, color, sw);
|
||||
|
||||
export const LogOutIcon = ({size=20,color='currentColor',sw}) => base(<>
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||
<polyline points="16 17 21 12 16 7"/>
|
||||
<line x1="21" y1="12" x2="9" y2="12"/>
|
||||
</>, size, color, sw);
|
||||
|
||||
export const SearchIcon = ({size=20,color='currentColor',sw}) => base(<>
|
||||
<circle cx="11" cy="11" r="7"/>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</>, size, color, sw);
|
||||
|
||||
export const PlusIcon = ({size=20,color='currentColor',sw}) => base(<>
|
||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</>, size, color, sw);
|
||||
|
||||
export const XIcon = ({size=20,color='currentColor',sw}) => base(<>
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</>, size, color, sw);
|
||||
|
||||
export const CheckIcon = ({size=20,color='currentColor',sw}) => base(<>
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</>, size, color, sw);
|
||||
|
||||
export const EditIcon = ({size=20,color='currentColor',sw}) => base(<>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||
</>, size, color, sw);
|
||||
|
||||
export const TrashIcon = ({size=20,color='currentColor',sw}) => base(<>
|
||||
<polyline points="3 6 5 6 21 6"/>
|
||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
|
||||
<path d="M10 11v6M14 11v6"/>
|
||||
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
|
||||
</>, size, color, sw);
|
||||
|
||||
export const DownloadIcon = ({size=20,color='currentColor',sw}) => base(<>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</>, size, color, sw);
|
||||
|
||||
export const UploadIcon = ({size=20,color='currentColor',sw}) => base(<>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="17 8 12 3 7 8"/>
|
||||
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</>, size, color, sw);
|
||||
|
||||
export const UpdateIcon = ({size=20,color='currentColor',sw}) => base(<>
|
||||
<circle cx="12" cy="12" r="9"/>
|
||||
<line x1="12" y1="8" x2="12" y2="12"/>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</>, size, color, sw);
|
||||
|
||||
export const ChevronIcon = ({size=20,color='currentColor',sw,dir='right'}) => base(<>
|
||||
{dir==='right' && <polyline points="9 18 15 12 9 6"/>}
|
||||
{dir==='down' && <polyline points="6 9 12 15 18 9"/>}
|
||||
{dir==='left' && <polyline points="15 18 9 12 15 6"/>}
|
||||
{dir==='up' && <polyline points="18 15 12 9 6 15"/>}
|
||||
</>, size, color, sw);
|
||||
|
||||
export const CameraIcon = ({size=20,color='currentColor',sw}) => base(<>
|
||||
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/>
|
||||
<circle cx="12" cy="13" r="4"/>
|
||||
</>, size, color, sw);
|
||||
|
||||
export const KeyIcon = ({size=20,color='currentColor',sw}) => base(<>
|
||||
<circle cx="8" cy="12" r="5"/>
|
||||
<path d="M13 12h8M17 10v4"/>
|
||||
</>, size, color, sw);
|
||||
|
||||
export const DatabaseIcon = ({size=20,color='currentColor',sw}) => base(<>
|
||||
<ellipse cx="12" cy="5" rx="9" ry="3"/>
|
||||
<path d="M3 5v4c0 1.66 4.03 3 9 3s9-1.34 9-3V5"/>
|
||||
<path d="M3 9v4c0 1.66 4.03 3 9 3s9-1.34 9-3V9"/>
|
||||
<path d="M3 13v4c0 1.66 4.03 3 9 3s9-1.34 9-3v-4"/>
|
||||
</>, size, color, sw);
|
||||
|
||||
export const ZapIcon = ({size=20,color='currentColor',sw}) => base(<>
|
||||
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>
|
||||
</>, size, color, sw);
|
||||
export const MessageIcon = ({size=20,color='currentColor',sw}) => base(<>
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinejoin="round" strokeLinecap="round"/>
|
||||
</>, size, color, sw);
|
||||
export const LinkIcon = ({size=20,color='currentColor',sw}) => base(<>
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</>, size, color, sw);
|
||||
export const CodeIcon = ({size=20,color='currentColor',sw}) => base(<>
|
||||
<polyline points="16 18 22 12 16 6" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<polyline points="8 6 2 12 8 18" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</>, size, color, sw);
|
||||
export const SkizzeIcon = ({size=20,color='currentColor',sw}) => base(<>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" stroke={color} strokeWidth={sw||1.5} fill="none"/>
|
||||
<path d="M3 9h18M9 21V9" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinecap="round"/>
|
||||
<path d="M13 13h5M13 17h3" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinecap="round"/>
|
||||
</>, size, color, sw);
|
||||
export const WrenchIcon = ({size=20,color='currentColor',sw}) => base(<>
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</>, size, color, sw);
|
||||
export const PaywallIcon = ({size=20,color='currentColor',sw}) => base(<>
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M7 11V7a5 5 0 0 1 9.9-1" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<line x1="8" y1="16" x2="10" y2="16" stroke={color} strokeWidth={sw||1.5} strokeLinecap="round"/>
|
||||
<line x1="14" y1="16" x2="16" y2="16" stroke={color} strokeWidth={sw||1.5} strokeLinecap="round"/>
|
||||
<line x1="11" y1="14" x2="13" y2="18" stroke={color} strokeWidth={sw||1.5} strokeLinecap="round"/>
|
||||
</>, size, color, sw);
|
||||
export const FilmIcon = ({size=20,color='currentColor',sw}) => base(<>
|
||||
<rect x="2" y="2" width="20" height="20" rx="2.18" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<line x1="7" y1="2" x2="7" y2="22" stroke={color} strokeWidth={sw||1.5} strokeLinecap="round"/>
|
||||
<line x1="17" y1="2" x2="17" y2="22" stroke={color} strokeWidth={sw||1.5} strokeLinecap="round"/>
|
||||
<line x1="2" y1="12" x2="22" y2="12" stroke={color} strokeWidth={sw||1.5} strokeLinecap="round"/>
|
||||
<line x1="2" y1="7" x2="7" y2="7" stroke={color} strokeWidth={sw||1.5} strokeLinecap="round"/>
|
||||
<line x1="2" y1="17" x2="7" y2="17" stroke={color} strokeWidth={sw||1.5} strokeLinecap="round"/>
|
||||
<line x1="17" y1="17" x2="22" y2="17" stroke={color} strokeWidth={sw||1.5} strokeLinecap="round"/>
|
||||
<line x1="17" y1="7" x2="22" y2="7" stroke={color} strokeWidth={sw||1.5} strokeLinecap="round"/>
|
||||
</>, size, color, sw);
|
||||
|
||||
export const ChartIcon = ({size=20,color='currentColor',sw}) => base(<>
|
||||
<rect x="3" y="12" width="4" height="8" rx="1" fill={color}/>
|
||||
<rect x="9" y="7" width="4" height="13" rx="1" fill={color}/>
|
||||
<rect x="15" y="3" width="4" height="17" rx="1" fill={color}/>
|
||||
</>, size, color, sw);
|
||||
export const KanbanIcon = ({size=20,color='currentColor',sw}) => base(<>
|
||||
<rect x="3" y="3" width="5" height="13" rx="1.5" stroke={color} strokeWidth={sw||1.5} fill="none"/>
|
||||
<rect x="10" y="3" width="5" height="8" rx="1.5" stroke={color} strokeWidth={sw||1.5} fill="none"/>
|
||||
<rect x="17" y="3" width="4" height="5" rx="1.5" stroke={color} strokeWidth={sw||1.5} fill="none"/>
|
||||
</>, size, color, sw);
|
||||
export const WhiteboardIcon = ({size=20,color='currentColor',sw}) => base(<>
|
||||
<rect x="3" y="3" width="18" height="14" rx="2" stroke={color} strokeWidth={sw||1.5} fill="none"/>
|
||||
<path d="M7 13 L10 9 L13 11 L16 7" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<line x1="3" y1="19" x2="21" y2="19" stroke={color} strokeWidth={sw||1.5} strokeLinecap="round"/>
|
||||
</>, size, color, sw);
|
||||
export const KoepiIcon = ({size=20,color='currentColor',sw}) => base(<>
|
||||
<path d="M5 3h14l-2 10H7L5 3z" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinejoin="round"/>
|
||||
<path d="M7 13c0 3 2 5 5 5s5-2 5-5" stroke={color} strokeWidth={sw||1.5} fill="none"/>
|
||||
<path d="M19 7h2v4h-2" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinejoin="round"/>
|
||||
</>, size, color, sw);
|
||||
|
||||
export const SchockenIcon = ({size=20,color='currentColor',sw}) => base(<>
|
||||
<circle cx="12" cy="12" r="9" stroke={color} strokeWidth={sw||1.5} fill="none"/>
|
||||
<circle cx="8.5" cy="9.5" r="1.2" fill={color}/>
|
||||
<circle cx="15.5" cy="9.5" r="1.2" fill={color}/>
|
||||
<circle cx="8.5" cy="14.5" r="1.2" fill={color}/>
|
||||
<circle cx="15.5" cy="14.5" r="1.2" fill={color}/>
|
||||
<circle cx="12" cy="12" r="1.2" fill={color}/>
|
||||
</>, size, color, sw);
|
||||
|
||||
export const GeoIcon = ({size=20,color='currentColor',sw}) => base(<>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" stroke={color} strokeWidth={sw||1.5} fill="none"/>
|
||||
<rect x="3" y="3" width="8" height="8" rx="1" fill={color} opacity="0.7"/>
|
||||
<rect x="13" y="13" width="8" height="8" rx="1" fill={color} opacity="0.4"/>
|
||||
<rect x="11" y="3" width="10" height="5" rx="1" stroke={color} strokeWidth={sw||1.2} fill="none"/>
|
||||
<rect x="3" y="11" width="5" height="10" rx="1" stroke={color} strokeWidth={sw||1.2} fill="none"/>
|
||||
</>, size, color, sw);
|
||||
|
||||
export const ScaleIcon = ({size=20,color='currentColor',sw}) => base(<>
|
||||
<path d="M12 3v18" stroke={color} strokeWidth={sw||1.5}/>
|
||||
<path d="M7 21h10" stroke={color} strokeWidth={sw||1.5}/>
|
||||
<path d="M5 7h14" stroke={color} strokeWidth={sw||1.5}/>
|
||||
<path d="M12 3l3 4" stroke={color} strokeWidth={sw||1.5}/>
|
||||
<path d="M2 12l3-5 3 5a3 3 0 0 1-6 0z" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinejoin="round"/>
|
||||
<path d="M16 12l3-5 3 5a3 3 0 0 1-6 0z" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinejoin="round"/>
|
||||
</>, size, color, sw);
|
||||
@@ -15,6 +15,35 @@ export const api = async (path, { body, method, isFile } = {}) => {
|
||||
return data;
|
||||
};
|
||||
|
||||
// ── Upload mit echtem Byte-Fortschritt ──────────────────────────────────────
|
||||
// fetch() liefert keinerlei Upload-Progress-Events, daher hier bewusst auf
|
||||
// XMLHttpRequest aufgebaut. Nur für Datei-Uploads gedacht (FormData-Body).
|
||||
// onProgress(percent: 0-100) wird während des Uploads laufend aufgerufen.
|
||||
export const apiUpload = (path, formData, { onProgress } = {}) => {
|
||||
const token = localStorage.getItem('sk_token');
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', `/api${path}`);
|
||||
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
|
||||
xhr.upload.onprogress = e => {
|
||||
if (e.lengthComputable && onProgress) {
|
||||
onProgress(Math.round((e.loaded / e.total) * 100));
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
let data = {};
|
||||
try { data = JSON.parse(xhr.responseText); } catch { /* leere/ungültige Antwort */ }
|
||||
if (xhr.status >= 200 && xhr.status < 300) resolve(data);
|
||||
else reject(new Error(data.error || 'Fehler'));
|
||||
};
|
||||
|
||||
xhr.onerror = () => reject(new Error('Verbindung fehlgeschlagen'));
|
||||
xhr.send(formData);
|
||||
});
|
||||
};
|
||||
|
||||
// ── Design Tokens ─────────────────────────────────────────────────────────────
|
||||
// Einheitliche Styles für alle Tools – in jedem Tool importierbar
|
||||
export const S = {
|
||||
@@ -40,7 +69,7 @@ export const S = {
|
||||
},
|
||||
// Abschnitts-Überschrift
|
||||
head: {
|
||||
color: 'rgba(255,255,255,0.3)', fontSize: 10,
|
||||
color: 'rgba(255,255,255,0.6)', fontSize: 10,
|
||||
fontFamily: 'monospace', letterSpacing: 2, marginBottom: 14,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,6 +2,100 @@ import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
|
||||
// ── Auto-Reload bei neuem Build ───────────────────────────────────────────────
|
||||
// __BUILD_TIME__ wird von Vite zur Build-Zeit aus version.txt gebacken.
|
||||
// /api/build-time liefert denselben Wert vom Server.
|
||||
// Wenn sie abweichen → neues Build wurde deployed → Reload.
|
||||
// Kein localStorage, keine Race-Condition.
|
||||
const MY_BUILD_TIME = __BUILD_TIME__;
|
||||
window.__newBuildAvailable = false;
|
||||
|
||||
function isUserTyping() {
|
||||
const el = document.activeElement;
|
||||
if (!el) return false;
|
||||
const tag = el.tagName;
|
||||
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable;
|
||||
}
|
||||
|
||||
function hasUnsavedInput() {
|
||||
const fields = document.querySelectorAll('input, textarea');
|
||||
for (const f of fields) {
|
||||
const isText = f.tagName === 'TEXTAREA' ||
|
||||
['text','search','url','email','number','password','tel'].includes(f.type);
|
||||
if (isText && f.value && f.value.trim() !== '') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function getBuildTime() {
|
||||
try {
|
||||
const r = await fetch('/api/build-time', { cache: 'no-store' });
|
||||
const { buildTime } = await r.json();
|
||||
return buildTime;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// Prüft ob Server-Build != Bundle-Build → Flag setzen
|
||||
// reloadNow: sofort laden wenn kein Input aktiv (für App-Fokus)
|
||||
async function checkBuild({ reloadNow = false } = {}) {
|
||||
const serverBt = await getBuildTime();
|
||||
if (!serverBt) return;
|
||||
if (serverBt !== MY_BUILD_TIME) {
|
||||
window.__newBuildAvailable = true;
|
||||
if (reloadNow && !isUserTyping() && !hasUnsavedInput()) {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sicherer Reload bei Seitenwechsel (aufgerufen aus App.jsx setActive)
|
||||
window.__safeReloadIfNewBuild = function () {
|
||||
if (!window.__newBuildAvailable) return false;
|
||||
if (isUserTyping() || hasUnsavedInput()) return false;
|
||||
window.location.reload();
|
||||
return true;
|
||||
};
|
||||
|
||||
// Beim Start prüfen – nach 3s sofort reloaden wenn neue Version (App hat Zeit zu rendern)
|
||||
checkBuild().then(() => {
|
||||
if (window.__newBuildAvailable && !isUserTyping() && !hasUnsavedInput()) {
|
||||
setTimeout(() => {
|
||||
if (!isUserTyping() && !hasUnsavedInput()) window.location.reload();
|
||||
}, 3000);
|
||||
}
|
||||
});
|
||||
// Im Hintergrund alle 60s
|
||||
setInterval(() => checkBuild(), 60_000);
|
||||
|
||||
// App kommt in den Vordergrund → prüfen und bei Bedarf sofort laden
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
checkBuild({ reloadNow: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Service Worker ────────────────────────────────────────────────────────────
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.addEventListener('message', (e) => {
|
||||
// SW hat neue Version aktiviert → sofort reloaden
|
||||
if (e.data?.type === 'SW_ACTIVATED') {
|
||||
window.__newBuildAvailable = true;
|
||||
if (!isUserTyping() && !hasUnsavedInput()) window.location.reload();
|
||||
}
|
||||
});
|
||||
|
||||
navigator.serviceWorker.register('/sw.js').then(reg => {
|
||||
setInterval(() => reg.update().catch(()=>{}), 60_000);
|
||||
if (reg.waiting) reg.waiting.postMessage('SKIP_WAITING');
|
||||
reg.addEventListener('updatefound', () => {
|
||||
const nw = reg.installing;
|
||||
nw?.addEventListener('statechange', () => {
|
||||
if (nw.state === 'installed') nw.postMessage('SKIP_WAITING');
|
||||
});
|
||||
});
|
||||
}).catch(()=>{});
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode><App /></React.StrictMode>
|
||||
)
|
||||
|
||||
@@ -1,26 +1,178 @@
|
||||
import Kalkulator3D, { SavedList } from './tools/Kalkulator3D.jsx';
|
||||
import Kalkulator3D, { SavedList } from './tools/kalkulator3d.jsx';
|
||||
import Bestellungen from './tools/bestellungen.jsx';
|
||||
import Statistik from './tools/statistik.jsx';
|
||||
import Dateien from './tools/dateien.jsx';
|
||||
import Nachrichten from './tools/nachrichten.jsx';
|
||||
import Linkliste from './tools/linkliste.jsx';
|
||||
import CodeSchnipsel from './tools/codeschnipsel.jsx';
|
||||
import Skizze from './tools/skizze.jsx';
|
||||
import DevTools from './tools/devtools.jsx';
|
||||
import Media from './tools/media.jsx';
|
||||
import PaywallKiller from './tools/paywallkiller.jsx';
|
||||
import Kanban from './tools/kanban.jsx';
|
||||
import Whiteboard from './tools/whiteboard.jsx';
|
||||
import Gebietseroberung from './tools/gebietseroberung.jsx';
|
||||
import Schocken from './tools/schocken.jsx';
|
||||
import Koepi from './tools/koepi.jsx';
|
||||
import Wettrechner from './tools/wettrechner.jsx';
|
||||
import { CalculatorIcon, ArchiveIcon, DatabaseIcon, AppsIcon, MessageIcon, LinkIcon, CodeIcon, SkizzeIcon, WrenchIcon, FilmIcon, PaywallIcon, ChartIcon, KanbanIcon, WhiteboardIcon, GeoIcon, SchockenIcon, KoepiIcon, ScaleIcon } from './icons.jsx';
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// Tool-Registry – hier neue Tools eintragen
|
||||
//
|
||||
// Neues Tool hinzufügen:
|
||||
// 1. Backend: src/tools/<name>/routes.js anlegen → wird automatisch geladen
|
||||
// 2. Frontend: src/tools/<Name>.jsx anlegen
|
||||
// 3. Hier einen Eintrag ergänzen
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
export const TOOLS = [
|
||||
{
|
||||
id: 'kalkulator3d',
|
||||
icon: '◈',
|
||||
label: '3D-Kalkulator',
|
||||
Icon: CalculatorIcon,
|
||||
label: 'Kostenrechner',
|
||||
navLabel: 'Kosten',
|
||||
group: '3D-Druck',
|
||||
component: Kalkulator3D,
|
||||
},
|
||||
{
|
||||
id: 'kalkulator3d-saved',
|
||||
icon: '◉',
|
||||
label: 'Gespeichert',
|
||||
Icon: ArchiveIcon,
|
||||
label: 'Archiv',
|
||||
navLabel: 'Archiv',
|
||||
group: '3D-Druck',
|
||||
component: SavedList,
|
||||
},
|
||||
// Nächstes Tool:
|
||||
// { id: 'newtool', icon: '◇', label: 'Mein Tool', component: MeinTool },
|
||||
{
|
||||
id: 'bestellungen',
|
||||
Icon: DatabaseIcon,
|
||||
label: 'Bestellungen',
|
||||
navLabel: 'Aufträge',
|
||||
group: '3D-Druck',
|
||||
component: Bestellungen,
|
||||
},
|
||||
{
|
||||
id: 'statistik3d',
|
||||
Icon: ChartIcon,
|
||||
label: 'Statistik',
|
||||
navLabel: 'Statistik',
|
||||
group: '3D-Druck',
|
||||
component: Statistik,
|
||||
},
|
||||
{
|
||||
id: 'kanban',
|
||||
Icon: KanbanIcon,
|
||||
label: 'Kanban',
|
||||
navLabel: 'Kanban',
|
||||
group: 'Werkzeuge',
|
||||
component: Kanban,
|
||||
},
|
||||
{
|
||||
id: 'whiteboard',
|
||||
Icon: WhiteboardIcon,
|
||||
label: 'Whiteboard',
|
||||
navLabel: 'Whiteboard',
|
||||
group: 'Werkzeuge',
|
||||
component: Whiteboard,
|
||||
},
|
||||
{
|
||||
id: 'gebietseroberung',
|
||||
Icon: GeoIcon,
|
||||
label: 'Hex Wars',
|
||||
navLabel: 'Hex Wars',
|
||||
group: 'Freizeit',
|
||||
component: Gebietseroberung,
|
||||
},
|
||||
{
|
||||
id: 'schocken',
|
||||
Icon: SchockenIcon,
|
||||
label: 'Schocken',
|
||||
navLabel: 'Schocken',
|
||||
group: 'Freizeit',
|
||||
component: Schocken,
|
||||
},
|
||||
{
|
||||
id: 'koepi',
|
||||
Icon: KoepiIcon,
|
||||
label: 'KöPi',
|
||||
navLabel: 'KöPi',
|
||||
group: 'Freizeit',
|
||||
component: Koepi,
|
||||
},
|
||||
{
|
||||
id: 'wettrechner',
|
||||
Icon: ScaleIcon,
|
||||
label: 'Wettrechner',
|
||||
navLabel: 'Wetten',
|
||||
group: 'Freizeit',
|
||||
component: Wettrechner,
|
||||
},
|
||||
{
|
||||
id: 'dateien',
|
||||
Icon: DatabaseIcon,
|
||||
label: 'Dateien',
|
||||
navLabel: 'Dateien',
|
||||
group: 'Werkzeuge',
|
||||
component: Dateien,
|
||||
},
|
||||
{
|
||||
id: 'nachrichten',
|
||||
Icon: MessageIcon,
|
||||
label: 'Nachrichten',
|
||||
navLabel: 'Nachrichten',
|
||||
group: 'Werkzeuge',
|
||||
component: Nachrichten,
|
||||
},
|
||||
{
|
||||
id: 'linkliste',
|
||||
Icon: LinkIcon,
|
||||
label: 'Linkliste',
|
||||
navLabel: 'Linkliste',
|
||||
group: 'Werkzeuge',
|
||||
component: Linkliste,
|
||||
},
|
||||
{
|
||||
id: 'codeschnipsel',
|
||||
Icon: CodeIcon,
|
||||
label: 'Code-Schnipsel',
|
||||
navLabel: 'Code',
|
||||
group: 'Werkzeuge',
|
||||
component: CodeSchnipsel,
|
||||
},
|
||||
{
|
||||
id: 'skizze',
|
||||
Icon: SkizzeIcon,
|
||||
label: 'CAD-Skizzen 🚧',
|
||||
navLabel: 'CAD-Skizzen 🚧',
|
||||
group: 'Werkzeuge',
|
||||
component: Skizze,
|
||||
},
|
||||
{
|
||||
id: 'devtools',
|
||||
Icon: WrenchIcon,
|
||||
label: 'Dev-Tools',
|
||||
navLabel: 'Dev-Tools',
|
||||
group: 'Werkzeuge',
|
||||
component: DevTools,
|
||||
},
|
||||
{
|
||||
id: 'paywallkiller',
|
||||
Icon: PaywallIcon,
|
||||
label: 'Paywall-Killer',
|
||||
navLabel: 'Paywall',
|
||||
group: 'Werkzeuge',
|
||||
component: PaywallKiller,
|
||||
},
|
||||
{
|
||||
id: 'media',
|
||||
Icon: FilmIcon,
|
||||
label: 'Media',
|
||||
navLabel: 'Media',
|
||||
group: 'Freizeit',
|
||||
component: Media,
|
||||
},
|
||||
// Neues Tool: { id:'...', Icon:..., label:'...', navLabel:'...', group:'...', component:... },
|
||||
];
|
||||
|
||||
export function getGroupedTools(isAdmin = false, hiddenTools = []) {
|
||||
const groups = {};
|
||||
for (const t of TOOLS) {
|
||||
if (t.adminOnly && !isAdmin) continue;
|
||||
if (hiddenTools.includes(t.id)) continue;
|
||||
const g = t.group || 'Allgemein';
|
||||
if (!groups[g]) groups[g] = [];
|
||||
groups[g].push(t);
|
||||
}
|
||||
return Object.entries(groups);
|
||||
}
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
import { useState, useMemo, useEffect, useRef } from 'react';
|
||||
import { api, S } from '../lib.js';
|
||||
|
||||
// ── Konstanten ────────────────────────────────────────────────────────────────
|
||||
const DEFAULTS = {
|
||||
materialpreis_pro_gramm: 0.01, stromverbrauch_kw: 0.15,
|
||||
strompreis_pro_kwh: 0.38, druckerpreis: 550,
|
||||
gesamtdruckstunden: 5000, verschleiss_pro_stunde: 0.06,
|
||||
};
|
||||
const FARB = { 1: 1.0, 2: 1.1, 3: 1.2, 4: 1.3 };
|
||||
const STUFEN = [
|
||||
{ key: 'f', emoji: '💖', label: 'Freundschaft', mult: 1.0, c: '#ff6b9d' },
|
||||
{ key: 'n', emoji: '🤝', label: 'Normal', mult: 1.5, c: '#4ecdc4' },
|
||||
{ key: 'a', emoji: '💼', label: 'Auftrag', mult: 2.5, c: '#ffe66d' },
|
||||
];
|
||||
|
||||
// ── Hilfselemente ─────────────────────────────────────────────────────────────
|
||||
function NInput({ label, value, onChange, step = 0.01, unit }) {
|
||||
return (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ ...S.head, display: 'block', marginBottom: 3 }}>
|
||||
{label.toUpperCase()} {unit && <span style={{ color: 'rgba(255,255,255,0.2)' }}>({unit})</span>}
|
||||
</label>
|
||||
<input type="number" value={value} step={step}
|
||||
onChange={e => onChange(parseFloat(e.target.value) || 0)} style={S.inp}
|
||||
onFocus={e => e.target.style.borderColor = 'rgba(78,205,196,0.5)'}
|
||||
onBlur={e => e.target.style.borderColor = 'rgba(255,255,255,0.1)'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Kalkulator ────────────────────────────────────────────────────────────────
|
||||
export default function Kalkulator3D({ toast, editData, onEditDone, onNavigate }) {
|
||||
const [gramm, setGramm] = useState(editData?.gramm ?? 50);
|
||||
const [stunden, setStunden] = useState(editData?.stunden ?? 3);
|
||||
const [farben, setFarben] = useState(editData?.farben ?? 1);
|
||||
const [vars, setVars] = useState(editData
|
||||
? { materialpreis_pro_gramm: editData.materialpreis_pro_gramm, stromverbrauch_kw: editData.stromverbrauch_kw,
|
||||
strompreis_pro_kwh: editData.strompreis_pro_kwh, druckerpreis: editData.druckerpreis,
|
||||
gesamtdruckstunden: editData.gesamtdruckstunden, verschleiss_pro_stunde: editData.verschleiss_pro_stunde }
|
||||
: { ...DEFAULTS });
|
||||
const [showAdv, setShowAdv] = useState(false);
|
||||
const [name, setName] = useState(editData?.name ?? '');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const sv = (k, v) => setVars(p => ({ ...p, [k]: v }));
|
||||
|
||||
const calc = useMemo(() => {
|
||||
const f = FARB[farben] || 1;
|
||||
const mat = gramm * vars.materialpreis_pro_gramm * f;
|
||||
const str = stunden * vars.stromverbrauch_kw * vars.strompreis_pro_kwh;
|
||||
const ver = stunden * vars.verschleiss_pro_stunde;
|
||||
return { mat, str, ver, grund: mat + str + ver };
|
||||
}, [gramm, stunden, farben, vars]);
|
||||
|
||||
const save = async () => {
|
||||
if (!name.trim()) { toast('Bitte einen Namen eingeben', 'error'); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = { name, gramm, stunden, farben, ...vars,
|
||||
preis_freundschaft: calc.grund * 1.0,
|
||||
preis_normal: calc.grund * 1.5,
|
||||
preis_auftrag: calc.grund * 2.5 };
|
||||
if (editData?.id) {
|
||||
await api(`/tools/kalkulator3d/${editData.id}`, { method: 'PUT', body: payload });
|
||||
toast('Aktualisiert ✓'); onEditDone?.();
|
||||
} else {
|
||||
await api('/tools/kalkulator3d', { body: payload });
|
||||
toast('Gespeichert ✓'); setName('');
|
||||
}
|
||||
} catch (e) { toast(e.message, 'error'); } finally { setSaving(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '36px 44px', maxWidth: 860 }}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 24 }}>
|
||||
<div>
|
||||
{editData && <div style={{ color: '#ffe66d', fontSize: 11, fontFamily: 'monospace', marginBottom: 5 }}>✎ Bearbeite: {editData.name}</div>}
|
||||
<h1 style={{ color: '#fff', fontFamily: "'Space Mono',monospace", fontSize: 22, margin: 0 }}>3D-Druck Kalkulator</h1>
|
||||
<p style={{ color: 'rgba(255,255,255,0.25)', fontSize: 11, fontFamily: 'monospace', marginTop: 5 }}>Echtzeit-Kalkulation · 3 Preisstufen</p>
|
||||
</div>
|
||||
{editData && <button onClick={onEditDone} style={S.btn('#ff6b9d')}>✕ Abbrechen</button>}
|
||||
</div>
|
||||
|
||||
{/* Eingaben */}
|
||||
<div style={{ ...S.card, marginBottom: 12 }}>
|
||||
<div style={S.head}>EINGABEN</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 16 }}>
|
||||
<NInput label="Gewicht" value={gramm} onChange={setGramm} step={1} unit="g" />
|
||||
<NInput label="Druckdauer" value={stunden} onChange={setStunden} step={0.5} unit="h" />
|
||||
<div>
|
||||
<label style={{ ...S.head, display: 'block', marginBottom: 3 }}>ANZAHL FARBEN</label>
|
||||
<select value={farben} onChange={e => setFarben(Number(e.target.value))} style={S.inp}>
|
||||
{[1,2,3,4].map(n => <option key={n} value={n} style={{ background: '#1a1a1e' }}>{n} Farbe{n>1?'n':''} (×{FARB[n].toFixed(1)})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Erweiterte Einstellungen */}
|
||||
<button onClick={() => setShowAdv(v => !v)} style={{
|
||||
width: '100%', marginTop: 12, background: 'rgba(255,255,255,0.02)',
|
||||
border: '1px solid rgba(255,255,255,0.06)', borderRadius: 7,
|
||||
padding: '8px 12px', color: 'rgba(255,255,255,0.35)', cursor: 'pointer',
|
||||
fontFamily: 'monospace', fontSize: 11, textAlign: 'left', display: 'flex', alignItems: 'center', gap: 7,
|
||||
}}>
|
||||
<span style={{ display: 'inline-block', transform: showAdv ? 'rotate(90deg)' : 'rotate(0)', transition: 'transform 0.18s' }}>▶</span>
|
||||
Erweiterte Einstellungen
|
||||
<span style={{ marginLeft: 'auto', fontSize: 9, background: 'rgba(78,205,196,0.1)', borderRadius: 3, padding: '2px 6px', color: '#4ecdc4' }}>
|
||||
{Object.entries(vars).some(([k,v]) => v !== DEFAULTS[k]) ? 'GEÄNDERT' : 'STANDARD'}
|
||||
</span>
|
||||
</button>
|
||||
{showAdv && (
|
||||
<div style={{ marginTop: 10, background: 'rgba(0,0,0,0.2)', border: '1px solid rgba(255,255,255,0.05)', borderRadius: 9, padding: 14 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
|
||||
<NInput label="Material €/g" value={vars.materialpreis_pro_gramm} onChange={v=>sv('materialpreis_pro_gramm',v)} step={0.001} />
|
||||
<NInput label="Strom kW" value={vars.stromverbrauch_kw} onChange={v=>sv('stromverbrauch_kw',v)} step={0.01} />
|
||||
<NInput label="Strom €/kWh" value={vars.strompreis_pro_kwh} onChange={v=>sv('strompreis_pro_kwh',v)} step={0.01} />
|
||||
<NInput label="Druckerpreis €" value={vars.druckerpreis} onChange={v=>sv('druckerpreis',v)} step={10} />
|
||||
<NInput label="Lebensdauer h" value={vars.gesamtdruckstunden} onChange={v=>sv('gesamtdruckstunden',v)} step={100} />
|
||||
<NInput label="Verschleiß €/h" value={vars.verschleiss_pro_stunde} onChange={v=>sv('verschleiss_pro_stunde',v)} step={0.01} />
|
||||
</div>
|
||||
<button onClick={() => setVars({...DEFAULTS})} style={{ ...S.btn('#ff6b9d', true), marginTop: 10 }}>↺ Standardwerte</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Kostenaufschlüsselung */}
|
||||
<div style={{ ...S.card, display: 'flex', padding: '12px 0', marginBottom: 12 }}>
|
||||
{[['Material',calc.mat,'#a78bfa'],['Strom',calc.str,'#60a5fa'],['Verschleiß',calc.ver,'#f59e0b'],['Grundkosten',calc.grund,'#4ecdc4',true]].map(([l,v,c,bold],i)=>(
|
||||
<div key={l} style={{ flex:1, padding:'5px 18px', borderLeft:i>0?'1px solid rgba(255,255,255,0.05)':'none' }}>
|
||||
<div style={{ color:'rgba(255,255,255,0.28)', fontSize:10, fontFamily:'monospace', marginBottom:3 }}>{l}</div>
|
||||
<div style={{ color:c, fontSize:bold?16:13, fontFamily:"'Space Mono',monospace", fontWeight:bold?700:400 }}>{v.toFixed(4)} €</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Preis-Cards */}
|
||||
<div style={{ display: 'flex', gap: 10, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
{STUFEN.map((s,i) => (
|
||||
<div key={s.key} style={{ background:`${s.c}10`, border:`1px solid ${s.c}30`, borderRadius:11,
|
||||
padding:'16px 20px', flex:1, minWidth:130, transform:i===1?'scale(1.04)':'scale(1)' }}>
|
||||
<div style={{ fontSize:18, marginBottom:4 }}>{s.emoji}</div>
|
||||
<div style={{ color:'rgba(255,255,255,0.4)', fontSize:9, fontFamily:'monospace', marginBottom:2 }}>{s.label}</div>
|
||||
<div style={{ color:s.c, fontSize:24, fontFamily:"'Space Mono',monospace", fontWeight:700 }}>{(calc.grund*s.mult).toFixed(2)} €</div>
|
||||
<div style={{ color:'rgba(255,255,255,0.2)', fontSize:9, fontFamily:'monospace', marginTop:2 }}>×{s.mult}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Speichern */}
|
||||
<div style={S.card}>
|
||||
<div style={S.head}>BERECHNUNG SPEICHERN</div>
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<input value={name} onChange={e=>setName(e.target.value)}
|
||||
onKeyDown={e=>e.key==='Enter'&&save()}
|
||||
placeholder='z.B. "Halterung Kunde Müller"' style={S.inp} />
|
||||
</div>
|
||||
<button onClick={save} disabled={saving} style={{
|
||||
background:'linear-gradient(135deg,#4ecdc4,#45b7d1)', border:'none', borderRadius:7,
|
||||
padding:'8px 18px', color:'#0d0d0f', fontFamily:"'Space Mono',monospace",
|
||||
fontWeight:700, fontSize:12, cursor:saving?'default':'pointer', opacity:saving?0.7:1,
|
||||
}}>{saving?'…':editData?.id?'✓ Aktualisieren':'↓ Speichern'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Gespeicherte Berechnungen ─────────────────────────────────────────────────
|
||||
export function SavedList({ toast, onNavigate }) {
|
||||
const [items, setItems] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [delId, setDelId] = useState(null);
|
||||
const [editData, setEditData] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
api('/tools/kalkulator3d').then(setItems).catch(e=>toast(e.message,'error')).finally(()=>setLoading(false));
|
||||
}, []);
|
||||
|
||||
const del = async id => {
|
||||
try { await api(`/tools/kalkulator3d/${id}`,{method:'DELETE'}); setItems(p=>p.filter(i=>i.id!==id)); toast('Gelöscht'); }
|
||||
catch(e) { toast(e.message,'error'); } finally { setDelId(null); }
|
||||
};
|
||||
|
||||
if (editData) return (
|
||||
<Kalkulator3D toast={toast} editData={editData} onEditDone={() => {
|
||||
setEditData(null);
|
||||
api('/tools/kalkulator3d').then(setItems).catch(()=>{});
|
||||
}} />
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ padding:'36px 44px', maxWidth:1000 }}>
|
||||
<h1 style={{ color:'#fff', fontFamily:"'Space Mono',monospace", fontSize:22, marginBottom:6 }}>Gespeicherte Berechnungen</h1>
|
||||
<p style={{ color:'rgba(255,255,255,0.25)', fontFamily:'monospace', fontSize:11, marginBottom:24 }}>{items.length} Einträge</p>
|
||||
{loading && <div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace' }}>Lädt…</div>}
|
||||
{!loading && items.length===0 && (
|
||||
<div style={{ ...S.card, textAlign:'center', padding:48 }}>
|
||||
<div style={{ fontSize:28, marginBottom:10 }}>◉</div>
|
||||
<div style={{ color:'rgba(255,255,255,0.25)', fontFamily:'monospace', fontSize:12 }}>Noch keine Berechnungen gespeichert.</div>
|
||||
</div>
|
||||
)}
|
||||
{items.map(item => (
|
||||
<div key={item.id} style={{ ...S.card, display:'flex', alignItems:'center', gap:14, marginBottom:8, padding:'14px 18px' }}>
|
||||
<div style={{ flex:1 }}>
|
||||
<div style={{ color:'#fff', fontFamily:'monospace', fontSize:13, fontWeight:700, marginBottom:3 }}>{item.name}</div>
|
||||
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:10 }}>
|
||||
{item.gramm}g · {item.stunden}h · {item.farben} Farbe{item.farben>1?'n':''} · {new Date(item.created_at).toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display:'flex', gap:12 }}>
|
||||
{[['💖',item.preis_freundschaft,'#ff6b9d'],['🤝',item.preis_normal,'#4ecdc4'],['💼',item.preis_auftrag,'#ffe66d']].map(([e,v,c])=>(
|
||||
<div key={e} style={{ textAlign:'center' }}>
|
||||
<div style={{ fontSize:11 }}>{e}</div>
|
||||
<div style={{ color:c, fontFamily:"'Space Mono',monospace", fontSize:12, fontWeight:700 }}>{v.toFixed(2)}€</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display:'flex', gap:6 }}>
|
||||
<button onClick={()=>setEditData(item)} style={S.btn('#4ecdc4',true)}>✎ Bearbeiten</button>
|
||||
{delId===item.id ? (
|
||||
<div style={{ display:'flex', gap:5, alignItems:'center' }}>
|
||||
<span style={{ color:'rgba(255,255,255,0.35)', fontSize:10, fontFamily:'monospace' }}>Sicher?</span>
|
||||
<button onClick={()=>del(item.id)} style={S.btn('#ff6b9d',true)}>✕ Ja</button>
|
||||
<button onClick={()=>setDelId(null)} style={S.btn('rgba(255,255,255,0.4)',true)}>Nein</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={()=>setDelId(item.id)} style={S.btn('#ff6b9d',true)}>✕ Löschen</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1000
frontend/src/tools/bestellungen.jsx
Normal file
701
frontend/src/tools/codeschnipsel.jsx
Normal file
@@ -0,0 +1,701 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { api, S } from '../lib.js';
|
||||
|
||||
// ── Spracherkennung ───────────────────────────────────────────────────────────
|
||||
const LANG_PATTERNS = [
|
||||
{ lang:'javascript', label:'JavaScript', patterns:[/\b(const|let|var|function|=>|require|import|export|console\.log)\b/] },
|
||||
{ lang:'typescript', label:'TypeScript', patterns:[/\b(interface|type\s+\w+\s*=|:\s*(string|number|boolean|void)|<T>)\b/] },
|
||||
{ lang:'python', label:'Python', patterns:[/\b(def |import |from .* import|elif |print\(|self\.|__init__)\b/] },
|
||||
{ lang:'jsx', label:'JSX/React', patterns:[/<[A-Z][a-zA-Z]+|useState|useEffect|return\s*\([\s\S]*<|className=/] },
|
||||
{ lang:'html', label:'HTML', patterns:[/<html|<body|<div|<span|<head|<!DOCTYPE/i] },
|
||||
{ lang:'css', label:'CSS', patterns:[/\{[\s\S]*:\s*[^;]+;\s*\}|@media|@keyframes|\.[\w-]+\s*\{/] },
|
||||
{ lang:'sql', label:'SQL', patterns:[/\b(SELECT|INSERT|UPDATE|DELETE|FROM|WHERE|JOIN|CREATE TABLE)\b/i] },
|
||||
{ lang:'bash', label:'Bash/Shell', patterns:[/^#!/m, /\b(echo|grep|sed|awk|chmod|sudo|apt|curl|wget)\b/] },
|
||||
{ lang:'json', label:'JSON', patterns:[/^\s*[\[{][\s\S]*[\]}]\s*$/, /"\w+":\s*[\w"[\]{]/] },
|
||||
{ lang:'rust', label:'Rust', patterns:[/\b(fn |let mut|impl |pub fn|use std|println!)\b/] },
|
||||
{ lang:'go', label:'Go', patterns:[/\b(func |package |import |fmt\.|var |:=)\b/] },
|
||||
{ lang:'java', label:'Java', patterns:[/\b(public class|private |protected |void |System\.out)\b/] },
|
||||
{ lang:'php', label:'PHP', patterns:[/<\?php|\$\w+\s*=|echo |function \w+|\$this->/] },
|
||||
{ lang:'csharp', label:'C#', patterns:[/\b(using System|namespace |class \w+|public void|Console\.Write)\b/] },
|
||||
{ lang:'cpp', label:'C/C++', patterns:[/#include|std::|cout <<|int main\(|nullptr/] },
|
||||
{ lang:'yaml', label:'YAML', patterns:[/^[\w-]+:\s*$/m, /^\s+-\s+\w/m] },
|
||||
{ lang:'dockerfile', label:'Dockerfile', patterns:[/^FROM |^RUN |^CMD |^EXPOSE |^COPY /m] },
|
||||
];
|
||||
|
||||
const LANG_COLORS = {
|
||||
javascript:'#f7df1e', typescript:'#3178c6', python:'#3572a5', jsx:'#61dafb',
|
||||
html:'#e34c26', css:'#563d7c', sql:'#e38c00', bash:'#4EAA25',
|
||||
json:'#4ecdc4', rust:'#dea584', go:'#00add8', java:'#b07219',
|
||||
php:'#4f5d95', csharp:'#178600', cpp:'#f34b7d', yaml:'#cb171e',
|
||||
dockerfile:'#2496ed', text:'rgba(255,255,255,0.3)',
|
||||
};
|
||||
|
||||
function detectLanguage(code) {
|
||||
if (!code?.trim()) return 'text';
|
||||
for (const { lang, patterns } of LANG_PATTERNS) {
|
||||
if (patterns.some(p => p.test(code))) return lang;
|
||||
}
|
||||
return 'text';
|
||||
}
|
||||
|
||||
function getLangLabel(lang) {
|
||||
return LANG_PATTERNS.find(l=>l.lang===lang)?.label || lang.toUpperCase();
|
||||
}
|
||||
|
||||
function fmtDt(s) {
|
||||
if (!s) return '';
|
||||
const d = new Date(s.replace(' ','T'));
|
||||
return isNaN(d) ? '' : d.toLocaleString('de-DE',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'});
|
||||
}
|
||||
|
||||
// ── Share Modal ───────────────────────────────────────────────────────────────
|
||||
function SnippetShareModal({ snippet, onClose, toast }) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [shares, setShares] = useState([]);
|
||||
const load = () => api(`/tools/snippets/${snippet.id}/shares`).then(setShares).catch(()=>{});
|
||||
useEffect(()=>{ load(); },[]);
|
||||
const share = async () => {
|
||||
if (!username.trim()) return;
|
||||
try { await api(`/tools/snippets/${snippet.id}/share`,{body:{username:username.trim()}}); toast('Geteilt ✓'); setUsername(''); load(); }
|
||||
catch(e){ toast(e.message,'error'); }
|
||||
};
|
||||
const unshare = async uid => {
|
||||
try { await api(`/tools/snippets/${snippet.id}/share/${uid}`,{method:'DELETE'}); setShares(p=>p.filter(s=>s.id!==uid)); }
|
||||
catch(e){ toast(e.message,'error'); }
|
||||
};
|
||||
const isMob = window.innerWidth < 768;
|
||||
return (
|
||||
<div style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.8)',zIndex:7000,
|
||||
display:'flex',alignItems:isMob?'flex-end':'center',justifyContent:'center',padding:isMob?0:24}}
|
||||
onClick={e=>e.target===e.currentTarget&&onClose()}>
|
||||
<div style={{background:'#1a1a1e',borderRadius:isMob?'16px 16px 0 0':14,
|
||||
width:'100%',maxWidth:400,padding:'20px 20px 28px',border:'1px solid rgba(255,255,255,0.12)'}}>
|
||||
{isMob&&<div style={{width:36,height:4,background:'rgba(255,255,255,0.15)',borderRadius:2,margin:'0 auto 14px'}}/>}
|
||||
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:12}}>
|
||||
<div style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:13,fontWeight:700,
|
||||
overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap',flex:1}}>🤝 {snippet.title}</div>
|
||||
<button onClick={onClose} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:18,marginLeft:8}}>✕</button>
|
||||
</div>
|
||||
<p style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:10,marginBottom:12,lineHeight:1.6}}>
|
||||
Empfänger können lesen, aber nicht bearbeiten.
|
||||
</p>
|
||||
<div style={{display:'flex',gap:8,marginBottom:14}}>
|
||||
<input value={username} onChange={e=>setUsername(e.target.value)} onKeyDown={e=>e.key==='Enter'&&share()}
|
||||
placeholder="Benutzername" autoCapitalize="none" style={{...S.inp,flex:1}}/>
|
||||
<button onClick={share} style={S.btn('#4ecdc4')}>Teilen</button>
|
||||
</div>
|
||||
{shares.length>0 ? shares.map(s=>(
|
||||
<div key={s.id} style={{display:'flex',justifyContent:'space-between',alignItems:'center',
|
||||
padding:'7px 0',borderBottom:'1px solid rgba(255,255,255,0.05)'}}>
|
||||
<span style={{color:'rgba(255,255,255,0.7)',fontFamily:'monospace',fontSize:13}}>{s.username}</span>
|
||||
<button onClick={()=>unshare(s.id)} style={S.btn('#ff6b9d',true)}>✕</button>
|
||||
</div>
|
||||
)) : <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:11}}>Noch nicht geteilt.</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Simple Diff ───────────────────────────────────────────────────────────────
|
||||
function DiffView({ oldCode, newCode }) {
|
||||
if (!oldCode) return (
|
||||
<pre style={{margin:0,padding:'12px 14px',color:'#d0d0d0',fontFamily:"'Courier New',monospace",
|
||||
fontSize:11,lineHeight:1.6,whiteSpace:'pre-wrap',wordBreak:'break-all',
|
||||
background:'rgba(0,0,0,0.35)'}}>
|
||||
{newCode}
|
||||
</pre>
|
||||
);
|
||||
const oldLines = oldCode.split('\n');
|
||||
const newLines = newCode.split('\n');
|
||||
const maxLen = Math.max(oldLines.length, newLines.length);
|
||||
const rows = [];
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
const o = oldLines[i];
|
||||
const n = newLines[i];
|
||||
if (o === n) {
|
||||
rows.push({ type:'same', text: n ?? '' });
|
||||
} else {
|
||||
if (o !== undefined) rows.push({ type:'del', text: o });
|
||||
if (n !== undefined) rows.push({ type:'add', text: n });
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div style={{background:'rgba(0,0,0,0.4)',border:'1px solid rgba(255,255,255,0.08)',
|
||||
borderRadius:8,overflow:'auto',maxHeight:320,fontFamily:"'Courier New',monospace",fontSize:11,lineHeight:1.6}}>
|
||||
{rows.map((r,i) => (
|
||||
<div key={i} style={{
|
||||
padding:'0 12px',whiteSpace:'pre-wrap',wordBreak:'break-all',
|
||||
background: r.type==='add'?'rgba(78,205,196,0.12)':r.type==='del'?'rgba(255,107,157,0.12)':'transparent',
|
||||
borderLeft:`2px solid ${r.type==='add'?'#4ecdc4':r.type==='del'?'#ff6b9d':'transparent'}`,
|
||||
}}>
|
||||
<span style={{color: r.type==='add'?'#4ecdc4':r.type==='del'?'rgba(255,107,157,0.7)':'rgba(255,255,255,0.2)',
|
||||
marginRight:6,userSelect:'none',fontSize:10}}>
|
||||
{r.type==='add'?'+':r.type==='del'?'−':' '}
|
||||
</span>
|
||||
<span style={{color: r.type==='same'?'#d0d0d0':'#fff'}}>{r.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function HistoryModal({ snippet, onClose, onRestore, toast }) {
|
||||
const [history, setHistory] = useState([]);
|
||||
const [selectedIdx, setSelectedIdx] = useState(0); // 0 = Aktuell (current)
|
||||
const [showDiff, setShowDiff] = useState(true);
|
||||
const isMob = window.innerWidth < 768;
|
||||
|
||||
const load = () => api(`/tools/snippets/${snippet.id}/history`).then(setHistory).catch(()=>{});
|
||||
useEffect(()=>{ load(); },[]);
|
||||
|
||||
// Entries: [current, ...history] where index 0 = current
|
||||
const entries = [
|
||||
{ id:'current', code: snippet.code, language: snippet.language,
|
||||
saved_at: snippet.updated_at, label:'Aktuell', isCurrent: true },
|
||||
...history.map((h,i) => ({ ...h, label:`v${history.length - i}`, isCurrent: false })),
|
||||
];
|
||||
|
||||
// Check if current code matches a history entry (= was restored)
|
||||
const restoredFromIdx = history.findIndex(h => h.code === snippet.code);
|
||||
const restoredFromLabel = restoredFromIdx >= 0 ? `v${history.length - restoredFromIdx}` : null;
|
||||
|
||||
const selected = entries[selectedIdx];
|
||||
const prevEntry = entries[selectedIdx + 1];
|
||||
const oldCode = prevEntry?.code ?? null;
|
||||
|
||||
const delHistory = async (hid) => {
|
||||
if (!window.confirm('Diese Version wirklich löschen?')) return;
|
||||
try {
|
||||
await api(`/tools/snippets/${snippet.id}/history/${hid}`, { method:'DELETE' });
|
||||
await load();
|
||||
setSelectedIdx(0);
|
||||
toast('Version gelöscht');
|
||||
} catch(e) { toast(e.message,'error'); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.85)',zIndex:7000,
|
||||
display:'flex',alignItems:isMob?'flex-end':'center',justifyContent:'center',padding:isMob?0:24}}
|
||||
onClick={e=>e.target===e.currentTarget&&onClose()}>
|
||||
<div style={{background:'#1a1a1e',borderRadius:isMob?'16px 16px 0 0':14,
|
||||
width:'100%',maxWidth:700,border:'1px solid rgba(255,255,255,0.12)',
|
||||
display:'flex',flexDirection:'column',maxHeight:isMob?'88vh':'82vh',overflow:'hidden'}}>
|
||||
|
||||
{/* Header */}
|
||||
<div style={{padding:'14px 20px',borderBottom:'1px solid rgba(255,255,255,0.08)',
|
||||
display:'flex',justifyContent:'space-between',alignItems:'center',flexShrink:0,gap:10}}>
|
||||
<div style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:13,fontWeight:700}}>
|
||||
📜 Versionshistorie · {snippet.title}
|
||||
</div>
|
||||
<div style={{display:'flex',gap:6,alignItems:'center'}}>
|
||||
<button onClick={()=>setShowDiff(v=>!v)} style={{
|
||||
background:showDiff?'rgba(78,205,196,0.12)':'rgba(255,255,255,0.05)',
|
||||
border:`1px solid ${showDiff?'rgba(78,205,196,0.3)':'rgba(255,255,255,0.1)'}`,
|
||||
borderRadius:6,color:showDiff?'#4ecdc4':'rgba(255,255,255,0.4)',
|
||||
cursor:'pointer',fontSize:9,fontFamily:'monospace',padding:'3px 8px'}}>
|
||||
{showDiff?'± Diff':'📄 Code'}
|
||||
</button>
|
||||
<button onClick={onClose} style={{background:'transparent',border:'none',
|
||||
color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:18}}>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{entries.length <= 1 ? (
|
||||
<div style={{padding:32,textAlign:'center',color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:11}}>
|
||||
Keine älteren Versionen vorhanden.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{display:'flex',flex:1,overflow:'hidden'}}>
|
||||
|
||||
{/* Versions-Liste */}
|
||||
<div style={{width:155,borderRight:'1px solid rgba(255,255,255,0.07)',overflowY:'auto',flexShrink:0}}>
|
||||
{entries.map((e,i) => (
|
||||
<div key={e.id} style={{
|
||||
display:'flex',alignItems:'center',
|
||||
background: i===selectedIdx?'rgba(78,205,196,0.08)':'transparent',
|
||||
borderLeft:`2px solid ${i===selectedIdx?'#4ecdc4':'transparent'}`,
|
||||
borderBottom:'1px solid rgba(255,255,255,0.04)',
|
||||
}}>
|
||||
<button onClick={()=>setSelectedIdx(i)} style={{
|
||||
flex:1,padding:'10px 10px',background:'transparent',
|
||||
border:'none',cursor:'pointer',textAlign:'left',
|
||||
}}>
|
||||
<div style={{color: e.isCurrent?'#4ecdc4':'rgba(255,255,255,0.65)',
|
||||
fontFamily:'monospace',fontSize:10,fontWeight: e.isCurrent?700:400}}>
|
||||
{e.label}
|
||||
{e.isCurrent&&<span style={{color:'rgba(78,205,196,0.5)',fontSize:8,marginLeft:4}}>●</span>}
|
||||
{e.isCurrent && restoredFromLabel && (
|
||||
<div style={{color:'rgba(255,230,109,0.7)',fontSize:8,fontWeight:400,marginTop:2}}>
|
||||
↩ aus {restoredFromLabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:8,marginTop:2}}>
|
||||
{fmtDt(e.saved_at)}
|
||||
</div>
|
||||
</button>
|
||||
{/* Löschen nur für Altversionen, nicht Aktuell */}
|
||||
{!e.isCurrent && !snippet.is_shared && (
|
||||
<button onClick={()=>delHistory(e.id)}
|
||||
title="Version löschen"
|
||||
style={{background:'transparent',border:'none',cursor:'pointer',
|
||||
padding:'0 8px',color:'rgba(255,107,157,0.35)',fontSize:12,
|
||||
flexShrink:0,lineHeight:1}}>✕</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Detail */}
|
||||
<div style={{flex:1,overflow:'auto',padding:14,display:'flex',flexDirection:'column',gap:10}}>
|
||||
{selected && (
|
||||
<>
|
||||
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',flexWrap:'wrap',gap:6}}>
|
||||
<div style={{display:'flex',gap:6,alignItems:'center',flexWrap:'wrap'}}>
|
||||
<span style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:10}}>
|
||||
{getLangLabel(selected.language)} · {fmtDt(selected.saved_at)}
|
||||
</span>
|
||||
{showDiff && oldCode===null && (
|
||||
<span style={{color:'rgba(255,255,255,0.2)',fontFamily:'monospace',fontSize:9}}>älteste Version – kein Diff verfügbar</span>
|
||||
)}
|
||||
{showDiff && oldCode!==null && prevEntry && (
|
||||
<span style={{color:'rgba(255,255,255,0.2)',fontFamily:'monospace',fontSize:9}}>
|
||||
Änderungen gegenüber {prevEntry.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Wiederherstellen nur für Altversionen die vom aktuellen abweichen */}
|
||||
{!selected.isCurrent && !snippet.is_shared && selected.code !== snippet.code && (
|
||||
<button onClick={()=>onRestore(selected)} style={{
|
||||
background:'rgba(255,230,109,0.1)',border:'1px solid rgba(255,230,109,0.25)',
|
||||
borderRadius:6,color:'#ffe66d',cursor:'pointer',fontSize:10,padding:'4px 10px'}}>
|
||||
↩ Wiederherstellen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showDiff && oldCode!==null
|
||||
? <DiffView oldCode={oldCode} newCode={selected.code}/>
|
||||
: <pre style={{margin:0,padding:'12px 14px',color:'#d0d0d0',
|
||||
fontFamily:"'Courier New',monospace",fontSize:11,lineHeight:1.6,
|
||||
background:'rgba(0,0,0,0.35)',border:'1px solid rgba(255,255,255,0.08)',
|
||||
borderRadius:8,overflowX:'auto',whiteSpace:'pre-wrap',wordBreak:'break-all',
|
||||
maxHeight:380,overflowY:'auto'}}>
|
||||
{selected.code}
|
||||
</pre>
|
||||
}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Snippet Card ──────────────────────────────────────────────────────────────
|
||||
function SnippetCard({ snippet, onEdit, onDelete, onShare, onHistory, toast }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const langColor = LANG_COLORS[snippet.language] || LANG_COLORS.text;
|
||||
|
||||
const copy = async () => {
|
||||
try { await navigator.clipboard.writeText(snippet.code); setCopied(true); setTimeout(()=>setCopied(false),1500); }
|
||||
catch { toast('Kopieren fehlgeschlagen','error'); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{...S.card,marginBottom:10,padding:0,overflow:'hidden'}}>
|
||||
{/* Header */}
|
||||
<div onClick={()=>setExpanded(v=>!v)} style={{padding:'12px 14px',cursor:'pointer',
|
||||
display:'flex',alignItems:'center',gap:10}}>
|
||||
<div style={{width:8,height:8,borderRadius:'50%',background:langColor,flexShrink:0,
|
||||
boxShadow:`0 0 6px ${langColor}80`}}/>
|
||||
<div style={{flex:1,minWidth:0}}>
|
||||
<div style={{display:'flex',alignItems:'center',gap:6,flexWrap:'wrap'}}>
|
||||
<span style={{color:'#fff',fontFamily:'monospace',fontSize:13,fontWeight:700,
|
||||
overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}>{snippet.title}</span>
|
||||
{snippet.is_shared && (
|
||||
<span style={{fontSize:9,fontFamily:'monospace',color:'rgba(78,205,196,0.6)',
|
||||
background:'rgba(78,205,196,0.08)',border:'1px solid rgba(78,205,196,0.2)',
|
||||
borderRadius:4,padding:'1px 5px',flexShrink:0}}>von {snippet.owner}</span>
|
||||
)}
|
||||
</div>
|
||||
{snippet.description && (
|
||||
<div style={{color:'rgba(255,255,255,0.38)',fontFamily:'monospace',fontSize:10,
|
||||
marginTop:2,overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}>
|
||||
{snippet.description}
|
||||
</div>
|
||||
)}
|
||||
<div style={{display:'flex',alignItems:'center',gap:6,marginTop:2,flexWrap:'wrap'}}>
|
||||
<span style={{fontSize:9,fontFamily:'monospace',color:langColor,opacity:0.8}}>
|
||||
{getLangLabel(snippet.language)}
|
||||
</span>
|
||||
{snippet.tags?.map(t=>(
|
||||
<span key={t} style={{fontSize:8,fontFamily:'monospace',
|
||||
color:'rgba(255,255,255,0.4)',background:'rgba(255,255,255,0.05)',
|
||||
border:'1px solid rgba(255,255,255,0.08)',borderRadius:4,padding:'1px 5px'}}>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<span style={{color:'rgba(255,255,255,0.3)',fontSize:10,
|
||||
transform:expanded?'rotate(90deg)':'rotate(0)',transition:'transform 0.2s',flexShrink:0}}>▶</span>
|
||||
</div>
|
||||
|
||||
{/* Expanded */}
|
||||
{expanded && (
|
||||
<div style={{borderTop:'1px solid rgba(255,255,255,0.06)'}}>
|
||||
{snippet.description && (
|
||||
<div style={{padding:'8px 14px',color:'rgba(255,255,255,0.45)',fontFamily:'monospace',fontSize:11,
|
||||
borderBottom:'1px solid rgba(255,255,255,0.04)'}}>
|
||||
{snippet.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Code */}
|
||||
<div style={{position:'relative'}}>
|
||||
<pre style={{margin:0,padding:'12px 14px',paddingRight:48,
|
||||
color:'#d0d0d0',fontFamily:"'Courier New',Courier,monospace",
|
||||
fontSize:12,lineHeight:1.65,overflowX:'auto',
|
||||
background:'rgba(0,0,0,0.35)',whiteSpace:'pre',maxHeight:320,overflowY:'auto'}}>
|
||||
{snippet.code}
|
||||
</pre>
|
||||
<button onClick={copy} title="Kopieren" style={{
|
||||
position:'absolute',top:8,right:8,
|
||||
background:'rgba(255,255,255,0.07)',border:'1px solid rgba(255,255,255,0.12)',
|
||||
borderRadius:6,color: copied?'#4ecdc4':'rgba(255,255,255,0.5)',
|
||||
cursor:'pointer',fontSize:13,padding:'4px 8px',transition:'color 0.2s',
|
||||
}}>{copied?'✓':'⎘'}</button>
|
||||
</div>
|
||||
|
||||
{/* Meta + Aktionen */}
|
||||
<div style={{padding:'10px 14px',display:'flex',alignItems:'center',
|
||||
justifyContent:'space-between',gap:8,flexWrap:'wrap',
|
||||
borderTop:'1px solid rgba(255,255,255,0.05)'}}>
|
||||
<div style={{color:'rgba(255,255,255,0.2)',fontFamily:'monospace',fontSize:9,lineHeight:1.7}}>
|
||||
<div>erstellt: {fmtDt(snippet.created_at)}</div>
|
||||
{snippet.updated_at !== snippet.created_at && (
|
||||
<div>bearbeitet: {fmtDt(snippet.updated_at)}</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{display:'flex',gap:5,flexWrap:'wrap'}}>
|
||||
<button onClick={()=>onHistory(snippet)}
|
||||
style={{background:'rgba(255,255,255,0.07)',border:'1px solid rgba(255,255,255,0.12)',
|
||||
borderRadius:6,color:'rgba(255,255,255,0.5)',cursor:'pointer',
|
||||
fontSize:10,padding:'4px 8px'}}>📜</button>
|
||||
{!snippet.is_shared && (
|
||||
<>
|
||||
<button onClick={()=>onShare(snippet)}
|
||||
style={{...S.btn('#ffe66d',true),fontSize:10,padding:'4px 8px'}}>🤝</button>
|
||||
<button onClick={()=>onEdit(snippet)}
|
||||
style={{...S.btn('#4ecdc4',true),fontSize:10,padding:'4px 8px'}}>✎</button>
|
||||
<button onClick={()=>onDelete(snippet.id)}
|
||||
style={{...S.btn('#ff6b9d',true),fontSize:10,padding:'4px 8px'}}>✕</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Snippet Form ──────────────────────────────────────────────────────────────
|
||||
function SnippetForm({ initial, onSave, onCancel, toast, existingTags=[] }) {
|
||||
const ALL_LANGS = ['text',...LANG_PATTERNS.map(l=>l.lang)];
|
||||
const [title, setTitle] = useState(initial?.title || '');
|
||||
const [code, setCode] = useState(initial?.code || '');
|
||||
const [lang, setLang] = useState(initial?.language || 'text');
|
||||
const [desc, setDesc] = useState(initial?.description || '');
|
||||
const [tags, setTags] = useState(initial?.tags?.filter(t=>t!==initial?.language).join(', ') || '');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [autoLang,setAutoLang]= useState(!initial?.id);
|
||||
|
||||
useEffect(()=>{
|
||||
if (!autoLang) return;
|
||||
const detected = detectLanguage(code);
|
||||
setLang(detected);
|
||||
},[code, autoLang]);
|
||||
|
||||
// Nur Komma als Trennzeichen
|
||||
const parseTags = () => {
|
||||
const langTag = lang !== 'text' ? [lang] : [];
|
||||
const manual = tags.split(',').map(t=>t.trim().toLowerCase()).filter(Boolean);
|
||||
return [...new Set([...langTag, ...manual])];
|
||||
};
|
||||
|
||||
const addSuggestedTag = tag => {
|
||||
const cur = tags.split(',').map(t=>t.trim()).filter(Boolean);
|
||||
if (cur.includes(tag)) return;
|
||||
setTags(cur.length ? cur.join(', ') + ', ' + tag : tag);
|
||||
};
|
||||
|
||||
// Tags die noch nicht eingetragen sind als Vorschläge
|
||||
const currentParsed = parseTags();
|
||||
const suggestions = existingTags.filter(t => t !== lang && !currentParsed.includes(t));
|
||||
|
||||
const save = async () => {
|
||||
if (!title.trim()) { toast('Titel erforderlich','error'); return; }
|
||||
setBusy(true);
|
||||
try {
|
||||
await onSave({ title:title.trim(), code, language:lang, description:desc.trim(), tags:parseTags() });
|
||||
} catch(e) { toast(e.message,'error'); }
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{...S.card,marginBottom:14}}>
|
||||
<div style={{...S.head,marginBottom:10}}>{initial?.id ? 'BEARBEITEN' : 'NEUER SCHNIPSEL'}</div>
|
||||
|
||||
<input value={title} onChange={e=>setTitle(e.target.value)} placeholder="Titel *"
|
||||
style={{...S.inp,marginBottom:8}}/>
|
||||
<input value={desc} onChange={e=>setDesc(e.target.value)} placeholder="Beschreibung (optional)"
|
||||
style={{...S.inp,marginBottom:8,fontSize:12}}/>
|
||||
|
||||
{/* Code */}
|
||||
<textarea value={code} onChange={e=>setCode(e.target.value)} placeholder="Code einfügen…"
|
||||
rows={8} style={{...S.inp,resize:'vertical',fontFamily:"'Courier New',monospace",
|
||||
fontSize:12,lineHeight:1.6,marginBottom:8,width:'100%',boxSizing:'border-box'}}/>
|
||||
|
||||
{/* Sprache */}
|
||||
<div style={{display:'flex',gap:8,marginBottom:8,alignItems:'center'}}>
|
||||
<select value={lang} onChange={e=>{setLang(e.target.value);setAutoLang(false);}}
|
||||
style={{...S.inp,flex:1,fontSize:12}}>
|
||||
{ALL_LANGS.map(l=>(
|
||||
<option key={l} value={l} style={{background:'#1a1a1e'}}>
|
||||
{getLangLabel(l)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label style={{display:'flex',alignItems:'center',gap:5,cursor:'pointer',flexShrink:0}}>
|
||||
<input type="checkbox" checked={autoLang} onChange={e=>setAutoLang(e.target.checked)}
|
||||
style={{accentColor:'#4ecdc4'}}/>
|
||||
<span style={{color:'rgba(255,255,255,0.45)',fontFamily:'monospace',fontSize:10}}>Auto</span>
|
||||
</label>
|
||||
{lang!=='text'&&<div style={{width:10,height:10,borderRadius:'50%',
|
||||
background:LANG_COLORS[lang]||'#fff',flexShrink:0}}/>}
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div style={{marginBottom:12}}>
|
||||
<label style={{...S.head,display:'block',marginBottom:4,fontSize:9}}>WEITERE TAGS (kommagetrennt)</label>
|
||||
<input value={tags} onChange={e=>setTags(e.target.value)} placeholder="z.B. api, auth, utils"
|
||||
autoCapitalize="none" style={{...S.inp,fontSize:12}}/>
|
||||
{/* Vorhandene Tags als Vorschläge */}
|
||||
{suggestions.length>0 && (
|
||||
<div style={{marginTop:6}}>
|
||||
<div style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:8,marginBottom:4}}>VORHANDENE TAGS HINZUFÜGEN:</div>
|
||||
<div style={{display:'flex',gap:4,flexWrap:'wrap'}}>
|
||||
{suggestions.map(t=>(
|
||||
<button key={t} onClick={()=>addSuggestedTag(t)} style={{
|
||||
fontSize:9,fontFamily:'monospace',padding:'2px 8px',borderRadius:12,cursor:'pointer',
|
||||
background:'rgba(255,255,255,0.04)',border:'1px solid rgba(255,255,255,0.1)',
|
||||
color:'rgba(255,255,255,0.4)',
|
||||
}}>+ {t}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{parseTags().length>0&&(
|
||||
<div style={{display:'flex',gap:4,flexWrap:'wrap',marginTop:6}}>
|
||||
{parseTags().map(t=>(
|
||||
<span key={t} style={{fontSize:9,fontFamily:'monospace',
|
||||
color:t===lang?LANG_COLORS[lang]:'rgba(255,255,255,0.5)',
|
||||
background:'rgba(255,255,255,0.06)',borderRadius:4,padding:'2px 7px',
|
||||
border:`1px solid ${t===lang?(LANG_COLORS[lang]+'40'):'rgba(255,255,255,0.1)'}`}}>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{display:'flex',gap:8}}>
|
||||
<button onClick={save} disabled={busy}
|
||||
style={{...S.btn('#4ecdc4'),flex:1,textAlign:'center',padding:'10px 0',opacity:busy?0.5:1}}>
|
||||
{busy?'…':initial?.id?'✓ Aktualisieren':'↓ Speichern'}
|
||||
</button>
|
||||
<button onClick={onCancel} style={{...S.btn('#ff6b9d',true),padding:'0 16px'}}>Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Component ────────────────────────────────────────────────────────────
|
||||
export default function CodeSchnipsel({ toast, mobile }) {
|
||||
const [own, setOwn] = useState([]);
|
||||
const [shared, setShared] = useState([]);
|
||||
const [sharedByMe,setSharedByMe]= useState([]);
|
||||
const [tab, setTab] = useState('own');
|
||||
const [search, setSearch] = useState('');
|
||||
const [tagFilters,setTagFilters]= useState([]); // multi-select array
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editItem, setEditItem] = useState(null);
|
||||
const [shareItem, setShareItem] = useState(null);
|
||||
const [histItem, setHistItem] = useState(null);
|
||||
|
||||
const load = useCallback(()=>{
|
||||
api('/tools/snippets').then(d=>{ setOwn(d.own||[]); setShared(d.shared||[]); setSharedByMe(d.sharedByMe||[]); }).catch(()=>{});
|
||||
},[]);
|
||||
|
||||
useEffect(()=>{ load(); },[load]);
|
||||
|
||||
// Only tags that are currently in use
|
||||
const allItems = [...own, ...shared];
|
||||
const allTags = [...new Set(allItems.flatMap(s=>s.tags||[]))].sort();
|
||||
|
||||
const toggleTag = t => setTagFilters(p => p.includes(t) ? p.filter(x=>x!==t) : [...p, t]);
|
||||
|
||||
const filterItems = items => {
|
||||
let res = items;
|
||||
if (tagFilters.length) res = res.filter(s => tagFilters.every(t=>s.tags?.includes(t)));
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
res = res.filter(s =>
|
||||
s.title.toLowerCase().includes(q) ||
|
||||
s.code.toLowerCase().includes(q) ||
|
||||
s.description?.toLowerCase().includes(q) ||
|
||||
s.tags?.some(t=>t.includes(q)) ||
|
||||
s.language.includes(q)
|
||||
);
|
||||
}
|
||||
return res;
|
||||
};
|
||||
|
||||
const curList = tab==='own' ? own : tab==='shared' ? shared : sharedByMe;
|
||||
const items = filterItems(curList);
|
||||
|
||||
const handleSave = async form => {
|
||||
if (editItem?.id) {
|
||||
const r = await api(`/tools/snippets/${editItem.id}`,{method:'PUT',body:form});
|
||||
setOwn(p=>p.map(s=>s.id===r.id?r:s)); setEditItem(null);
|
||||
} else {
|
||||
const r = await api('/tools/snippets',{body:form});
|
||||
setOwn(p=>[r,...p]); setShowForm(false);
|
||||
}
|
||||
toast('Gespeichert ✓');
|
||||
};
|
||||
|
||||
const handleDelete = async id => {
|
||||
if (!window.confirm('Schnipsel und alle seine Versionen wirklich löschen?')) return;
|
||||
await api(`/tools/snippets/${id}`,{method:'DELETE'});
|
||||
setOwn(p=>p.filter(s=>s.id!==id)); toast('Gelöscht');
|
||||
};
|
||||
|
||||
const handleRestore = async (histEntry) => {
|
||||
if (!histItem) return;
|
||||
const r = await api(`/tools/snippets/${histItem.id}`,{method:'PUT',body:{
|
||||
code: histEntry.code, language: histEntry.language
|
||||
}});
|
||||
setOwn(p=>p.map(s=>s.id===r.id?r:s));
|
||||
setHistItem(null); toast('Version wiederhergestellt ✓');
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{padding:mobile?'14px 14px 90px':'36px 44px',maxWidth:860}}>
|
||||
{shareItem && <SnippetShareModal snippet={shareItem} onClose={()=>{setShareItem(null);load();}} toast={toast}/>}
|
||||
{histItem && <HistoryModal snippet={histItem} onClose={()=>setHistItem(null)} onRestore={handleRestore} toast={toast}/>}
|
||||
|
||||
{/* Header */}
|
||||
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:14}}>
|
||||
<h1 style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:mobile?17:22,margin:0}}>Code-Schnipsel</h1>
|
||||
<div style={{display:'flex',gap:8}}>
|
||||
<button onClick={load} title="Neu laden"
|
||||
style={{background:'transparent',border:'1px solid rgba(255,255,255,0.1)',borderRadius:8,
|
||||
color:'rgba(255,255,255,0.4)',cursor:'pointer',padding:'6px 10px',fontSize:14,fontFamily:'monospace'}}>↺</button>
|
||||
{!showForm && !editItem && tab==='own' && (
|
||||
<button onClick={()=>setShowForm(true)}
|
||||
style={{...S.btn('#4ecdc4'),padding:'7px 16px',fontSize:12}}>+ Neu</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
{(showForm || editItem) && (
|
||||
<SnippetForm
|
||||
initial={editItem}
|
||||
onSave={handleSave}
|
||||
onCancel={()=>{ setShowForm(false); setEditItem(null); }}
|
||||
toast={toast}
|
||||
existingTags={allTags}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{display:'flex',gap:6,marginBottom:12,flexWrap:'wrap'}}>
|
||||
{[['own','Meine',own.length],['shared','Geteilt mit mir',shared.length],['sharedByMe','Geteilt von mir',sharedByMe.length]].map(([k,l,cnt])=>(
|
||||
<button key={k} onClick={()=>{setTab(k);setSearch('');setTagFilters([]);}} style={{
|
||||
padding:'6px 14px',borderRadius:20,fontFamily:'monospace',fontSize:11,cursor:'pointer',
|
||||
background:tab===k?'#4ecdc4':'rgba(255,255,255,0.05)',
|
||||
color:tab===k?'#0d0d0f':'rgba(255,255,255,0.55)',
|
||||
border:tab===k?'none':'1px solid rgba(255,255,255,0.1)',
|
||||
fontWeight:tab===k?700:400,
|
||||
}}>
|
||||
{l}{cnt>0&&<span style={{marginLeft:4,opacity:0.7}}>({cnt})</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Suche */}
|
||||
<div style={{position:'relative',marginBottom:10}}>
|
||||
<span style={{position:'absolute',left:12,top:'50%',transform:'translateY(-50%)',
|
||||
color:'rgba(255,255,255,0.4)',pointerEvents:'none'}}>⌕</span>
|
||||
<input value={search} onChange={e=>setSearch(e.target.value)} placeholder="Suchen (Titel, Code, Tags…)"
|
||||
style={{...S.inp,paddingLeft:34}}/>
|
||||
{search && <button onClick={()=>setSearch('')} style={{position:'absolute',right:10,top:'50%',
|
||||
transform:'translateY(-50%)',background:'transparent',border:'none',
|
||||
color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:16}}>✕</button>}
|
||||
</div>
|
||||
|
||||
{/* Tag-Filter (Mehrfachauswahl) */}
|
||||
{allTags.length>0 && (
|
||||
<div style={{display:'flex',gap:5,flexWrap:'wrap',marginBottom:14,alignItems:'center'}}>
|
||||
<button onClick={()=>setTagFilters([])} style={{
|
||||
fontSize:9,fontFamily:'monospace',padding:'3px 9px',borderRadius:20,cursor:'pointer',
|
||||
background:!tagFilters.length?'rgba(78,205,196,0.15)':'rgba(255,255,255,0.04)',
|
||||
border:`1px solid ${!tagFilters.length?'rgba(78,205,196,0.3)':'rgba(255,255,255,0.08)'}`,
|
||||
color:!tagFilters.length?'#4ecdc4':'rgba(255,255,255,0.4)',
|
||||
}}>Alle</button>
|
||||
{allTags.map(t=>{
|
||||
const active = tagFilters.includes(t);
|
||||
const c = LANG_COLORS[t] || '#4ecdc4';
|
||||
return (
|
||||
<button key={t} onClick={()=>toggleTag(t)} style={{
|
||||
fontSize:9,fontFamily:'monospace',padding:'3px 9px',borderRadius:20,cursor:'pointer',
|
||||
background:active?`${c}20`:'rgba(255,255,255,0.04)',
|
||||
border:`1px solid ${active?`${c}50`:'rgba(255,255,255,0.08)'}`,
|
||||
color:active?c:'rgba(255,255,255,0.45)',
|
||||
}}>{active?'✓ ':''}{t}</button>
|
||||
);
|
||||
})}
|
||||
{tagFilters.length>0 && (
|
||||
<span style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:9,marginLeft:2}}>
|
||||
({tagFilters.length} aktiv)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Liste */}
|
||||
{items.length===0 ? (
|
||||
<div style={{...S.card,textAlign:'center',padding:40}}>
|
||||
<div style={{fontSize:28,marginBottom:10}}>{'</>'}</div>
|
||||
<div style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:12}}>
|
||||
{search||tagFilters.length ? 'Keine Treffer.' : tab==='own' ? 'Noch keine Schnipsel – füge deinen ersten hinzu.' : 'Noch nichts geteilt.'}
|
||||
</div>
|
||||
</div>
|
||||
) : items.map(s=>(
|
||||
<SnippetCard
|
||||
key={s.id} snippet={s} toast={toast}
|
||||
onEdit={setEditItem}
|
||||
onDelete={handleDelete}
|
||||
onShare={setShareItem}
|
||||
onHistory={setHistItem}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1068
frontend/src/tools/dateien.jsx
Normal file
3284
frontend/src/tools/devtools.jsx
Normal file
643
frontend/src/tools/gebietseroberung.jsx
Normal file
@@ -0,0 +1,643 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { api, S } from '../lib.js';
|
||||
|
||||
const GRID = 20;
|
||||
const MY_COLOR = '#4ecdc4';
|
||||
const OPP_COLOR = '#ff6b9d';
|
||||
const DIRS8 = [[-1,-1],[-1,0],[-1,1],[0,-1],[0,1],[1,-1],[1,0],[1,1]];
|
||||
|
||||
// Terrain-Konstanten (müssen mit Backend übereinstimmen)
|
||||
const T_NORMAL = 0;
|
||||
const T_GOLD = 1;
|
||||
const T_MINE = 2;
|
||||
const T_ROCK = 3;
|
||||
const T_FOG = -1; // maskiert vom Server
|
||||
|
||||
function getMyId() {
|
||||
try { return JSON.parse(atob(localStorage.getItem('sk_token').split('.')[1])).id || null; } catch { return null; }
|
||||
}
|
||||
function getMyRole() {
|
||||
try { return JSON.parse(atob(localStorage.getItem('sk_token').split('.')[1])).role || null; } catch { return null; }
|
||||
}
|
||||
|
||||
// ── Spielerklärung ────────────────────────────────────────────────────────────
|
||||
function HelpModal({ onClose }) {
|
||||
return (
|
||||
<div style={{ position:'fixed', inset:0, background:'rgba(0,0,0,0.85)', zIndex:1000, display:'flex', flexDirection:'column' }}
|
||||
onClick={onClose}>
|
||||
<div style={{ ...S.card, maxWidth:460, width:'100%', margin:'16px auto', display:'flex', flexDirection:'column',
|
||||
maxHeight:'calc(100dvh - 32px)', overflow:'hidden' }}
|
||||
onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'center',
|
||||
paddingBottom:14, marginBottom:14, borderBottom:'1px solid rgba(255,255,255,0.08)', flexShrink:0 }}>
|
||||
<span style={{ color:'#fff', fontFamily:'Space Mono,monospace', fontSize:14, fontWeight:700, letterSpacing:1 }}>⬡ SPIELREGELN</span>
|
||||
<button onClick={onClose} style={S.btn('#ff6b9d', true)}>✕ Schließen</button>
|
||||
</div>
|
||||
<div style={{ overflowY:'auto', paddingBottom:'calc(56px + env(safe-area-inset-bottom, 0px))' }}>
|
||||
<div style={{ color:'rgba(255,255,255,0.75)', fontSize:13, lineHeight:1.9, fontFamily:'monospace' }}>
|
||||
<p style={{ marginTop:0 }}>
|
||||
<strong style={{ color:MY_COLOR }}>Ziel:</strong> Sammle mehr <strong>Punkte</strong> als dein Gegner — durch clevere Expansion und das Meiden von Fallen.
|
||||
</p>
|
||||
|
||||
<p><strong style={{ color:'#fff' }}>Ablauf:</strong><br/>
|
||||
Beide starten in gegenüberliegenden Ecken. Du hast einen <strong style={{ color:MY_COLOR }}>Kopf</strong> — dein letzter Zug, markiert mit einem leuchtenden Rahmen. Du kannst <strong>nur an den Kopf anbauen</strong> (alle 8 Richtungen). Dein Gebiet bleibt erhalten, aber du kannst nicht mehr von alten Feldern aus expandieren. Tippe ein Feld an, bestätige — fertig.
|
||||
</p>
|
||||
|
||||
<div style={{ background:'rgba(255,255,255,0.04)', borderRadius:8, padding:'12px 14px', marginBottom:12, border:'1px solid rgba(255,255,255,0.08)' }}>
|
||||
<div style={{ marginBottom:8, color:'#fff', fontSize:12, letterSpacing:1 }}>FELDER & PUNKTE</div>
|
||||
<div style={{ display:'grid', gridTemplateColumns:'28px 1fr', gap:'6px 10px', alignItems:'center' }}>
|
||||
<span style={{ fontSize:16, textAlign:'center' }}>⬜</span><span><strong style={{ color:'rgba(255,255,255,0.9)' }}>Normales Feld</strong> — +1 Punkt</span>
|
||||
<span style={{ fontSize:16, textAlign:'center' }}>⭐</span><span><strong style={{ color:'#ffe66d' }}>Goldfeld</strong> — +3 Punkte, sehr wertvoll!</span>
|
||||
<span style={{ fontSize:16, textAlign:'center' }}>💣</span><span><strong style={{ color:'#ff6b9d' }}>Sprengmine</strong> — Betreten zündet eine Explosion: alle 8 umliegenden Felder werden zu Felsen (auch Gold!). Besetzte Felder gehen verloren. Liegt eine weitere Mine im Umkreis, explodiert auch diese (Kettenreaktion!). Du verlierst deinen nächsten Zug.</span>
|
||||
<span style={{ fontSize:16, textAlign:'center' }}>🪨</span><span><strong style={{ color:'rgba(255,255,255,0.4)' }}>Felsen</strong> — unbesetzbar, blockiert Wege</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
<strong style={{ color:'#fff' }}>🌫️ Nebel:</strong><br/>
|
||||
Du siehst nur dein Gebiet und die direkt angrenzenden Felder. Goldfelder und Minen bleiben verborgen bis du nahe genug herankommst — also aufgepasst!
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong style={{ color:'#ffe66d' }}>🔭 Kundschaften:</strong><br/>
|
||||
Statt ein Feld zu besetzen kannst du einmal pro Zug <strong>kundschaften</strong>: tippe auf den Button <strong style={{ color:'#ffe66d' }}>🔭 Kundschaften</strong>, dann wähle einen Mittelpunkt auf dem Spielfeld — ein <strong>3×3 Bereich</strong> wird nur für dich aufgedeckt. Dein Gegner sieht nicht was du entdeckt hast. Kostet deinen Zug, gibt dir aber wertvolle Information über Minen und Gold.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong style={{ color:'#fff' }}>Spielende:</strong><br/>
|
||||
Das Spiel endet wenn niemand mehr ziehen kann — oder wenn jemand <strong>doppelt so viele Punkte</strong> hat wie der Gegner (Dominanzsieg 👑). Wer mehr Punkte hat, gewinnt.
|
||||
</p>
|
||||
|
||||
<div style={{ background:'rgba(255,255,255,0.04)', borderRadius:8, padding:'10px 14px', border:'1px solid rgba(255,255,255,0.08)' }}>
|
||||
📱 <em>Auf dem Handy: Pinch zum Zoomen, Feld antippen und bestätigen.</em>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Neues Spiel Modal ─────────────────────────────────────────────────────────
|
||||
function NewGameModal({ onClose, onCreated, toast }) {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [oppId, setOppId] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => { api('/tools/gebietseroberung/users').then(setUsers).catch(() => {}); }, []);
|
||||
|
||||
const create = async () => {
|
||||
if (!oppId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api('/tools/gebietseroberung', { body: { opponent_id: Number(oppId) } });
|
||||
toast('Spiel erstellt! Dein Gegner wurde benachrichtigt ⬡');
|
||||
onCreated(res.id);
|
||||
} catch(e) {
|
||||
toast(e.message || 'Fehler beim Erstellen', 'error');
|
||||
} finally { setLoading(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position:'fixed', inset:0, background:'rgba(0,0,0,0.85)', display:'flex', alignItems:'center', justifyContent:'center', zIndex:1000, padding:20 }}>
|
||||
<div style={{ ...S.card, maxWidth:360, width:'100%' }}>
|
||||
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:18 }}>
|
||||
<span style={{ color:'#fff', fontFamily:'Space Mono,monospace', fontSize:13, fontWeight:700, letterSpacing:1 }}>NEUES SPIEL</span>
|
||||
<button onClick={onClose} style={S.btn('#666666', true)}>✕</button>
|
||||
</div>
|
||||
<div style={{ ...S.head, marginBottom:6 }}>GEGNER WÄHLEN</div>
|
||||
<select value={oppId} onChange={e => setOppId(e.target.value)} style={{ ...S.inp, marginBottom:18 }}>
|
||||
<option value=''>-- Benutzer wählen --</option>
|
||||
{users.map(u => <option key={u.id} value={u.id}>{u.username}</option>)}
|
||||
</select>
|
||||
<div style={{ display:'flex', gap:8 }}>
|
||||
<button onClick={onClose} style={{ ...S.btn('#666666'), flex:1 }}>Abbrechen</button>
|
||||
<button onClick={create} disabled={!oppId || loading} style={{ ...S.btn('#4ecdc4'), flex:1 }}>
|
||||
{loading ? 'Erstelle…' : 'Spiel starten'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Topliste ──────────────────────────────────────────────────────────────────
|
||||
function Leaderboard({ reloadKey }) {
|
||||
const [rows, setRows] = useState(null);
|
||||
useEffect(() => { api('/tools/gebietseroberung/leaderboard').then(setRows).catch(() => setRows([])); }, [reloadKey]);
|
||||
if (!rows) return null;
|
||||
const medals = ['🥇','🥈','🥉'];
|
||||
return (
|
||||
<div style={{ marginTop:28 }}>
|
||||
<div style={{ ...S.head, marginBottom:10 }}>TOPLISTE — SIEGE</div>
|
||||
{rows.length === 0
|
||||
? <div style={{ color:'rgba(255,255,255,0.25)', fontFamily:'monospace', fontSize:12, textAlign:'center', padding:'16px 0' }}>Noch keine abgeschlossenen Spiele.</div>
|
||||
: rows.map((r,i) => (
|
||||
<div key={r.username} style={{ display:'flex', alignItems:'center', gap:12, padding:'10px 14px', marginBottom:6, background:'rgba(255,255,255,0.03)', border:'1px solid rgba(255,255,255,0.07)', borderRadius:8 }}>
|
||||
<span style={{ fontSize:16, width:24, textAlign:'center', flexShrink:0 }}>{medals[i] || `${i+1}.`}</span>
|
||||
<span style={{ flex:1, color:'#fff', fontFamily:'monospace', fontSize:13 }}>{r.username}</span>
|
||||
<span style={{ color:MY_COLOR, fontFamily:'Space Mono,monospace', fontSize:16, fontWeight:700 }}>{r.wins}</span>
|
||||
<span style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:10 }}>Siege</span>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Spielfeld ─────────────────────────────────────────────────────────────────
|
||||
function GameBoard({ game, myId, onMove, onScout, toast }) {
|
||||
const [pending, setPending] = useState(null);
|
||||
const [moving, setMoving] = useState(false);
|
||||
const [scoutMode, setScoutMode] = useState(false); // Scout-Modus aktiv
|
||||
const [scoutPend, setScoutPend] = useState(null); // gewählter Scout-Mittelpunkt
|
||||
const [mineAlert, setMineAlert] = useState(null); // letztes Mine-Event
|
||||
|
||||
const grid = JSON.parse(game.grid);
|
||||
const terrain = JSON.parse(game.terrain || '[]');
|
||||
const hasTerrain = terrain.length > 0;
|
||||
|
||||
const isMyTurn = game.current_turn === myId;
|
||||
const opponent = game.owner_id === myId ? game.opponent_id : game.owner_id;
|
||||
const myColor = game.owner_id === myId ? MY_COLOR : OPP_COLOR;
|
||||
const oppColor = game.owner_id === myId ? OPP_COLOR : MY_COLOR;
|
||||
const oppName = game.owner_id === myId ? game.opp_name : game.owner_name;
|
||||
|
||||
// Mine-Event anzeigen wenn frisch
|
||||
const [blastHighlight, setBlastHighlight] = useState(new Set());
|
||||
const [alertShownAt, setAlertShownAt] = useState(null); // move_count beim Anzeigen
|
||||
|
||||
useEffect(() => {
|
||||
if (!game.last_event) {
|
||||
setMineAlert(null); // Event wurde vom Server geclearet (nächster Zug)
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const ev = JSON.parse(game.last_event);
|
||||
if (ev.type === 'mine') {
|
||||
setMineAlert(ev);
|
||||
setAlertShownAt(game.move_count);
|
||||
if (ev.blastCells?.length) {
|
||||
const keys = new Set(ev.blastCells.map(([r,c]) => `${r}-${c}`));
|
||||
setBlastHighlight(keys);
|
||||
setTimeout(() => setBlastHighlight(new Set()), 2000);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}, [game.last_event, game.move_count]);
|
||||
|
||||
// Schlangen-Köpfe aus Server-Response
|
||||
const myHeadStr = game.myHead;
|
||||
const myHead = myHeadStr ? myHeadStr.split(',').map(Number) : null;
|
||||
const [myHR, myHC] = myHead ?? [null, null];
|
||||
|
||||
const oppHeadStr = game.oppHead;
|
||||
const oppHead = oppHeadStr ? oppHeadStr.split(',').map(Number) : null;
|
||||
const [oppHR, oppHC] = oppHead ?? [null, null];
|
||||
|
||||
const isAdjacentToHead = (r, c) => {
|
||||
if (myHR === null) return false;
|
||||
return Math.abs(r - myHR) <= 1 && Math.abs(c - myHC) <= 1 && !(r === myHR && c === myHC);
|
||||
};
|
||||
|
||||
const canClick = (r, c) => {
|
||||
if (scoutMode) return false; // im Scout-Modus kein normaler Zug
|
||||
if (!isMyTurn || game.status !== 'active') return false;
|
||||
if (grid[r][c] !== 0) return false;
|
||||
if (hasTerrain && terrain[r][c] === T_ROCK) return false;
|
||||
if (myHead) return isAdjacentToHead(r, c);
|
||||
return DIRS8.some(([dr,dc]) => {
|
||||
const nr=r+dr, nc=c+dc;
|
||||
return nr>=0&&nr<GRID&&nc>=0&&nc<GRID&&grid[nr][nc]===myId;
|
||||
});
|
||||
};
|
||||
|
||||
const handleCellClick = (r, c) => {
|
||||
if (!canClick(r, c) || moving) return;
|
||||
if (pending?.row === r && pending?.col === c) { setPending(null); return; }
|
||||
setPending({ row:r, col:c });
|
||||
};
|
||||
|
||||
const confirmMove = async () => {
|
||||
if (!pending || moving) return;
|
||||
setMoving(true);
|
||||
try {
|
||||
await onMove(pending.row, pending.col);
|
||||
setPending(null);
|
||||
} catch(e) {
|
||||
toast?.(e.message || 'Fehler', 'error');
|
||||
} finally { setMoving(false); }
|
||||
};
|
||||
|
||||
// Scores kommen vom Server (auf ungemasktem Grid berechnet — korrekt für beide Spieler)
|
||||
const myScore = game.myScore ?? 0;
|
||||
const oppScore = game.oppScore ?? 0;
|
||||
const myCount = grid.flat().filter(v => v === myId).length;
|
||||
const oppCount = grid.flat().filter(v => v === opponent).length;
|
||||
const fogCount = grid.flat().filter(v => v === 0 && hasTerrain).length;
|
||||
|
||||
let banner = null;
|
||||
if (game.status === 'finished') {
|
||||
const isDominance = myScore >= oppScore * 2 || oppScore >= myScore * 2;
|
||||
if (!game.winner_id) banner = { text:'Unentschieden! 🤝', color:'#ffe66d' };
|
||||
else if (game.winner_id === myId) banner = { text: isDominance ? 'Dominanzsieg! 👑🎉' : 'Du hast gewonnen! 🎉', color:MY_COLOR };
|
||||
else banner = { text: isDominance ? 'Dominanzniederlage 💀' : 'Du hast verloren.', color:OPP_COLOR };
|
||||
}
|
||||
|
||||
const [cellSize, setCellSize] = useState(14);
|
||||
|
||||
useEffect(() => {
|
||||
const calc = () => {
|
||||
const vw = Math.min(window.innerWidth, 600);
|
||||
const vh = window.visualViewport?.height ?? window.innerHeight;
|
||||
// Overhead: Header(36) + Scoreboard(50) + Status(30) + Legende(26) + margins(28) = ~170px
|
||||
const availH = vh - 170;
|
||||
const byW = Math.floor((vw - 32) / GRID);
|
||||
const byH = Math.floor(availH / GRID);
|
||||
setCellSize(Math.max(10, Math.min(byW, byH)));
|
||||
};
|
||||
calc();
|
||||
window.visualViewport?.addEventListener('resize', calc);
|
||||
window.addEventListener('resize', calc);
|
||||
return () => {
|
||||
window.visualViewport?.removeEventListener('resize', calc);
|
||||
window.removeEventListener('resize', calc);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Zell-Rendering
|
||||
const handleScoutClick = (r, c) => {
|
||||
if (!scoutPend) return;
|
||||
setScoutPend({ row:r, col:c, confirmed: true });
|
||||
};
|
||||
|
||||
const confirmScout = async () => {
|
||||
if (!scoutPend || moving) return;
|
||||
setMoving(true);
|
||||
try {
|
||||
await onScout(scoutPend.row, scoutPend.col);
|
||||
setScoutMode(false);
|
||||
setScoutPend(null);
|
||||
} catch(e) {
|
||||
toast?.(e.message || 'Fehler', 'error');
|
||||
} finally { setMoving(false); }
|
||||
};
|
||||
|
||||
const renderCell = (r, c) => {
|
||||
const cell = grid[r][c];
|
||||
const t = hasTerrain ? terrain[r][c] : T_NORMAL;
|
||||
const clic = canClick(r, c);
|
||||
const isPend = pending?.row === r && pending?.col === c;
|
||||
const isBlast = blastHighlight.has(`${r}-${c}`);
|
||||
const isHead = myHR === r && myHC === c;
|
||||
const isOppHead = game.status === 'finished' && oppHR === r && oppHC === c;
|
||||
// Scout-Preview: 3x3 um Maus-Position im Scout-Modus
|
||||
const isScoutPreview = scoutPend &&
|
||||
Math.abs(r - scoutPend.row) <= 1 && Math.abs(c - scoutPend.col) <= 1;
|
||||
const isFog = t === T_FOG;
|
||||
const isRock = t === T_ROCK;
|
||||
const isGold = t === T_GOLD;
|
||||
const isMine = t === T_MINE;
|
||||
|
||||
let bg = 'rgba(255,255,255,0.05)';
|
||||
let content = null;
|
||||
let border = '1px solid transparent';
|
||||
|
||||
if (isFog || (cell === 0 && !clic && hasTerrain && t === T_FOG)) {
|
||||
bg = 'rgba(0,0,0,0.4)'; // dunkler Nebel
|
||||
} else if (isRock) {
|
||||
bg = 'rgba(255,255,255,0.12)';
|
||||
content = cellSize > 16 ? '🪨' : null;
|
||||
} else if (cell === myId) {
|
||||
bg = isGold ? `#ffe66d88` : isMine ? `#ff6b9d44` : `${myColor}55`;
|
||||
if (isGold) content = cellSize > 16 ? '⭐' : null;
|
||||
if (isMine) content = cellSize > 16 ? '💣' : null;
|
||||
} else if (cell === opponent) {
|
||||
bg = isGold ? `#ffe66d55` : isMine ? `#ff6b9d33` : `${oppColor}55`;
|
||||
} else {
|
||||
// neutral & sichtbar
|
||||
if (isGold) { bg = 'rgba(255,230,109,0.18)'; content = cellSize > 16 ? '⭐' : null; }
|
||||
else if (isMine) { bg = 'rgba(255,107,157,0.15)'; content = cellSize > 16 ? '💣' : null; }
|
||||
else if (clic) bg = `${myColor}22`;
|
||||
}
|
||||
|
||||
if (isBlast) { bg = '#ff440066'; border = '2px solid #ff4400'; }
|
||||
if (isOppHead) { border = `2px solid ${oppColor}`; }
|
||||
if (isScoutPreview && !isHead) { bg = `rgba(255,230,109,0.2)`; border = `1px solid rgba(255,230,109,0.5)`; }
|
||||
if (isHead && !isPend) { border = `2px solid ${myColor}`; }
|
||||
if (isPend) { bg = myColor; border = `2px solid ${myColor}`; }
|
||||
else if (clic && !isPend && !isBlast && !isHead) border = `1px solid ${myColor}44`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${r}-${c}`}
|
||||
onClick={() => scoutMode ? setScoutPend({row:r,col:c}) : handleCellClick(r, c)}
|
||||
onMouseEnter={() => scoutMode ? setScoutPend({row:r,col:c}) : setHover(`${r}-${c}`)}
|
||||
onMouseLeave={() => { if(!scoutMode) setHover(null); }}
|
||||
style={{
|
||||
width:cellSize, height:cellSize, background:bg,
|
||||
borderRadius:1, cursor:clic?'pointer':'default',
|
||||
transition:'background 0.08s', border, boxSizing:'border-box',
|
||||
display:'flex', alignItems:'center', justifyContent:'center',
|
||||
fontSize: cellSize > 18 ? 10 : 7, lineHeight:1, userSelect:'none',
|
||||
}}
|
||||
>{content}</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Scoreboard */}
|
||||
<div style={{ display:'flex', gap:8, alignItems:'center', marginBottom:6 }}>
|
||||
<div style={{ ...S.card, padding:'5px 8px', flex:1 }}>
|
||||
<div style={{ color:myColor, fontFamily:'monospace', fontSize:9, letterSpacing:1, marginBottom:2 }}>DU</div>
|
||||
<div style={{ color:'#fff', fontFamily:'Space Mono,monospace', fontSize:16, fontWeight:700, lineHeight:1 }}>{myScore} <span style={{ fontSize:9, color:'rgba(255,255,255,0.3)' }}>Pkt</span></div>
|
||||
</div>
|
||||
<div style={{ textAlign:'center', color:'rgba(255,255,255,0.2)', fontFamily:'monospace', fontSize:9 }}>🌫️{fogCount}</div>
|
||||
<div style={{ ...S.card, padding:'5px 8px', flex:1, textAlign:'right' }}>
|
||||
<div style={{ color:oppColor, fontFamily:'monospace', fontSize:9, letterSpacing:1, marginBottom:2 }}>{oppName?.toUpperCase()}</div>
|
||||
<div style={{ color:'#fff', fontFamily:'Space Mono,monospace', fontSize:16, fontWeight:700, lineHeight:1 }}><span style={{ fontSize:9, color:'rgba(255,255,255,0.3)' }}>Pkt </span>{oppScore}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mine-Alert */}
|
||||
{mineAlert && (
|
||||
<div style={{
|
||||
background:'rgba(255,107,157,0.15)', border:'1px solid rgba(255,107,157,0.4)',
|
||||
borderRadius:8, padding:'10px 14px', marginBottom:12,
|
||||
display:'flex', alignItems:'center', gap:10,
|
||||
}}>
|
||||
<span style={{ fontSize:20 }}>💣</span>
|
||||
<span style={{ color:'#ff6b9d', fontFamily:'monospace', fontSize:12, flex:1 }}>
|
||||
{mineAlert.player === myId
|
||||
? `💥 Mine! ${mineAlert.destroyedOwn ? mineAlert.destroyedOwn + ' deiner Felder zerstört — ' : ''}Zug verloren.`
|
||||
: `💥 ${oppName} trat auf eine Mine! ${mineAlert.destroyedOpp ? mineAlert.destroyedOpp + ' deiner Felder zerstört!' : 'Kein Schaden.'} 😈`}
|
||||
</span>
|
||||
<button onClick={() => setMineAlert(null)} style={{ ...S.btn('#666666', true), padding:'2px 8px' }}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gewinner-Banner */}
|
||||
{banner && (
|
||||
<div style={{
|
||||
background:`${banner.color}18`, border:`1px solid ${banner.color}44`,
|
||||
borderRadius:10, padding:'12px 16px', marginBottom:14,
|
||||
color:banner.color, fontFamily:'Space Mono,monospace', fontSize:15,
|
||||
textAlign:'center', fontWeight:700, letterSpacing:1,
|
||||
}}>{banner.text}</div>
|
||||
)}
|
||||
|
||||
{/* Zug-Status */}
|
||||
{game.status === 'active' && (
|
||||
<div style={{ marginBottom:6 }}>
|
||||
{!isMyTurn && !pending && (
|
||||
<div style={{ color:'rgba(255,255,255,0.35)', fontFamily:'monospace', fontSize:11, textAlign:'center', padding:'4px 0' }}>
|
||||
⏳ {oppName} ist dran…
|
||||
</div>
|
||||
)}
|
||||
{isMyTurn && !pending && !scoutMode && (
|
||||
<div style={{ display:'flex', alignItems:'center', gap:6 }}>
|
||||
<div style={{ color:myColor, fontFamily:'monospace', fontSize:11, flex:1, textAlign:'center', padding:'4px 0' }}>
|
||||
▶ Baue vom Kopf aus — Vorsicht vor Minen! 💣
|
||||
</div>
|
||||
<button onClick={() => { setScoutMode(true); setPending(null); }}
|
||||
style={{ ...S.btn('#ffe66d', true), fontSize:10, flexShrink:0 }} title="3x3 Bereich aufdecken (kostet Zug)">
|
||||
🔭 Kundschaften
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{isMyTurn && scoutMode && !scoutPend && (
|
||||
<div style={{ display:'flex', alignItems:'center', gap:8, background:'rgba(255,230,109,0.1)', border:'1px solid rgba(255,230,109,0.4)', borderRadius:8, padding:'7px 10px' }}>
|
||||
<span style={{ color:'#ffe66d', fontFamily:'monospace', fontSize:11, flex:1 }}>
|
||||
🔭 Klicke einen Mittelpunkt zum Aufdecken (3×3)
|
||||
</span>
|
||||
<button onClick={() => { setScoutMode(false); setScoutPend(null); }} style={S.btn('#666666', true)}>✕ Abbrechen</button>
|
||||
</div>
|
||||
)}
|
||||
{isMyTurn && scoutMode && scoutPend && (
|
||||
<div style={{ display:'flex', alignItems:'center', gap:8, background:'rgba(255,230,109,0.1)', border:'1px solid rgba(255,230,109,0.44)', borderRadius:8, padding:'7px 10px' }}>
|
||||
<span style={{ color:'#ffe66d', fontFamily:'monospace', fontSize:11, flex:1 }}>
|
||||
🔭 R{scoutPend.row+1} S{scoutPend.col+1} aufdecken — kostet einen Zug
|
||||
</span>
|
||||
<button onClick={() => setScoutPend(null)} style={S.btn('#666666', true)}>✕</button>
|
||||
<button onClick={confirmScout} disabled={moving} style={S.btn('#ffe66d', true)}>{moving ? '…' : '✓ Aufdecken'}</button>
|
||||
</div>
|
||||
)}
|
||||
{pending && !scoutMode && (
|
||||
<div style={{ display:'flex', alignItems:'center', gap:8, background:`${myColor}12`, border:`1px solid ${myColor}44`, borderRadius:8, padding:'7px 10px' }}>
|
||||
<span style={{ color:myColor, fontFamily:'monospace', fontSize:11, flex:1 }}>
|
||||
✦ R{pending.row+1} S{pending.col+1} — bestätigen?
|
||||
</span>
|
||||
<button onClick={() => setPending(null)} style={S.btn('#666666', true)}>✕</button>
|
||||
<button onClick={confirmMove} disabled={moving} style={S.btn(MY_COLOR, true)}>{moving ? '…' : '✓ Ja'}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legende */}
|
||||
<div style={{ display:'flex', gap:6, marginBottom:4, flexWrap:'wrap' }}>
|
||||
{[['⭐','Gold +3','#ffe66d'],['💣','Mine 💥','#ff6b9d'],['🪨','Felsen','rgba(255,255,255,0.35)'],['🌫️','Nebel','rgba(255,255,255,0.25)']].map(([icon,label,color]) => (
|
||||
<div key={label} style={{ display:'flex', alignItems:'center', gap:4 }}>
|
||||
<span style={{ fontSize:12 }}>{icon}</span>
|
||||
<span style={{ color, fontFamily:'monospace', fontSize:10 }}>{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div style={{ overflowX:'auto', overflowY:'auto', WebkitOverflowScrolling:'touch', touchAction:'pinch-zoom', borderRadius:8, width:'fit-content' }}>
|
||||
<div style={{ display:'grid', gridTemplateColumns:`repeat(${GRID}, ${cellSize}px)`, gridTemplateRows:`repeat(${GRID}, ${cellSize}px)`, gap:1, background:'rgba(255,255,255,0.04)', borderRadius:8, padding:3 }}>
|
||||
{grid.map((row, r) => row.map((_, c) => renderCell(r, c)))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Spielzeile ────────────────────────────────────────────────────────────────
|
||||
function GameRow({ g, myId, isAdmin, onSelect, onDelete }) {
|
||||
const isMyTurn = g.status === 'active' && g.current_turn === myId;
|
||||
const opp = g.owner_id === myId ? g.opp_name : g.owner_name;
|
||||
const finished = g.status === 'finished' && (g.move_count || 0) >= 2;
|
||||
let result = null;
|
||||
if (g.status === 'finished') {
|
||||
if (!g.winner_id) result = { label:'Unentschieden', color:'#ffe66d' };
|
||||
else if (g.winner_id === myId) result = { label:'Gewonnen 🎉', color:MY_COLOR };
|
||||
else result = { label:'Verloren', color:OPP_COLOR };
|
||||
}
|
||||
return (
|
||||
<div style={{ ...S.card, marginBottom:8, display:'flex', alignItems:'center', gap:10, border: isMyTurn ? '1px solid rgba(78,205,196,0.35)' : '1px solid rgba(255,255,255,0.07)', padding:'12px 14px' }}>
|
||||
<button onClick={() => onSelect(g.id)} style={{ flex:1, background:'none', border:'none', cursor:'pointer', textAlign:'left', padding:0 }}>
|
||||
<div style={{ color:'#fff', fontFamily:'monospace', fontSize:13, marginBottom:4 }}>
|
||||
{isMyTurn && <span style={{ color:MY_COLOR, marginRight:6 }}>▶</span>}
|
||||
vs <strong>{opp}</strong>
|
||||
</div>
|
||||
<div style={{ color:'rgba(255,255,255,0.35)', fontSize:11, fontFamily:'monospace' }}>
|
||||
{g.move_count || 0} Züge gespielt
|
||||
</div>
|
||||
</button>
|
||||
{result
|
||||
? <span style={{ color:result.color, fontSize:11, fontFamily:'monospace', flexShrink:0 }}>{result.label}</span>
|
||||
: <span style={{ color: isMyTurn ? MY_COLOR : 'rgba(255,255,255,0.3)', fontSize:11, fontFamily:'monospace', flexShrink:0 }}>
|
||||
{isMyTurn ? 'Du bist dran' : 'Gegner dran'}
|
||||
</span>
|
||||
}
|
||||
{isAdmin && finished && (
|
||||
<button onClick={e => { e.stopPropagation(); onDelete(g.id); }} style={{ ...S.btn('#ff6b9d', true), flexShrink:0 }} title="Löschen">🗑</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Spielliste ────────────────────────────────────────────────────────────────
|
||||
function GameList({ games, myId, isAdmin, onSelect, onNew, onDelete, reloadKey }) {
|
||||
const active = games.filter(g => g.status === 'active');
|
||||
const finished = games.filter(g => g.status === 'finished' && (g.move_count || 0) >= 2);
|
||||
return (
|
||||
<div>
|
||||
<button onClick={onNew} style={{ ...S.btn('#4ecdc4'), width:'100%', marginBottom:20, padding:'10px 14px', fontSize:13 }}>
|
||||
+ Neues Spiel starten
|
||||
</button>
|
||||
{active.length > 0 && <>
|
||||
<div style={{ ...S.head, marginBottom:10 }}>LAUFENDE SPIELE ({active.length})</div>
|
||||
{active.map(g => <GameRow key={g.id} g={g} myId={myId} isAdmin={isAdmin} onSelect={onSelect} onDelete={onDelete} />)}
|
||||
</>}
|
||||
{finished.length > 0 && <>
|
||||
<div style={{ ...S.head, marginTop:20, marginBottom:10 }}>BEENDETE SPIELE</div>
|
||||
{finished.map(g => <GameRow key={g.id} g={g} myId={myId} isAdmin={isAdmin} onSelect={onSelect} onDelete={onDelete} finished />)}
|
||||
</>}
|
||||
{games.length === 0 && (
|
||||
<div style={{ color:'rgba(255,255,255,0.25)', fontFamily:'monospace', fontSize:13, textAlign:'center', paddingTop:40 }}>
|
||||
Noch keine Spiele — starte eines!
|
||||
</div>
|
||||
)}
|
||||
<Leaderboard reloadKey={reloadKey} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Hauptkomponente ───────────────────────────────────────────────────────────
|
||||
export default function HexWars({ toast }) {
|
||||
const [games, setGames] = useState([]);
|
||||
const [activeId, setActiveId] = useState(null);
|
||||
const [game, setGame] = useState(null);
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const myId = getMyId();
|
||||
const isAdmin = getMyRole() === 'admin';
|
||||
|
||||
const loadGames = useCallback(async () => {
|
||||
try { setGames(await api('/tools/gebietseroberung')); }
|
||||
catch { toast?.('Fehler beim Laden', 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadGames(); }, [loadGames]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeId) { setGame(null); return; }
|
||||
api(`/tools/gebietseroberung/${activeId}`).then(g => {
|
||||
setGame(g);
|
||||
// Beendetes Spiel als gesehen markieren → Badge verschwindet
|
||||
if (g.status === 'finished') {
|
||||
api(`/tools/gebietseroberung/${activeId}/seen`, { body: {} }).catch(() => {});
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, [activeId]);
|
||||
|
||||
// Polling wenn Gegner dran
|
||||
useEffect(() => {
|
||||
if (!activeId || !game || !myId) return;
|
||||
if (game.status !== 'active' || game.current_turn === myId) return;
|
||||
const iv = setInterval(async () => {
|
||||
const g = await api(`/tools/gebietseroberung/${activeId}`).catch(() => null);
|
||||
if (!g) return;
|
||||
setGame(g);
|
||||
if (g.current_turn === myId) {
|
||||
toast?.('Du bist dran! ⬡');
|
||||
// games-Liste sofort aktualisieren
|
||||
setGames(prev => prev.map(p => p.id === g.id
|
||||
? { ...p, current_turn: g.current_turn, move_count: g.move_count }
|
||||
: p
|
||||
));
|
||||
}
|
||||
}, 10000);
|
||||
return () => clearInterval(iv);
|
||||
}, [activeId, game, myId]);
|
||||
|
||||
const handleMove = async (row, col) => {
|
||||
const updated = await api(`/tools/gebietseroberung/${activeId}/move`, { body: { row, col } });
|
||||
setGame(updated);
|
||||
if (updated.status === 'finished') {
|
||||
api(`/tools/gebietseroberung/${activeId}/seen`, { body: {} }).catch(() => {});
|
||||
}
|
||||
// Direkt den games-State patchen damit die Liste sofort stimmt (kein Warten auf loadGames)
|
||||
setGames(prev => prev.map(g => g.id === updated.id
|
||||
? { ...g, current_turn: updated.current_turn, move_count: updated.move_count, status: updated.status, winner_id: updated.winner_id }
|
||||
: g
|
||||
));
|
||||
};
|
||||
|
||||
const handleScout = async (row, col) => {
|
||||
const updated = await api(`/tools/gebietseroberung/${activeId}/scout`, { body: { row, col } });
|
||||
setGame(updated);
|
||||
setGames(prev => prev.map(g => g.id === updated.id
|
||||
? { ...g, current_turn: updated.current_turn, move_count: updated.move_count }
|
||||
: g
|
||||
));
|
||||
};
|
||||
|
||||
const handleResign = async () => {
|
||||
if (!window.confirm('Wirklich aufgeben?')) return;
|
||||
await api(`/tools/gebietseroberung/${activeId}/resign`, { body: {} });
|
||||
const updated = await api(`/tools/gebietseroberung/${activeId}`);
|
||||
setGame(updated);
|
||||
setGames(prev => prev.map(g => g.id === updated.id ? { ...g, ...updated } : g));
|
||||
toast('Du hast aufgegeben.');
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!window.confirm('Spiel wirklich löschen?')) return;
|
||||
try {
|
||||
await api(`/tools/gebietseroberung/${id}`, { method: 'DELETE' });
|
||||
setGames(prev => prev.filter(g => g.id !== id));
|
||||
setReloadKey(k => k + 1);
|
||||
toast('Spiel gelöscht.');
|
||||
} catch(e) { toast?.(e.message || 'Fehler', 'error'); }
|
||||
};
|
||||
|
||||
if (loading) return <div style={{ color:'rgba(255,255,255,0.4)', fontFamily:'monospace', padding:40, textAlign:'center' }}>Lade…</div>;
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth:600, margin:'0 auto' }}>
|
||||
<div style={{ display:'flex', alignItems:'center', gap:8, marginBottom:8 }}>
|
||||
{activeId && (
|
||||
<button onClick={() => { setActiveId(null); setGame(null); loadGames(); }} style={S.btn('#666666', true)}>← Zurück</button>
|
||||
)}
|
||||
<h2 style={{ margin:0, fontSize:13, fontFamily:'monospace', color:'rgba(255,255,255,0.55)', letterSpacing:2, fontWeight:400, flexShrink:0 }}>
|
||||
⬡ HEX WARS
|
||||
</h2>
|
||||
<div style={{ flex:1 }} />
|
||||
<button onClick={() => setShowHelp(true)} style={S.btn('#ffe66d', true)}>? Regeln</button>
|
||||
{activeId && game?.status === 'active' && (
|
||||
<button onClick={handleResign} style={S.btn('#ff6b9d', true)}>⚑</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!activeId
|
||||
? <GameList games={games} myId={myId} isAdmin={isAdmin} onSelect={setActiveId} onNew={() => setShowNew(true)} onDelete={handleDelete} reloadKey={reloadKey} />
|
||||
: game && myId
|
||||
? <GameBoard game={game} myId={myId} onMove={handleMove} onScout={handleScout} toast={toast} />
|
||||
: <div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', textAlign:'center', paddingTop:40 }}>Lade Spiel…</div>
|
||||
}
|
||||
|
||||
{showNew && <NewGameModal onClose={() => setShowNew(false)} toast={toast} onCreated={id => { setShowNew(false); setActiveId(id); loadGames(); }} />}
|
||||
{showHelp && <HelpModal onClose={() => setShowHelp(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
844
frontend/src/tools/kalkulator3d.jsx
Normal file
@@ -0,0 +1,844 @@
|
||||
import { useConfirm } from '../confirm.jsx';
|
||||
import { useState, useMemo, useEffect, useRef } from 'react';
|
||||
import { api, S } from '../lib.js';
|
||||
|
||||
const MODEL_EXTENSIONS = ['.stl','.3mf','.f3d','.step','.stp','.obj','.iges','.igs','.fcstd','.amf','.scad'];
|
||||
|
||||
const DEFAULTS = {
|
||||
materialpreis_pro_rolle: 10, // €/Rolle (1 kg = 1000 g → 0.01 €/g)
|
||||
stromverbrauch_kw: 0.15,
|
||||
strompreis_pro_kwh: 0.38, druckerpreis: 550,
|
||||
gesamtdruckstunden: 5000, verschleiss_pro_stunde: 0.25,
|
||||
};
|
||||
const LS_CUSTOM = 'dd_kalk3d_custom_defaults';
|
||||
const getEffectiveDefaults = () => {
|
||||
try { const s = localStorage.getItem(LS_CUSTOM); if (s) return { ...DEFAULTS, ...JSON.parse(s) }; } catch {}
|
||||
return { ...DEFAULTS };
|
||||
};
|
||||
const getFarbFaktor = n => Math.round((1.0 + Math.max(0, n - 1) * 0.1) * 100) / 100;
|
||||
const STUFEN = [
|
||||
{ key:'f', emoji:'💖', label:'Freundschaft', mult:1.0, c:'#ff6b9d' },
|
||||
{ key:'n', emoji:'🤝', label:'Normal', mult:1.5, c:'#4ecdc4' },
|
||||
{ key:'a', emoji:'💼', label:'Auftrag', mult:2.5, c:'#ffe66d' },
|
||||
];
|
||||
|
||||
// ── Bild komprimieren (client-seitig, max 800px, JPEG 0.75) ──────────────────
|
||||
function compressImage(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const MAX = 800;
|
||||
let w = img.width, h = img.height;
|
||||
if (w > MAX || h > MAX) {
|
||||
if (w > h) { h = Math.round(h * MAX / w); w = MAX; }
|
||||
else { w = Math.round(w * MAX / h); h = MAX; }
|
||||
}
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = w; canvas.height = h;
|
||||
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
|
||||
resolve(canvas.toDataURL('image/jpeg', 0.75));
|
||||
};
|
||||
img.onerror = reject;
|
||||
img.src = e.target.result;
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Stepper ───────────────────────────────────────────────────────────────────
|
||||
function Stepper({ label, value, onChange, step, unit, min = 0, hint }) {
|
||||
const [raw, setRaw] = useState(String(value));
|
||||
|
||||
// Sync wenn value sich von außen ändert (z.B. beim Bearbeiten eines Archiv-Eintrags)
|
||||
useEffect(() => { setRaw(String(value)); }, [value]);
|
||||
|
||||
const dec = () => { const v = Math.max(min, Math.round((value - step) * 10000) / 10000); onChange(v); setRaw(String(v)); };
|
||||
const inc = () => { const v = Math.round((value + step) * 10000) / 10000; onChange(v); setRaw(String(v)); };
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ color:'rgba(255,255,255,0.4)', fontSize:10, fontFamily:'monospace', marginBottom:5, letterSpacing:1 }}>
|
||||
{label.toUpperCase()}{unit ? ` (${unit})` : ''}
|
||||
</div>
|
||||
<div style={{ display:'flex', alignItems:'center', background:'rgba(255,255,255,0.05)', borderRadius:8, border:'1px solid rgba(255,255,255,0.1)', overflow:'hidden' }}>
|
||||
<button onClick={dec} style={{ padding:'10px 12px', background:'transparent', border:'none', color:'rgba(255,255,255,0.6)', fontSize:20, cursor:'pointer', flexShrink:0, lineHeight:1 }}>−</button>
|
||||
<input
|
||||
type="number" value={raw} step={step}
|
||||
onChange={e => {
|
||||
setRaw(e.target.value);
|
||||
const parsed = parseFloat(e.target.value);
|
||||
if (!isNaN(parsed)) onChange(Math.max(min, parsed));
|
||||
}}
|
||||
onBlur={() => {
|
||||
const parsed = parseFloat(raw);
|
||||
const safe = isNaN(parsed) ? min : Math.max(min, parsed);
|
||||
onChange(safe); setRaw(String(safe));
|
||||
}}
|
||||
style={{ flex:1, background:'transparent', border:'none', color:'#fff', fontSize:15, fontFamily:"'Space Mono',monospace", textAlign:'center', outline:'none', minWidth:0, padding:'10px 4px' }}
|
||||
/>
|
||||
<button onClick={inc} style={{ padding:'10px 12px', background:'transparent', border:'none', color:'rgba(255,255,255,0.6)', fontSize:20, cursor:'pointer', flexShrink:0, lineHeight:1 }}>+</button>
|
||||
</div>
|
||||
{hint && <div style={{ color:'rgba(255,255,255,0.5)', fontSize:10, fontFamily:'monospace', marginTop:3 }}>{hint}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SmallInput({ label, value, onChange, step = 0.01 }) {
|
||||
const [raw, setRaw] = useState(String(value));
|
||||
useEffect(() => { setRaw(String(value)); }, [value]);
|
||||
return (
|
||||
<div>
|
||||
<div style={{ color:'rgba(255,255,255,0.6)', fontSize:9, fontFamily:'monospace', marginBottom:3 }}>{label}</div>
|
||||
<input
|
||||
type="number" value={raw} step={step}
|
||||
onChange={e => {
|
||||
setRaw(e.target.value);
|
||||
const parsed = parseFloat(e.target.value);
|
||||
if (!isNaN(parsed)) onChange(parsed);
|
||||
}}
|
||||
onBlur={() => {
|
||||
const parsed = parseFloat(raw);
|
||||
if (!isNaN(parsed)) { onChange(parsed); setRaw(String(parsed)); }
|
||||
else setRaw(String(value));
|
||||
}}
|
||||
style={{ ...S.inp, padding:'7px 8px', fontSize:13 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Bild-Modal ────────────────────────────────────────────────────────────────
|
||||
function ImageModal({ src, onClose }) {
|
||||
return (
|
||||
<div style={{ position:'fixed', inset:0, background:'rgba(0,0,0,0.9)', zIndex:9000,
|
||||
display:'flex', alignItems:'center', justifyContent:'center', padding:20 }}
|
||||
onClick={onClose}>
|
||||
<img src={src} alt="Vorschau"
|
||||
style={{ maxWidth:'100%', maxHeight:'90vh', borderRadius:10, objectFit:'contain' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Kostenrechner ─────────────────────────────────────────────────────────────
|
||||
export default function Kalkulator3D({ toast, editData, onEditDone, mobile, setActive }) {
|
||||
const [gramm, setGramm] = useState(editData?.gramm ?? 50);
|
||||
const [stunden, setStunden] = useState(editData?.stunden ?? 3);
|
||||
const [farben, setFarben] = useState(editData?.farben ?? 1);
|
||||
const [vars, setVars] = useState(editData ? {
|
||||
materialpreis_pro_rolle: editData.materialpreis_pro_gramm != null ? Math.round(editData.materialpreis_pro_gramm * 1000 * 100) / 100 : DEFAULTS.materialpreis_pro_rolle,
|
||||
stromverbrauch_kw: editData.stromverbrauch_kw,
|
||||
strompreis_pro_kwh: editData.strompreis_pro_kwh,
|
||||
druckerpreis: editData.druckerpreis,
|
||||
gesamtdruckstunden: editData.gesamtdruckstunden,
|
||||
verschleiss_pro_stunde: editData.verschleiss_pro_stunde,
|
||||
} : { ...getEffectiveDefaults() });
|
||||
const [showAdv, setShowAdv] = useState(false);
|
||||
const [name, setName] = useState(editData?.name ?? '');
|
||||
const [bemerkung, setBemerkung] = useState(editData?.bemerkung ?? '');
|
||||
const [image, setImage] = useState(editData?.image ?? null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [modelFiles, setModelFiles] = useState([]); // neue Dateien zum Hochladen
|
||||
const [existingFiles, setExistingFiles] = useState([]); // bereits gespeicherte Dateien
|
||||
const [modelFolderId, setModelFolderId] = useState(null);
|
||||
const fileRef = useRef(null);
|
||||
const modelFileRef = useRef(null);
|
||||
const sv = (k, v) => setVars(p => ({ ...p, [k]: v }));
|
||||
|
||||
// Im Bearbeitungsmodus: existierende Dateien laden (ohne Ordner zu erstellen)
|
||||
useEffect(() => {
|
||||
if (!editData?.id || !editData?.name) return;
|
||||
(async () => {
|
||||
try {
|
||||
const base = await api(`/tools/dateien/find-folder?name=3D-Modelle`);
|
||||
if (!base) return; // Basisordner existiert noch nicht → keine Dateien
|
||||
const sub = await api(`/tools/dateien/find-folder?name=${encodeURIComponent(editData.name.trim())}&parent_id=${base.id}`);
|
||||
if (!sub) return; // Unterordner existiert nicht → keine Dateien
|
||||
setModelFolderId(sub.id);
|
||||
const data = await api(`/tools/dateien?folder_id=${sub.id}`);
|
||||
setExistingFiles(data.own || []);
|
||||
} catch {}
|
||||
})();
|
||||
}, [editData?.id]);
|
||||
|
||||
const calc = useMemo(() => {
|
||||
const f = getFarbFaktor(farben);
|
||||
const matProGramm = vars.materialpreis_pro_rolle / 1000;
|
||||
const mat = gramm * matProGramm * f;
|
||||
const str = stunden * vars.stromverbrauch_kw * vars.strompreis_pro_kwh;
|
||||
const ver = stunden * vars.verschleiss_pro_stunde;
|
||||
return { mat, str, ver, grund: mat + str + ver };
|
||||
}, [gramm, stunden, farben, vars]);
|
||||
|
||||
const handleImagePick = async e => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith('image/')) { toast('Nur Bilddateien erlaubt (JPG, PNG, …)', 'error'); e.target.value = ''; return; }
|
||||
if (file.size > 20 * 1024 * 1024) { toast(`Bild zu groß (${(file.size/1024/1024).toFixed(1)} MB) – max. 20 MB`, 'error'); e.target.value = ''; return; }
|
||||
try {
|
||||
const compressed = await compressImage(file);
|
||||
setImage(compressed);
|
||||
} catch { toast('Bild konnte nicht verarbeitet werden – ist die Datei beschädigt?', 'error'); }
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!name.trim()) { toast('Bitte einen Namen eingeben', 'error'); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = { name, gramm, stunden, farben,
|
||||
materialpreis_pro_gramm: vars.materialpreis_pro_rolle / 1000,
|
||||
stromverbrauch_kw: vars.stromverbrauch_kw,
|
||||
strompreis_pro_kwh: vars.strompreis_pro_kwh,
|
||||
druckerpreis: vars.druckerpreis,
|
||||
gesamtdruckstunden: vars.gesamtdruckstunden,
|
||||
verschleiss_pro_stunde: vars.verschleiss_pro_stunde,
|
||||
preis_freundschaft: calc.grund * 1.0,
|
||||
preis_normal: calc.grund * 1.5,
|
||||
preis_auftrag: calc.grund * 2.5,
|
||||
image, bemerkung };
|
||||
|
||||
if (editData?.id) {
|
||||
await api(`/tools/kalkulator3d/${editData.id}`, { method:'PUT', body:payload });
|
||||
// Neue Dateien hochladen falls vorhanden
|
||||
if (modelFiles.length > 0) {
|
||||
let folderId = modelFolderId;
|
||||
if (!folderId) {
|
||||
// Ordner erst jetzt anlegen wenn wirklich Dateien da sind
|
||||
const base = await api('/tools/dateien/ensure-folder', { body: { name: '3D-Modelle' } });
|
||||
const sub = await api('/tools/dateien/ensure-folder', { body: { name: editData.name.trim(), parent_id: base.id } });
|
||||
folderId = sub.id;
|
||||
setModelFolderId(folderId);
|
||||
}
|
||||
const form = new FormData();
|
||||
form.append('folder_id', folderId);
|
||||
for (const f of modelFiles) form.append('files', f);
|
||||
await fetch('/api/tools/dateien/upload-3d', {
|
||||
method:'POST',
|
||||
headers:{ Authorization:`Bearer ${localStorage.getItem('sk_token')}` },
|
||||
body: form,
|
||||
});
|
||||
setModelFiles([]);
|
||||
const data = await api(`/tools/dateien?folder_id=${folderId}`);
|
||||
setExistingFiles(data.own || []);
|
||||
}
|
||||
toast('Aktualisiert ✓'); onEditDone?.();
|
||||
} else {
|
||||
await api('/tools/kalkulator3d', { body:payload });
|
||||
// 3D-Dateien hochladen wenn vorhanden
|
||||
if (modelFiles.length > 0) {
|
||||
try {
|
||||
// 1. "3D-Modelle" Basisordner sicherstellen
|
||||
const base = await api('/tools/dateien/ensure-folder', { body: { name: '3D-Modelle' } });
|
||||
// 2. Unterordner mit Modellnamen anlegen
|
||||
const sub = await api('/tools/dateien/ensure-folder', { body: { name: name.trim(), parent_id: base.id } });
|
||||
// 3. Dateien hochladen
|
||||
const form = new FormData();
|
||||
form.append('folder_id', sub.id);
|
||||
for (const f of modelFiles) form.append('files', f);
|
||||
await fetch('/api/tools/dateien/upload-3d', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('sk_token')}` },
|
||||
body: form,
|
||||
});
|
||||
toast(`Gespeichert ✓ · ${modelFiles.length} Datei(en) in 3D-Modelle/${name.trim()} abgelegt`);
|
||||
} catch {
|
||||
toast('Gespeichert ✓ (Datei-Upload fehlgeschlagen)', 'error');
|
||||
}
|
||||
} else {
|
||||
toast('Gespeichert ✓');
|
||||
}
|
||||
setName(''); setBemerkung(''); setImage(null); setModelFiles([]);
|
||||
setActive?.('kalkulator3d-saved');
|
||||
}
|
||||
} catch(e) { toast(e.message, 'error'); } finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const pad = mobile ? '14px 14px 90px' : '36px 44px';
|
||||
|
||||
return (
|
||||
<div style={{ padding:pad, maxWidth:860 }}>
|
||||
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'flex-start', marginBottom:20 }}>
|
||||
<div>
|
||||
{editData && <div style={{ color:'#ffe66d', fontSize:10, fontFamily:'monospace', marginBottom:4 }}>✎ {editData.name}</div>}
|
||||
<h1 style={{ color:'#fff', fontFamily:"'Space Mono',monospace", fontSize:mobile?18:22, margin:0 }}>Kostenrechner</h1>
|
||||
</div>
|
||||
<div style={{ display:'flex', gap:8 }}>
|
||||
{editData && <button onClick={onEditDone} style={S.btn('#ff6b9d', true)}>✕ Abbrechen</button>}
|
||||
{!editData && <button onClick={() => window.location.reload()} title="Neu laden"
|
||||
style={{ background:'transparent', border:'1px solid rgba(255,255,255,0.1)', borderRadius:8,
|
||||
color:'rgba(255,255,255,0.4)', cursor:'pointer', padding:'6px 10px', fontSize:14, fontFamily:'monospace' }}>↺</button>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Eingaben */}
|
||||
<div style={{ ...S.card, marginBottom:10 }}>
|
||||
<div style={{ display:'flex', flexDirection:'column', gap:10, marginBottom:10 }}>
|
||||
<Stepper label="Gewicht" value={gramm} onChange={setGramm} step={1} unit="g" min={0} />
|
||||
<Stepper label="Druckdauer" value={stunden} onChange={setStunden} step={0.5} unit="h" min={0} />
|
||||
<Stepper label="Anzahl Farben" value={farben}
|
||||
onChange={v => setFarben(Math.max(1, Math.round(v)))} step={1} min={1}
|
||||
hint={`Farbfaktor: ×${getFarbFaktor(farben).toFixed(2)}`} />
|
||||
</div>
|
||||
|
||||
<button onClick={() => setShowAdv(v => !v)} style={{
|
||||
width:'100%', marginTop:4, background:'rgba(255,255,255,0.02)',
|
||||
border:'1px solid rgba(255,255,255,0.06)', borderRadius:7,
|
||||
padding:'9px 12px', color:'rgba(255,255,255,0.6)', cursor:'pointer',
|
||||
fontFamily:'monospace', fontSize:11, textAlign:'left',
|
||||
display:'flex', alignItems:'center', gap:7,
|
||||
}}>
|
||||
<span style={{ display:'inline-block', transform:showAdv?'rotate(90deg)':'rotate(0)', transition:'transform 0.18s' }}>▶</span>
|
||||
Erweiterte Einstellungen
|
||||
<span style={{ marginLeft:'auto', fontSize:9, background:'rgba(78,205,196,0.1)', borderRadius:3, padding:'2px 6px', color:'#4ecdc4' }}>
|
||||
{Object.entries(vars).some(([k,v]) => v !== getEffectiveDefaults()[k]) ? 'GEÄNDERT' : 'STANDARD'}
|
||||
</span>
|
||||
</button>
|
||||
{showAdv && (
|
||||
<div style={{ marginTop:10, background:'rgba(0,0,0,0.2)', border:'1px solid rgba(255,255,255,0.05)', borderRadius:9, padding:12 }}>
|
||||
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:10 }}>
|
||||
<SmallInput label="Material €/Rolle" value={vars.materialpreis_pro_rolle} onChange={v=>sv('materialpreis_pro_rolle',v)} step={0.5}/>
|
||||
<SmallInput label="Strom kW" value={vars.stromverbrauch_kw} onChange={v=>sv('stromverbrauch_kw',v)} step={0.01}/>
|
||||
<SmallInput label="Strom €/kWh" value={vars.strompreis_pro_kwh} onChange={v=>sv('strompreis_pro_kwh',v)} step={0.01}/>
|
||||
<SmallInput label="Drucker €" value={vars.druckerpreis} onChange={v=>sv('druckerpreis',v)} step={10}/>
|
||||
<SmallInput label="Lebensdauer h" value={vars.gesamtdruckstunden} onChange={v=>sv('gesamtdruckstunden',v)} step={100}/>
|
||||
<SmallInput label="Verschleiß €/h" value={vars.verschleiss_pro_stunde} onChange={v=>sv('verschleiss_pro_stunde',v)} step={0.01}/>
|
||||
</div>
|
||||
<div style={{ display:'flex', gap:8, marginTop:10 }}>
|
||||
<button onClick={() => {
|
||||
localStorage.setItem(LS_CUSTOM, JSON.stringify(vars));
|
||||
toast('Als persönlicher Standard gespeichert ✓');
|
||||
}} style={{ ...S.btn('#4ecdc4', true), flex:1, textAlign:'center' }}>
|
||||
★ Als Standard speichern
|
||||
</button>
|
||||
<button onClick={() => {
|
||||
localStorage.removeItem(LS_CUSTOM);
|
||||
setVars({ ...DEFAULTS });
|
||||
toast('Auf Werkseinstellungen zurückgesetzt');
|
||||
}} style={{ ...S.btn('#ff6b9d', true) }} title="Werkseinstellungen wiederherstellen">
|
||||
↺
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Ergebnis */}
|
||||
<div style={{ ...S.card, marginBottom:10 }}>
|
||||
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:0 }}>
|
||||
{[['Material',calc.mat,'#a78bfa'],['Strom',calc.str,'#60a5fa'],['Verschleiß',calc.ver,'#f59e0b'],['Grundkosten',calc.grund,'#4ecdc4']].map(([l,v,c],i) => (
|
||||
<div key={l} style={{
|
||||
padding:'10px 14px',
|
||||
borderBottom: i < 2 ? '1px solid rgba(255,255,255,0.05)' : 'none',
|
||||
borderRight: i % 2 === 0 ? '1px solid rgba(255,255,255,0.05)' : 'none',
|
||||
}}>
|
||||
<div style={{ color:'rgba(255,255,255,0.6)', fontSize:9, fontFamily:'monospace', marginBottom:2 }}>{l.toUpperCase()}</div>
|
||||
<div style={{ color:c, fontSize:i===3?18:14, fontFamily:"'Space Mono',monospace", fontWeight:i===3?700:400 }}>
|
||||
{v.toFixed(2)} €
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preise */}
|
||||
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr 1fr', gap:8, marginBottom:12 }}>
|
||||
{STUFEN.map((s, i) => (
|
||||
<div key={s.key} style={{
|
||||
background:`${s.c}10`, border:`1px solid ${s.c}30`, borderRadius:10,
|
||||
padding:'12px 10px', textAlign:'center', transform:i===1?'scale(1.04)':'scale(1)',
|
||||
}}>
|
||||
<div style={{ fontSize:20, marginBottom:3 }}>{s.emoji}</div>
|
||||
<div style={{ color:'rgba(255,255,255,0.6)', fontSize:9, fontFamily:'monospace', marginBottom:4 }}>{s.label}</div>
|
||||
<div style={{ color:s.c, fontSize:mobile?16:20, fontFamily:"'Space Mono',monospace", fontWeight:700 }}>
|
||||
{(calc.grund * s.mult).toFixed(2)}€
|
||||
</div>
|
||||
<div style={{ color:'rgba(255,255,255,0.5)', fontSize:8, fontFamily:'monospace', marginTop:2 }}>×{s.mult}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Speichern */}
|
||||
<div style={S.card}>
|
||||
<div style={{ ...S.head, marginBottom:10 }}>BERECHNUNG SPEICHERN</div>
|
||||
|
||||
{/* Name */}
|
||||
<div style={{ marginBottom:10 }}>
|
||||
<label style={{ ...S.head, display:'block', marginBottom:4 }}>NAME</label>
|
||||
<input value={name} onChange={e => setName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && save()}
|
||||
placeholder='z.B. "Halterung Müller"'
|
||||
style={{ ...S.inp, fontSize:15 }} />
|
||||
</div>
|
||||
|
||||
{/* Bemerkung */}
|
||||
<div style={{ marginBottom:10 }}>
|
||||
<label style={{ ...S.head, display:'block', marginBottom:4 }}>BEMERKUNG</label>
|
||||
<textarea value={bemerkung} onChange={e => setBemerkung(e.target.value)}
|
||||
placeholder="Material, Infill, Notizen…"
|
||||
rows={3}
|
||||
style={{ ...S.inp, resize:'vertical', lineHeight:1.6, fontSize:14, padding:'9px 12px' }} />
|
||||
</div>
|
||||
|
||||
{/* Bild */}
|
||||
<div style={{ marginBottom:14 }}>
|
||||
<label style={{ ...S.head, display:'block', marginBottom:6 }}>BILD (optional)</label>
|
||||
<input ref={fileRef} type="file" accept="image/*" onChange={handleImagePick}
|
||||
style={{ display:'none' }} />
|
||||
{image ? (
|
||||
<div style={{ position:'relative', display:'inline-block' }}>
|
||||
<img src={image} alt="Vorschau"
|
||||
style={{ width:'100%', maxWidth:240, height:140, objectFit:'cover', borderRadius:8,
|
||||
border:'1px solid rgba(255,255,255,0.1)', display:'block' }} />
|
||||
<button onClick={() => setImage(null)} style={{
|
||||
position:'absolute', top:6, right:6,
|
||||
width:24, height:24, borderRadius:'50%',
|
||||
background:'rgba(0,0,0,0.7)', border:'none',
|
||||
color:'#fff', fontSize:13, cursor:'pointer', lineHeight:1,
|
||||
}}>✕</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => fileRef.current?.click()} style={{
|
||||
width:'100%', padding:'20px', background:'rgba(255,255,255,0.03)',
|
||||
border:'1px dashed rgba(255,255,255,0.15)', borderRadius:8,
|
||||
color:'rgba(255,255,255,0.6)', cursor:'pointer', fontFamily:'monospace', fontSize:12,
|
||||
}}>📷 Bild hinzufügen</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 3D-Modelldateien – beim Neuanlegen */}
|
||||
{!editData?.id && (
|
||||
<div style={{ marginBottom:14 }}>
|
||||
<label style={{ ...S.head, display:'block', marginBottom:6 }}>
|
||||
3D-DATEIEN (optional) – werden in 3D-Modelle/{name.trim()||'…'} abgelegt
|
||||
</label>
|
||||
<input ref={modelFileRef} type="file" multiple
|
||||
accept={MODEL_EXTENSIONS.join(',')}
|
||||
onChange={e => setModelFiles(Array.from(e.target.files||[]))}
|
||||
style={{ display:'none' }} />
|
||||
{modelFiles.length > 0 ? (
|
||||
<div style={{ background:'rgba(255,255,255,0.03)', border:'1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius:8, padding:10 }}>
|
||||
{modelFiles.map((f,i) => (
|
||||
<div key={i} style={{ display:'flex', alignItems:'center', justifyContent:'space-between',
|
||||
padding:'4px 0', borderBottom: i<modelFiles.length-1 ? '1px solid rgba(255,255,255,0.05)' : 'none' }}>
|
||||
<span style={{ color:'rgba(255,255,255,0.7)', fontFamily:'monospace', fontSize:11,
|
||||
overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap', maxWidth:'80%' }}>
|
||||
🖨 {f.name}
|
||||
</span>
|
||||
<span style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:9, flexShrink:0 }}>
|
||||
{(f.size/1024/1024).toFixed(1)} MB
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display:'flex', gap:6, marginTop:8 }}>
|
||||
<button onClick={() => modelFileRef.current?.click()}
|
||||
style={{ ...S.btn('#4ecdc4', true), flex:1, textAlign:'center', fontSize:11 }}>
|
||||
+ Weitere hinzufügen
|
||||
</button>
|
||||
<button onClick={() => setModelFiles([])}
|
||||
style={{ ...S.btn('#ff6b9d', true), fontSize:11 }}>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => modelFileRef.current?.click()} style={{
|
||||
width:'100%', padding:'16px', background:'rgba(255,255,255,0.03)',
|
||||
border:'1px dashed rgba(255,255,255,0.15)', borderRadius:8,
|
||||
color:'rgba(255,255,255,0.6)', cursor:'pointer', fontFamily:'monospace', fontSize:12,
|
||||
}}>🖨 3D-Dateien anhängen (STL, 3MF, F3D, STEP …)</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 3D-Modelldateien – beim Bearbeiten */}
|
||||
{editData?.id && (
|
||||
<div style={{ marginBottom:14 }}>
|
||||
<label style={{ ...S.head, display:'block', marginBottom:6 }}>
|
||||
3D-DATEIEN – 3D-Modelle/{editData.name}
|
||||
</label>
|
||||
<input ref={modelFileRef} type="file" multiple
|
||||
accept={MODEL_EXTENSIONS.join(',')}
|
||||
onChange={e => setModelFiles(Array.from(e.target.files||[]))}
|
||||
style={{ display:'none' }} />
|
||||
|
||||
{/* Existierende Dateien */}
|
||||
{existingFiles.length > 0 && (
|
||||
<div style={{ background:'rgba(255,255,255,0.03)', border:'1px solid rgba(255,255,255,0.08)',
|
||||
borderRadius:8, padding:10, marginBottom:8 }}>
|
||||
{existingFiles.map((f,i) => (
|
||||
<div key={f.id} style={{ display:'flex', alignItems:'center', gap:8,
|
||||
padding:'5px 0', borderBottom: i<existingFiles.length-1 ? '1px solid rgba(255,255,255,0.05)' : 'none' }}>
|
||||
<span style={{ fontSize:16, flexShrink:0 }}>🖨</span>
|
||||
<span style={{ color:'rgba(255,255,255,0.7)', fontFamily:'monospace', fontSize:11,
|
||||
flex:1, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
|
||||
{f.originalname}
|
||||
</span>
|
||||
<span style={{ color:'rgba(255,255,255,0.25)', fontFamily:'monospace', fontSize:9, flexShrink:0 }}>
|
||||
{(f.size/1024/1024).toFixed(1)} MB
|
||||
</span>
|
||||
<button onClick={async () => {
|
||||
try {
|
||||
await api(`/tools/dateien/${f.id}`, { method:'DELETE' });
|
||||
const remaining = existingFiles.filter(x => x.id !== f.id);
|
||||
setExistingFiles(remaining);
|
||||
// Ordner löschen wenn er jetzt leer ist
|
||||
if (remaining.length === 0 && modelFolderId) {
|
||||
await api(`/tools/dateien/folders/${modelFolderId}`, { method:'DELETE' });
|
||||
setModelFolderId(null);
|
||||
}
|
||||
} catch(e) { toast(e.message, 'error'); }
|
||||
}} style={{ background:'transparent', border:'none', cursor:'pointer',
|
||||
color:'rgba(255,107,157,0.5)', fontSize:14, padding:'0 2px', flexShrink:0, lineHeight:1 }}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Neue Dateien zum Hochladen */}
|
||||
{modelFiles.length > 0 && (
|
||||
<div style={{ background:'rgba(78,205,196,0.04)', border:'1px solid rgba(78,205,196,0.15)',
|
||||
borderRadius:8, padding:10, marginBottom:8 }}>
|
||||
<div style={{ ...S.head, marginBottom:6 }}>NEU HINZUFÜGEN</div>
|
||||
{modelFiles.map((f,i) => (
|
||||
<div key={i} style={{ display:'flex', justifyContent:'space-between',
|
||||
padding:'3px 0', borderBottom: i<modelFiles.length-1 ? '1px solid rgba(255,255,255,0.05)' : 'none' }}>
|
||||
<span style={{ color:'rgba(255,255,255,0.6)', fontFamily:'monospace', fontSize:11,
|
||||
overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap', maxWidth:'80%' }}>
|
||||
{f.name}
|
||||
</span>
|
||||
<span style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:9, flexShrink:0 }}>
|
||||
{(f.size/1024/1024).toFixed(1)} MB
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display:'flex', gap:6 }}>
|
||||
<button onClick={() => modelFileRef.current?.click()}
|
||||
style={{ ...S.btn('#4ecdc4', true), flex:1, textAlign:'center', fontSize:11, padding:'9px 0' }}>
|
||||
+ Dateien hinzufügen
|
||||
</button>
|
||||
{modelFiles.length > 0 && (
|
||||
<button onClick={() => setModelFiles([])}
|
||||
style={{ ...S.btn('#ff6b9d', true), fontSize:11 }}>✕ Abbrechen</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button onClick={save} disabled={saving} style={{
|
||||
width:'100%', background:'linear-gradient(135deg,#4ecdc4,#45b7d1)', border:'none',
|
||||
borderRadius:8, padding:'12px 0', color:'#0d0d0f',
|
||||
fontFamily:"'Space Mono',monospace", fontWeight:700,
|
||||
fontSize:13, cursor:saving?'default':'pointer', opacity:saving?0.7:1,
|
||||
}}>{saving ? '…' : editData?.id ? '✓ Aktualisieren' : '↓ Speichern'}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Archiv ──────────────────────────────────────────────────────────────
|
||||
// ── Datei-Downloads für ein Archiv-Modell ────────────────────────────────────
|
||||
function ModelFiles({ calcId, toast }) {
|
||||
const [files, setFiles] = useState(null); // null = noch nicht geladen
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
if (files !== null) return;
|
||||
try { setFiles(await api(`/tools/kalkulator3d/${calcId}/files`)); }
|
||||
catch { setFiles([]); }
|
||||
};
|
||||
|
||||
const download = async f => {
|
||||
try {
|
||||
const res = await fetch(`/api/tools/kalkulator3d/${calcId}/files/${f.id}/download`, {
|
||||
headers:{ Authorization:`Bearer ${localStorage.getItem('sk_token')}` }
|
||||
});
|
||||
if (!res.ok) { toast('Download fehlgeschlagen','error'); return; }
|
||||
const blob = await res.blob();
|
||||
const a = Object.assign(document.createElement('a'),{ href:URL.createObjectURL(blob), download:f.originalname });
|
||||
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
||||
setTimeout(()=>URL.revokeObjectURL(a.href),1000);
|
||||
} catch(e){ toast(e.message,'error'); }
|
||||
};
|
||||
|
||||
const toggle = () => { if (!open) load(); setOpen(v=>!v); };
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={toggle} style={{ ...S.btn('#4ecdc4', true), width:'100%', textAlign:'left',
|
||||
display:'flex', alignItems:'center', gap:6, fontSize:11 }}>
|
||||
<span>🖨</span>
|
||||
<span style={{flex:1}}>3D-Dateien {files!==null&&files.length>0?`(${files.length})`:''}</span>
|
||||
<span style={{transform:open?'rotate(90deg)':'rotate(0)',transition:'transform 0.2s',fontSize:10}}>▶</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div style={{ marginTop:6, background:'rgba(255,255,255,0.02)', border:'1px solid rgba(255,255,255,0.07)',
|
||||
borderRadius:8, overflow:'hidden' }}>
|
||||
{files === null ? (
|
||||
<div style={{ padding:'10px 14px', color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:11 }}>Lädt…</div>
|
||||
) : files.length === 0 ? (
|
||||
<div style={{ padding:'10px 14px', color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:11 }}>Keine Dateien vorhanden</div>
|
||||
) : files.map((f,i) => (
|
||||
<div key={f.id} style={{ display:'flex', alignItems:'center', gap:8, padding:'8px 12px',
|
||||
borderBottom: i<files.length-1 ? '1px solid rgba(255,255,255,0.05)' : 'none' }}>
|
||||
<span style={{ color:'rgba(255,255,255,0.6)', fontFamily:'monospace', fontSize:11,
|
||||
flex:1, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
|
||||
{f.originalname}
|
||||
</span>
|
||||
<span style={{ color:'rgba(255,255,255,0.25)', fontFamily:'monospace', fontSize:9, flexShrink:0 }}>
|
||||
{(f.size/1024/1024).toFixed(1)} MB
|
||||
</span>
|
||||
<button onClick={()=>download(f)} style={{ ...S.btn('#4ecdc4'), padding:'4px 10px', fontSize:10, flexShrink:0 }}>
|
||||
↓
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Share Modal ───────────────────────────────────────────────────────────────
|
||||
function CalcShareModal({ item, onClose, toast }) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [shares, setShares] = useState([]);
|
||||
const load = () => api(`/tools/kalkulator3d/${item.id}/shares`).then(setShares).catch(()=>{});
|
||||
useEffect(()=>{ load(); },[]);
|
||||
const share = async () => {
|
||||
if (!username.trim()) return;
|
||||
try { await api(`/tools/kalkulator3d/${item.id}/share`,{body:{username:username.trim()}}); toast('Geteilt ✓'); setUsername(''); load(); }
|
||||
catch(e) { toast(e.message,'error'); }
|
||||
};
|
||||
const unshare = async uid => {
|
||||
try { await api(`/tools/kalkulator3d/${item.id}/share/${uid}`,{method:'DELETE'}); setShares(p=>p.filter(s=>s.id!==uid)); toast('Freigabe entfernt'); }
|
||||
catch(e) { toast(e.message,'error'); }
|
||||
};
|
||||
const isMobile = window.innerWidth<768;
|
||||
return (
|
||||
<div style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.8)',zIndex:6000,
|
||||
display:'flex',alignItems:isMobile?'flex-end':'center',justifyContent:'center',padding:isMobile?0:24}}
|
||||
onClick={e=>e.target===e.currentTarget&&onClose()}>
|
||||
<div style={{background:'#1a1a1e',borderRadius:isMobile?'16px 16px 0 0':16,
|
||||
width:'100%',maxWidth:420,padding:'20px 20px 28px',border:'1px solid rgba(255,255,255,0.12)'}}>
|
||||
{isMobile&&<div style={{width:36,height:4,background:'rgba(255,255,255,0.15)',borderRadius:2,margin:'0 auto 14px'}}/>}
|
||||
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:14}}>
|
||||
<div style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:13,fontWeight:700}}>
|
||||
🖨 {item.name}
|
||||
</div>
|
||||
<button onClick={onClose} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:18,padding:'0 4px'}}>✕</button>
|
||||
</div>
|
||||
<p style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10,marginBottom:12,lineHeight:1.6}}>
|
||||
Empfänger können das Modell nur ansehen, nicht bearbeiten oder löschen.
|
||||
</p>
|
||||
<div style={{display:'flex',gap:8,marginBottom:14}}>
|
||||
<input value={username} onChange={e=>setUsername(e.target.value)} onKeyDown={e=>e.key==='Enter'&&share()}
|
||||
placeholder="Benutzername" autoCapitalize="none" style={{...S.inp,flex:1,fontSize:15}}/>
|
||||
<button onClick={share} style={S.btn('#4ecdc4')}>Teilen</button>
|
||||
</div>
|
||||
{shares.length>0 ? (
|
||||
<div>
|
||||
<div style={{...S.head,marginBottom:6}}>GETEILT MIT</div>
|
||||
{shares.map(s=>(
|
||||
<div key={s.id} style={{display:'flex',justifyContent:'space-between',alignItems:'center',
|
||||
padding:'8px 0',borderBottom:'1px solid rgba(255,255,255,0.05)'}}>
|
||||
<div>
|
||||
<div style={{color:'rgba(255,255,255,0.8)',fontFamily:'monospace',fontSize:13}}>{s.username}</div>
|
||||
{s.shared_at && <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:10}}>seit {new Date(s.shared_at.replace(' ','T')).toLocaleDateString('de-DE')}</div>}
|
||||
</div>
|
||||
<button onClick={()=>unshare(s.id)} style={S.btn('#ff6b9d',true)}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:11}}>Noch mit niemandem geteilt.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SavedList({ toast, mobile }) {
|
||||
const { confirm, ConfirmDialog } = useConfirm();
|
||||
const [own, setOwn] = useState([]);
|
||||
const [shared, setShared] = useState([]);
|
||||
const [sharedByMe, setSharedByMe] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tab, setTab] = useState('own');
|
||||
const [editData, setEditData] = useState(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [lightbox, setLightbox] = useState(null);
|
||||
const [shareItem, setShareItem]= useState(null);
|
||||
|
||||
const load = () => {
|
||||
setLoading(true);
|
||||
api('/tools/kalkulator3d')
|
||||
.then(d => { setOwn(d.own||[]); setShared(d.shared||[]); setSharedByMe(d.sharedByMe||[]); })
|
||||
.catch(e => toast(e.message,'error'))
|
||||
.finally(()=>setLoading(false));
|
||||
};
|
||||
useEffect(()=>{ load(); },[]);
|
||||
|
||||
const del = async id => {
|
||||
try { await api(`/tools/kalkulator3d/${id}`,{method:'DELETE'}); setOwn(p=>p.filter(i=>i.id!==id)); toast('Gelöscht'); }
|
||||
catch(e) { toast(e.message,'error'); }
|
||||
};
|
||||
|
||||
const items = tab==='own' ? own : tab==='shared' ? shared : sharedByMe;
|
||||
const filtered = search.trim()
|
||||
? items.filter(i => i.name.toLowerCase().includes(search.toLowerCase()) || (i.bemerkung&&i.bemerkung.toLowerCase().includes(search.toLowerCase())))
|
||||
: items;
|
||||
|
||||
if (editData) return (
|
||||
<Kalkulator3D toast={toast} mobile={mobile} editData={editData} onEditDone={() => { setEditData(null); load(); }} />
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ padding: mobile ? '14px 14px 90px' : '36px 44px', maxWidth:900 }}>
|
||||
{lightbox && <ImageModal src={lightbox} onClose={() => setLightbox(null)} />}
|
||||
{shareItem && <CalcShareModal item={shareItem} onClose={()=>{setShareItem(null);load();}} toast={toast}/>}
|
||||
<ConfirmDialog/>
|
||||
|
||||
<div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', marginBottom:14 }}>
|
||||
<h1 style={{ color:'#fff', fontFamily:"'Space Mono',monospace", fontSize:mobile?18:22, margin:0 }}>Archiv</h1>
|
||||
<button onClick={load} title="Neu laden"
|
||||
style={{ background:'transparent', border:'1px solid rgba(255,255,255,0.1)', borderRadius:8,
|
||||
color:'rgba(255,255,255,0.4)', cursor:'pointer', padding:'6px 10px', fontSize:14, fontFamily:'monospace' }}>↺</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{ display:'flex', gap:6, marginBottom:14, flexWrap:'wrap' }}>
|
||||
{[['own','Meine',own.length],['shared','Geteilt mit mir',shared.length],['sharedByMe','Geteilt von mir',sharedByMe.length]].map(([k,l,cnt])=>(
|
||||
<button key={k} onClick={()=>{setTab(k);setSearch('');}} style={{
|
||||
padding:'6px 14px', borderRadius:20, fontFamily:'monospace', fontSize:11, cursor:'pointer',
|
||||
background: tab===k ? '#4ecdc4' : 'rgba(255,255,255,0.05)',
|
||||
color: tab===k ? '#0d0d0f' : 'rgba(255,255,255,0.55)',
|
||||
border: tab===k ? 'none' : '1px solid rgba(255,255,255,0.1)',
|
||||
fontWeight: tab===k ? 700 : 400,
|
||||
}}>
|
||||
{l}{cnt>0&&<span style={{marginLeft:5,opacity:0.7}}>({cnt})</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Suche */}
|
||||
<div style={{ position:'relative', marginBottom:12 }}>
|
||||
<span style={{ position:'absolute', left:12, top:'50%', transform:'translateY(-50%)',
|
||||
color:'rgba(255,255,255,0.6)', fontSize:14, pointerEvents:'none' }}>⌕</span>
|
||||
<input value={search} onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Name oder Bemerkung suchen…"
|
||||
style={{ ...S.inp, paddingLeft:34, fontSize:15 }} />
|
||||
{search && (
|
||||
<button onClick={() => setSearch('')} style={{
|
||||
position:'absolute', right:10, top:'50%', transform:'translateY(-50%)',
|
||||
background:'transparent', border:'none', color:'rgba(255,255,255,0.6)',
|
||||
cursor:'pointer', fontSize:16,
|
||||
}}>✕</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p style={{ color:'rgba(255,255,255,0.55)', fontFamily:'monospace', fontSize:10, marginBottom:14 }}>
|
||||
{filtered.length} von {items.length} Einträgen{search ? ` für „${search}"` : ''}
|
||||
</p>
|
||||
|
||||
{loading && <div style={{ color:'rgba(255,255,255,0.6)', fontFamily:'monospace' }}>Lädt…</div>}
|
||||
|
||||
{!loading && items.length === 0 && (
|
||||
<div style={{ ...S.card, textAlign:'center', padding:40 }}>
|
||||
<div style={{ fontSize:28, marginBottom:10 }}>◉</div>
|
||||
<div style={{ color:'rgba(255,255,255,0.55)', fontFamily:'monospace', fontSize:12 }}>Noch keine Berechnungen.</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && search && filtered.length === 0 && (
|
||||
<div style={{ ...S.card, textAlign:'center', padding:24 }}>
|
||||
<div style={{ color:'rgba(255,255,255,0.55)', fontFamily:'monospace', fontSize:12 }}>Keine Treffer.</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filtered.map(item => (
|
||||
<div key={item.id} style={{ ...S.card, marginBottom:10, padding:'14px' }}>
|
||||
<div style={{ display:'flex', gap:12 }}>
|
||||
|
||||
{/* Bild Thumbnail */}
|
||||
{item.image && (
|
||||
<img src={item.image} alt={item.name}
|
||||
onClick={() => setLightbox(item.image)}
|
||||
style={{ width:72, height:72, objectFit:'cover', borderRadius:8, flexShrink:0,
|
||||
border:'1px solid rgba(255,255,255,0.1)', cursor:'pointer' }} />
|
||||
)}
|
||||
|
||||
<div style={{ flex:1, minWidth:0 }}>
|
||||
{/* Name + Datum */}
|
||||
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'flex-start', marginBottom:4 }}>
|
||||
<div style={{ display:'flex', alignItems:'center', gap:6 }}>
|
||||
<div style={{ color:'#fff', fontFamily:'monospace', fontSize:13, fontWeight:700 }}>{item.name}</div>
|
||||
{item.has_files && (
|
||||
<span title="3D-Dateien vorhanden"
|
||||
style={{ background:'rgba(78,205,196,0.12)', border:'1px solid rgba(78,205,196,0.25)',
|
||||
borderRadius:5, padding:'1px 5px', color:'#4ecdc4', fontFamily:'monospace', fontSize:9,
|
||||
letterSpacing:0.5, flexShrink:0 }}>🖨 Dateien</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ color:'rgba(255,255,255,0.5)', fontFamily:'monospace', fontSize:10, flexShrink:0, marginLeft:8 }}>
|
||||
{new Date(item.created_at).toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Infos */}
|
||||
<div style={{ color:'rgba(255,255,255,0.6)', fontFamily:'monospace', fontSize:10, marginBottom:6 }}>
|
||||
{item.gramm}g · {item.stunden}h · {item.farben} Farbe{item.farben > 1 ? 'n' : ''}
|
||||
</div>
|
||||
|
||||
{/* Bemerkung */}
|
||||
{item.bemerkung && (
|
||||
<div style={{ color:'rgba(255,255,255,0.45)', fontFamily:'monospace', fontSize:11,
|
||||
lineHeight:1.5, marginBottom:8, wordBreak:'break-word' }}>
|
||||
{item.bemerkung}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preise */}
|
||||
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr 1fr', gap:6, marginBottom:10 }}>
|
||||
{[['💖',item.preis_freundschaft,'#ff6b9d'],['🤝',item.preis_normal,'#4ecdc4'],['💼',item.preis_auftrag,'#ffe66d']].map(([e,v,c]) => (
|
||||
<div key={e} style={{ background:`${c}10`, border:`1px solid ${c}25`, borderRadius:7, padding:'6px 4px', textAlign:'center' }}>
|
||||
<div style={{ fontSize:12 }}>{e}</div>
|
||||
<div style={{ color:c, fontFamily:"'Space Mono',monospace", fontSize:12, fontWeight:700 }}>{v.toFixed(2)}€</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Aktionen */}
|
||||
<div style={{ display:'flex', gap:6, marginTop:10, flexDirection:'column' }}>
|
||||
{/* Datei-Downloads */}
|
||||
{item.has_files && (
|
||||
<ModelFiles calcId={item.id} toast={toast}/>
|
||||
)}
|
||||
<div style={{ display:'flex', gap:6 }}>
|
||||
{!item.is_shared && tab !== 'sharedByMe' ? (
|
||||
<>
|
||||
<button onClick={() => setEditData(item)} style={{ ...S.btn('#4ecdc4', true), flex:1, textAlign:'center' }}>✎ Bearbeiten</button>
|
||||
<button onClick={() => setShareItem(item)} style={{ ...S.btn('#ffe66d', true), padding:'0 12px' }} title="Teilen">🤝</button>
|
||||
<button onClick={() => del(item.id)} style={{ ...S.btn('#ff6b9d', true), padding:'0 12px' }} title="Löschen">✕</button>
|
||||
</>
|
||||
) : tab === 'sharedByMe' ? (
|
||||
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:10, padding:'4px 0' }}>
|
||||
→ {item.shared_with_name}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color:'rgba(255,255,255,0.25)', fontFamily:'monospace', fontSize:10, padding:'6px 0' }}>
|
||||
von {item.owner_name} · nur Ansicht
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
513
frontend/src/tools/kanban.jsx
Normal file
@@ -0,0 +1,513 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { api, S } from '../lib.js';
|
||||
|
||||
// ── Konstanten ────────────────────────────────────────────────────────────────
|
||||
const PRIORITIES = [
|
||||
{ id: 'none', label: 'Keine', color: '#666666' },
|
||||
{ id: 'low', label: 'Niedrig', color: '#4ecdc4' },
|
||||
{ id: 'medium', label: 'Mittel', color: '#ffe66d' },
|
||||
{ id: 'high', label: 'Hoch', color: '#ff6b9d' },
|
||||
];
|
||||
const COL_COLORS = ['#4ecdc4','#60a5fa','#a78bfa','#ff6b9d','#ffe66d','#fb923c','#4ade80','#f472b6','#94a3b8'];
|
||||
const fmtCardDate = s => {
|
||||
if (!s) return '';
|
||||
const d = new Date(String(s).replace(' ', 'T'));
|
||||
if (isNaN(d)) return '';
|
||||
return d.toLocaleDateString('de-DE', { day:'2-digit', month:'2-digit', year:'numeric' })
|
||||
+ ' ' + d.toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' });
|
||||
};
|
||||
const prioOf = id => PRIORITIES.find(p => p.id === id) || PRIORITIES[0];
|
||||
|
||||
// ── Karten-Modal ──────────────────────────────────────────────────────────────
|
||||
function CardModal({ card, columnId, columns, onSave, onClose, toast }) {
|
||||
const [title, setTitle] = useState(card?.title || '');
|
||||
const [desc, setDesc] = useState(card?.description || '');
|
||||
const [prio, setPrio] = useState(card?.priority || 'none');
|
||||
const [moveToCol, setMoveToCol] = useState(card?.column_id || columnId);
|
||||
const [newColName, setNewColName] = useState('');
|
||||
const [showNewCol, setShowNewCol] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const titleRef = useRef(null);
|
||||
const newColRef = useRef(null);
|
||||
const isMob = window.innerWidth < 768;
|
||||
|
||||
useEffect(() => { titleRef.current?.focus(); }, []);
|
||||
useEffect(() => { if (showNewCol) newColRef.current?.focus(); }, [showNewCol]);
|
||||
|
||||
const save = async () => {
|
||||
if (!title.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
// Neue Spalte anlegen wenn gewünscht
|
||||
let targetCol = moveToCol;
|
||||
if (showNewCol && newColName.trim()) {
|
||||
const nc = await api('/tools/kanban/columns', { body: { title: newColName.trim() } });
|
||||
targetCol = nc.id;
|
||||
}
|
||||
if (card) {
|
||||
await api(`/tools/kanban/cards/${card.id}`, {
|
||||
method: 'PATCH',
|
||||
body: { title, description: desc, priority: prio },
|
||||
});
|
||||
// Spalte gewechselt?
|
||||
if (targetCol && targetCol !== card.column_id) {
|
||||
await api(`/tools/kanban/cards/${card.id}/move`, { body: { column_id: targetCol } });
|
||||
}
|
||||
} else {
|
||||
await api('/tools/kanban/cards', { body: { column_id: targetCol || columnId, title, description: desc, priority: prio } });
|
||||
}
|
||||
onSave();
|
||||
} catch (e) { toast(e.message, 'error'); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const onKey = e => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) save();
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div onClick={e => e.target === e.currentTarget && onClose()}
|
||||
style={{ position:'fixed',inset:0,background:'rgba(0,0,0,0.7)',zIndex:1000,
|
||||
display:'flex',
|
||||
alignItems:isMob?'flex-end':'center',
|
||||
justifyContent:'center',
|
||||
padding:isMob?0:20,
|
||||
// Auf Mobile: unten Platz für die fixe Bottom-Nav (56px + safe-area) lassen
|
||||
paddingBottom: isMob ? 'calc(56px + env(safe-area-inset-bottom, 0px))' : 20,
|
||||
}}>
|
||||
<div style={{ background:'#1a1a1e',border:'1px solid rgba(255,255,255,0.12)',
|
||||
borderRadius:isMob?'16px 16px 0 0':14,
|
||||
width:'100%',maxWidth:460,
|
||||
display:'flex',flexDirection:'column',
|
||||
maxHeight: isMob ? '80vh' : '90vh',
|
||||
}}>
|
||||
{/* Drag-Handle + Header – immer sichtbar, nicht scrollend */}
|
||||
<div style={{ padding:'18px 20px 0', flexShrink:0 }}>
|
||||
{isMob && <div style={{ width:36,height:4,background:'rgba(255,255,255,0.15)',borderRadius:2,margin:'0 auto 14px' }}/>}
|
||||
<div style={{ display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:16 }}>
|
||||
<div style={{ ...S.head, marginBottom:0 }}>{card ? 'KARTE BEARBEITEN' : 'NEUE KARTE'}</div>
|
||||
<button onClick={onClose} style={{ background:'transparent',border:'none',color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:18 }}>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollbarer Inhalt */}
|
||||
<div style={{ overflowY:'auto',padding:'0 20px',flex:1,scrollbarWidth:'thin',scrollbarColor:'rgba(255,255,255,0.08) transparent' }}>
|
||||
<input ref={titleRef} value={title} onChange={e=>setTitle(e.target.value)} onKeyDown={onKey}
|
||||
placeholder="Titel" style={{ ...S.inp, marginBottom:10 }} />
|
||||
|
||||
<textarea value={desc} onChange={e=>setDesc(e.target.value)} onKeyDown={onKey}
|
||||
placeholder="Beschreibung (optional)" rows={3}
|
||||
style={{ ...S.inp, resize:'vertical', marginBottom:14 }} />
|
||||
|
||||
{/* Priorität */}
|
||||
<div style={{ marginBottom:14 }}>
|
||||
<div style={{ ...S.head, marginBottom:8 }}>PRIORITÄT</div>
|
||||
<div style={{ display:'flex', gap:6, flexWrap:'wrap' }}>
|
||||
{PRIORITIES.map(p => (
|
||||
<button key={p.id} onClick={()=>setPrio(p.id)} style={{
|
||||
...S.btn(p.color, true),
|
||||
background: prio===p.id ? `${p.color}28` : `${p.color}0e`,
|
||||
borderColor: prio===p.id ? p.color : `${p.color}35`,
|
||||
fontWeight: prio===p.id ? 700 : 400,
|
||||
}}>{p.label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Verschieben nach */}
|
||||
<div style={{ marginBottom:14 }}>
|
||||
<div style={{ ...S.head, marginBottom:8 }}>{card ? 'VERSCHIEBEN NACH' : 'IN SPALTE'}</div>
|
||||
{!showNewCol ? (
|
||||
<div style={{ display:'flex', gap:6, flexWrap:'wrap' }}>
|
||||
{columns.map(c => (
|
||||
<button key={c.id} onClick={()=>setMoveToCol(c.id)} style={{
|
||||
...S.btn(c.color || '#4ecdc4', true),
|
||||
background: moveToCol===c.id ? `${c.color||'#4ecdc4'}28` : `${c.color||'#4ecdc4'}0e`,
|
||||
borderColor: moveToCol===c.id ? (c.color||'#4ecdc4') : `${c.color||'#4ecdc4'}35`,
|
||||
fontWeight: moveToCol===c.id ? 700 : 400,
|
||||
}}>{c.title}</button>
|
||||
))}
|
||||
<button onClick={()=>setShowNewCol(true)} style={S.btn('#888888', true)}>+ Neue Spalte</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display:'flex', gap:6 }}>
|
||||
<input ref={newColRef} value={newColName} onChange={e=>setNewColName(e.target.value)}
|
||||
onKeyDown={e=>{ if(e.key==='Escape'){setShowNewCol(false);setNewColName('');} }}
|
||||
placeholder="Name der neuen Spalte"
|
||||
style={{ ...S.inp, flex:1, fontSize:12 }} />
|
||||
<button onClick={()=>{setShowNewCol(false);setNewColName('');}} style={S.btn('#888888',true)}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Aktionsbuttons – immer sichtbar am unteren Rand, nicht scrollend */}
|
||||
<div style={{ padding:'14px 20px 24px', flexShrink:0, borderTop:'1px solid rgba(255,255,255,0.07)',
|
||||
display:'flex', gap:8, justifyContent:'flex-end' }}>
|
||||
<button onClick={onClose} style={S.btn('#888888', true)}>Abbrechen</button>
|
||||
<button onClick={save} disabled={!title.trim()||saving} style={S.btn('#4ecdc4', true)}>
|
||||
{saving ? '…' : card ? 'Speichern' : 'Erstellen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Spalten-Header ────────────────────────────────────────────────────────────
|
||||
function ColumnHeader({ col, onUpdate, onDelete, toast }) {
|
||||
const [editTitle, setEditTitle] = useState(false);
|
||||
const [titleVal, setTitleVal] = useState(col.title);
|
||||
const [showColor, setShowColor] = useState(false);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
useEffect(() => { if (editTitle) inputRef.current?.focus(); }, [editTitle]);
|
||||
|
||||
const commitTitle = async () => {
|
||||
setEditTitle(false);
|
||||
if (titleVal.trim() && titleVal.trim() !== col.title) {
|
||||
try { await onUpdate(col.id, { title: titleVal.trim() }); }
|
||||
catch (e) { toast(e.message, 'error'); setTitleVal(col.title); }
|
||||
} else { setTitleVal(col.title); }
|
||||
};
|
||||
|
||||
const setColor = async (color) => {
|
||||
setShowColor(false);
|
||||
try { await onUpdate(col.id, { color }); }
|
||||
catch (e) { toast(e.message, 'error'); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom:12 }}>
|
||||
{/* Farbstreifen oben */}
|
||||
<div style={{ height:3,borderRadius:'2px 2px 0 0',background:col.color||'#4ecdc4',margin:'-12px -12px 10px',marginTop:-12 }}/>
|
||||
|
||||
<div style={{ display:'flex', alignItems:'center', gap:6 }}>
|
||||
{/* Farbpicker-Button */}
|
||||
<div style={{ position:'relative' }}>
|
||||
<button onClick={()=>setShowColor(v=>!v)} title="Farbe ändern"
|
||||
style={{ width:16,height:16,borderRadius:'50%',background:col.color||'#4ecdc4',
|
||||
border:'2px solid rgba(255,255,255,0.2)',cursor:'pointer',flexShrink:0,padding:0 }}/>
|
||||
{showColor && (
|
||||
<div style={{ position:'absolute',top:22,left:0,zIndex:200,
|
||||
background:'#1a1a1e',border:'1px solid rgba(255,255,255,0.12)',
|
||||
borderRadius:10,padding:8,display:'flex',flexWrap:'wrap',gap:6,width:130 }}>
|
||||
{COL_COLORS.map(c => (
|
||||
<button key={c} onClick={()=>setColor(c)} style={{
|
||||
width:22,height:22,borderRadius:'50%',background:c,border:
|
||||
(col.color||'#4ecdc4')===c?'2px solid #fff':'2px solid transparent',
|
||||
cursor:'pointer',padding:0,transition:'border 0.1s',
|
||||
}}/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Titel */}
|
||||
{editTitle ? (
|
||||
<input ref={inputRef} value={titleVal} onChange={e=>setTitleVal(e.target.value)}
|
||||
onBlur={commitTitle}
|
||||
onKeyDown={e=>{ if(e.key==='Enter') commitTitle(); if(e.key==='Escape'){setTitleVal(col.title);setEditTitle(false);} }}
|
||||
style={{ ...S.inp, fontSize:12,fontWeight:700,padding:'3px 7px',flex:1 }} />
|
||||
) : (
|
||||
<span onDoubleClick={()=>setEditTitle(true)} title="Doppelklick zum Umbenennen"
|
||||
style={{ flex:1,fontSize:12,fontWeight:700,fontFamily:'monospace',color:'#fff',
|
||||
cursor:'pointer',overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap' }}>
|
||||
{col.title}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span style={{ color:'rgba(255,255,255,0.3)',fontSize:11,fontFamily:'monospace',flexShrink:0 }}>
|
||||
{col.cards.length}
|
||||
</span>
|
||||
<button onClick={()=>onDelete(col.id)} title="Spalte löschen"
|
||||
style={{ background:'transparent',border:'none',color:'rgba(255,255,255,0.2)',
|
||||
cursor:'pointer',fontSize:13,padding:'0 2px',lineHeight:1,flexShrink:0 }}>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Einzelne Karte ─────────────────────────────────────────────────────────────
|
||||
function KanbanCard({ card, col, allCols, onEdit, onDelete, onMove, isDragging, dragHandlers }) {
|
||||
const prio = prioOf(card.priority);
|
||||
const colIdx = allCols.findIndex(c => c.id === col.id);
|
||||
const cardIdx = col.cards.findIndex(c => c.id === card.id);
|
||||
const isFirst = cardIdx === 0;
|
||||
const isLast = cardIdx === col.cards.length - 1;
|
||||
const isFirstCol = colIdx === 0;
|
||||
const isLastCol = colIdx === allCols.length - 1;
|
||||
|
||||
const arrowBtn = (label, disabled, onClick) => (
|
||||
<button onClick={disabled ? undefined : onClick} disabled={disabled}
|
||||
style={{ background:'transparent',border:'none',padding:'1px 4px',lineHeight:1,fontSize:13,
|
||||
color: disabled ? 'rgba(255,255,255,0.1)' : 'rgba(255,255,255,0.4)',
|
||||
cursor: disabled ? 'default' : 'pointer' }}>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div {...dragHandlers} style={{
|
||||
background: isDragging ? 'rgba(78,205,196,0.06)' : 'rgba(255,255,255,0.04)',
|
||||
border: `1px solid ${isDragging ? '#4ecdc4' : 'rgba(255,255,255,0.08)'}`,
|
||||
borderLeft: `3px solid ${prio.color}`,
|
||||
borderRadius:8, padding:'10px 10px 8px', marginBottom:6,
|
||||
cursor:'grab', opacity: isDragging ? 0.45 : 1,
|
||||
transition:'border-color 0.12s, opacity 0.12s', userSelect:'none',
|
||||
}}>
|
||||
{/* Titel-Zeile */}
|
||||
<div style={{ display:'flex',alignItems:'flex-start',gap:6,marginBottom:card.description?6:8 }}>
|
||||
<span style={{ flex:1,fontSize:13,fontFamily:'monospace',color:'#fff',wordBreak:'break-word',lineHeight:1.4 }}>
|
||||
{card.title}
|
||||
</span>
|
||||
<div style={{ display:'flex',gap:3,flexShrink:0 }}>
|
||||
<button onClick={()=>onEdit(card)} title="Bearbeiten"
|
||||
style={{ background:'transparent',border:'none',color:'rgba(255,255,255,0.35)',
|
||||
cursor:'pointer',fontSize:12,padding:'1px 3px',lineHeight:1 }}>✎</button>
|
||||
<button onClick={()=>onDelete(card.id)} title="Löschen"
|
||||
style={{ background:'transparent',border:'none',color:'rgba(255,255,255,0.2)',
|
||||
cursor:'pointer',fontSize:12,padding:'1px 3px',lineHeight:1 }}>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Beschreibung */}
|
||||
{card.description && (
|
||||
<div style={{ fontSize:11,color:'rgba(255,255,255,0.4)',fontFamily:'monospace',
|
||||
lineHeight:1.4,whiteSpace:'pre-wrap',wordBreak:'break-word',marginBottom:8 }}>
|
||||
{card.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Priorität + Pfeile */}
|
||||
<div style={{ display:'flex',alignItems:'center',justifyContent:'space-between',gap:4 }}>
|
||||
{prio.id !== 'none' ? (
|
||||
<span style={{ fontSize:9,fontFamily:'monospace',letterSpacing:1,
|
||||
color:prio.color, background:`${prio.color}15`,
|
||||
border:`1px solid ${prio.color}30`, borderRadius:4, padding:'1px 5px' }}>
|
||||
{prio.label.toUpperCase()}
|
||||
</span>
|
||||
) : <span/>}
|
||||
|
||||
<div style={{ display:'flex',gap:0,flexShrink:0 }}>
|
||||
{arrowBtn('↑', isFirst, ()=>onMove(card.id, col.id, cardIdx-1))}
|
||||
{arrowBtn('↓', isLast, ()=>onMove(card.id, col.id, cardIdx+1))}
|
||||
{arrowBtn('←', isFirstCol, ()=>onMove(card.id, allCols[colIdx-1]?.id))}
|
||||
{arrowBtn('→', isLastCol, ()=>onMove(card.id, allCols[colIdx+1]?.id))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Erstellungsdatum */}
|
||||
{card.created_at && (
|
||||
<div style={{ fontSize:9,fontFamily:'monospace',color:'rgba(255,255,255,0.2)',
|
||||
marginTop:6, textAlign:'right', letterSpacing:0.5 }}>
|
||||
{fmtCardDate(card.created_at)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Hauptkomponente ───────────────────────────────────────────────────────────
|
||||
export default function Kanban({ toast, mobile }) {
|
||||
const [columns, setColumns] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modal, setModal] = useState(null); // null | { card?, columnId }
|
||||
const [newColInput, setNewColInput] = useState('');
|
||||
const [addingCol, setAddingCol] = useState(false);
|
||||
const [dragCardId, setDragCardId] = useState(null);
|
||||
const [dragOverCol, setDragOverCol] = useState(null);
|
||||
const [dragOverPos, setDragOverPos] = useState(null);
|
||||
const newColRef = useRef(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
api('/tools/kanban/board')
|
||||
.then(d => setColumns(d.columns || []))
|
||||
.catch(() => toast('Fehler beim Laden', 'error'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
useEffect(() => { if (addingCol) newColRef.current?.focus(); }, [addingCol]);
|
||||
|
||||
// ── Spalten-Aktionen ────────────────────────────────────────────────────────
|
||||
const addColumn = async () => {
|
||||
if (!newColInput.trim()) return;
|
||||
try {
|
||||
await api('/tools/kanban/columns', { body: { title: newColInput.trim() } });
|
||||
setNewColInput(''); setAddingCol(false); load();
|
||||
} catch (e) { toast(e.message, 'error'); }
|
||||
};
|
||||
|
||||
const updateColumn = async (id, patch) => {
|
||||
await api(`/tools/kanban/columns/${id}`, { method:'PATCH', body: patch });
|
||||
load();
|
||||
};
|
||||
|
||||
const deleteColumn = async (id) => {
|
||||
const col = columns.find(c => c.id === id);
|
||||
const msg = col?.cards?.length
|
||||
? `Spalte "${col.title}" und alle ${col.cards.length} Karte(n) löschen?`
|
||||
: `Spalte "${col?.title}" löschen?`;
|
||||
if (!confirm(msg)) return;
|
||||
try { await api(`/tools/kanban/columns/${id}`, { method:'DELETE' }); load(); }
|
||||
catch (e) { toast(e.message, 'error'); }
|
||||
};
|
||||
|
||||
// ── Karten-Aktionen ─────────────────────────────────────────────────────────
|
||||
const deleteCard = async (id) => {
|
||||
if (!confirm('Karte löschen?')) return;
|
||||
try { await api(`/tools/kanban/cards/${id}`, { method:'DELETE' }); load(); }
|
||||
catch (e) { toast(e.message, 'error'); }
|
||||
};
|
||||
|
||||
const moveCard = async (cardId, colIdOrPos, posOrUndef) => {
|
||||
// Aufruf-Varianten:
|
||||
// onMove(id, col.id, newPos) → Position innerhalb Spalte
|
||||
// onMove(id, targetColId) → Spalte wechseln, ans Ende
|
||||
try {
|
||||
if (posOrUndef !== undefined) {
|
||||
await api(`/tools/kanban/cards/${cardId}/move`, { body:{ column_id:colIdOrPos, position:posOrUndef } });
|
||||
} else {
|
||||
await api(`/tools/kanban/cards/${cardId}/move`, { body:{ column_id:colIdOrPos } });
|
||||
}
|
||||
load();
|
||||
} catch (e) { toast(e.message, 'error'); }
|
||||
};
|
||||
|
||||
// ── Drag & Drop ─────────────────────────────────────────────────────────────
|
||||
const onDragStart = (e, cardId) => {
|
||||
setDragCardId(cardId);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
const onDragEnd = () => { setDragCardId(null); setDragOverCol(null); setDragOverPos(null); };
|
||||
|
||||
const onDragOverCard = (e, colId, pos) => {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
setDragOverCol(colId); setDragOverPos(pos);
|
||||
};
|
||||
const onDragOverColBg = (e, colId) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
const col = columns.find(c => c.id === colId);
|
||||
setDragOverCol(colId); setDragOverPos(col ? col.cards.length : 0);
|
||||
};
|
||||
const onDrop = async (e, colId, pos) => {
|
||||
e.preventDefault();
|
||||
if (!dragCardId) return;
|
||||
const targetPos = pos !== undefined ? pos : dragOverPos;
|
||||
setDragCardId(null); setDragOverCol(null); setDragOverPos(null);
|
||||
try {
|
||||
await api(`/tools/kanban/cards/${dragCardId}/move`, { body:{ column_id:colId, position:targetPos } });
|
||||
load();
|
||||
} catch (err) { toast(err.message, 'error'); }
|
||||
};
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────────
|
||||
if (loading) return (
|
||||
<div style={{ padding: mobile ? '14px 14px 90px' : '36px 44px' }}>
|
||||
<div style={{ color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:12 }}>Lädt…</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ padding: mobile ? '14px 14px 90px' : '36px 44px', height:'100%', boxSizing:'border-box', display:'flex', flexDirection:'column' }}>
|
||||
|
||||
{/* ── Header ─────────────────────────────────────────────────────────── */}
|
||||
<div style={{ display:'flex', alignItems:'center', gap:10, marginBottom:24, flexWrap:'wrap' }}>
|
||||
<h2 style={{ margin:0, fontSize:15, fontFamily:'monospace', color:'rgba(255,255,255,0.55)', letterSpacing:2, fontWeight:400 }}>
|
||||
KANBAN BOARD
|
||||
</h2>
|
||||
<div style={{ flex:1 }}/>
|
||||
{addingCol ? (
|
||||
<div style={{ display:'flex', gap:6 }}>
|
||||
<input ref={newColRef} value={newColInput} onChange={e=>setNewColInput(e.target.value)}
|
||||
onKeyDown={e=>{ if(e.key==='Enter') addColumn(); if(e.key==='Escape'){setAddingCol(false);setNewColInput('');} }}
|
||||
placeholder="Spaltenname" style={{ ...S.inp, width: mobile?140:180, fontSize:12 }} />
|
||||
<button onClick={addColumn} style={S.btn('#4ecdc4', true)}>Hinzufügen</button>
|
||||
<button onClick={()=>{setAddingCol(false);setNewColInput('');}} style={S.btn('#888888', true)}>✕</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={()=>setAddingCol(true)} style={S.btn('#4ecdc4', true)}>+ Spalte</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Board ──────────────────────────────────────────────────────────── */}
|
||||
{columns.length === 0 ? (
|
||||
<div style={{ ...S.card, textAlign:'center', padding:40 }}>
|
||||
<div style={{ color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:12 }}>
|
||||
Noch keine Spalten. Erstelle eine Spalte um loszulegen.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display:'flex', gap:14, overflowX:'auto', flex:1,
|
||||
paddingBottom:12, scrollbarWidth:'thin', scrollbarColor:'rgba(255,255,255,0.08) transparent' }}>
|
||||
{columns.map(col => (
|
||||
<div key={col.id}
|
||||
onDragOver={e=>onDragOverColBg(e, col.id)}
|
||||
onDrop={e=>onDrop(e, col.id, col.cards.length)}
|
||||
style={{
|
||||
...S.card,
|
||||
minWidth: mobile ? 260 : 280,
|
||||
maxWidth: 320,
|
||||
width: mobile ? 260 : 280,
|
||||
flexShrink:0,
|
||||
display:'flex', flexDirection:'column',
|
||||
padding:12, overflow:'hidden',
|
||||
border:`1px solid ${dragOverCol===col.id && dragCardId ? (col.color||'#4ecdc4')+'66' : 'rgba(255,255,255,0.07)'}`,
|
||||
transition:'border-color 0.15s',
|
||||
}}>
|
||||
|
||||
<ColumnHeader col={col} onUpdate={updateColumn} onDelete={deleteColumn} toast={toast}/>
|
||||
|
||||
{/* Karten-Liste */}
|
||||
<div style={{ flex:1, overflowY:'auto', scrollbarWidth:'thin', scrollbarColor:'rgba(255,255,255,0.08) transparent' }}>
|
||||
{col.cards.map((card, cardIdx) => (
|
||||
<div key={card.id}
|
||||
onDragOver={e=>onDragOverCard(e, col.id, cardIdx)}
|
||||
onDrop={e=>{ e.stopPropagation(); onDrop(e, col.id, cardIdx); }}>
|
||||
{dragCardId && dragOverCol===col.id && dragOverPos===cardIdx && (
|
||||
<div style={{ height:3,background:col.color||'#4ecdc4',borderRadius:2,marginBottom:4,opacity:0.7 }}/>
|
||||
)}
|
||||
<KanbanCard
|
||||
card={card} col={col} allCols={columns}
|
||||
onEdit={card=>setModal({ card, columnId:col.id })}
|
||||
onDelete={deleteCard}
|
||||
onMove={moveCard}
|
||||
isDragging={dragCardId===card.id}
|
||||
dragHandlers={{ draggable:true, onDragStart:e=>onDragStart(e,card.id), onDragEnd }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{dragCardId && dragOverCol===col.id && dragOverPos===col.cards.length && (
|
||||
<div style={{ height:3,background:col.color||'#4ecdc4',borderRadius:2,marginTop:2,opacity:0.7 }}/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Karte hinzufügen */}
|
||||
<button onClick={()=>setModal({ card:null, columnId:col.id })}
|
||||
style={{ ...S.btn(col.color||'#4ecdc4', true), width:'100%', marginTop:10, textAlign:'center' }}>
|
||||
+ Karte
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Modal ──────────────────────────────────────────────────────────── */}
|
||||
{modal && (
|
||||
<CardModal
|
||||
card={modal.card}
|
||||
columnId={modal.columnId}
|
||||
columns={columns}
|
||||
onSave={()=>{ setModal(null); load(); }}
|
||||
onClose={()=>setModal(null)}
|
||||
toast={toast}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
274
frontend/src/tools/koepi.jsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api, S } from '../lib.js';
|
||||
|
||||
const GOLD = '#f59e0b';
|
||||
|
||||
function getMyRole() {
|
||||
try { return JSON.parse(atob(localStorage.getItem('sk_token').split('.')[1])).role; } catch { return null; }
|
||||
}
|
||||
|
||||
function publisherColor(name) {
|
||||
if (!name) return '#555';
|
||||
const n = name.toLowerCase();
|
||||
if (n.includes('rewe')) return '#e2001a';
|
||||
if (n.includes('edeka') || n.includes('e center')) return '#e8a800';
|
||||
if (n.includes('netto')) return '#0057a8';
|
||||
if (n.includes('trinkgut')) return '#e87722';
|
||||
if (n.includes('penny')) return '#e2001a';
|
||||
if (n.includes('kaufland')) return '#e2001a';
|
||||
return '#888';
|
||||
}
|
||||
|
||||
function fmtDate(str) {
|
||||
if (!str) return null;
|
||||
try { return new Date(str).toLocaleDateString('de-DE', { day:'2-digit', month:'2-digit', year:'numeric' }); }
|
||||
catch { return str; }
|
||||
}
|
||||
|
||||
// ── Angebots-Karte (marktguru) ────────────────────────────────────────────────
|
||||
function OfferCard({ offer }) {
|
||||
const color = publisherColor(offer.retailer);
|
||||
return (
|
||||
<div style={{
|
||||
...S.card, padding:'12px 14px',
|
||||
border:'1px solid rgba(255,255,255,0.1)',
|
||||
display:'flex', flexDirection:'column', gap:8,
|
||||
}}>
|
||||
{/* Bild oben */}
|
||||
{offer.imageLocal && (
|
||||
<div style={{ margin:'-12px -14px 8px -14px', borderRadius:'8px 8px 0 0', background:'transparent', height:130, display:'flex', alignItems:'center', justifyContent:'center' }}>
|
||||
<img src={offer.imageLocal + '?v=4'} alt="König Pilsener"
|
||||
style={{ maxWidth:'90%', maxHeight:'130px', width:'auto', height:'auto', display:'block', objectFit:'contain' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Händler + Adresse */}
|
||||
<div style={{ display:'flex', alignItems:'center', gap:6, flexWrap:'wrap' }}>
|
||||
<span style={{
|
||||
background:`${color}22`, border:`1px solid ${color}55`,
|
||||
borderRadius:4, padding:'2px 7px',
|
||||
color, fontFamily:'monospace', fontSize:10, fontWeight:700,
|
||||
}}>{offer.retailer}</span>
|
||||
{offer.oldPrice && <span style={{ background:'#e2001a', color:'#fff', borderRadius:4, padding:'2px 6px', fontSize:9, fontFamily:'monospace', fontWeight:700 }}>-29%</span>}
|
||||
</div>
|
||||
{offer.address && (
|
||||
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:9 }}>
|
||||
📍 {offer.address}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Name + Beschreibung */}
|
||||
<div style={{ color:'#fff', fontFamily:'monospace', fontSize:13, fontWeight:600 }}>König Pilsener</div>
|
||||
{offer.description && (
|
||||
<div style={{ color:'rgba(255,255,255,0.45)', fontFamily:'monospace', fontSize:10, lineHeight:1.4 }}>
|
||||
{offer.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preis */}
|
||||
<div style={{ display:'flex', alignItems:'baseline', gap:8 }}>
|
||||
<span style={{ color:GOLD, fontFamily:'Space Mono,monospace', fontSize:22, fontWeight:700 }}>
|
||||
{offer.price}
|
||||
</span>
|
||||
{offer.oldPrice && (
|
||||
<span style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:12, textDecoration:'line-through' }}>
|
||||
{offer.oldPrice}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Gültigkeit */}
|
||||
<div style={{ borderTop:'1px solid rgba(255,255,255,0.06)', paddingTop:6, display:'flex', gap:12, flexWrap:'wrap' }}>
|
||||
{offer.dateRange && (
|
||||
<span style={{ color:'rgba(255,255,255,0.35)', fontFamily:'monospace', fontSize:10 }}>📅 {offer.dateRange}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Prospekt-Karte (kaufda) ───────────────────────────────────────────────────
|
||||
function ProspektCard({ b }) {
|
||||
const color = publisherColor(b.publisher);
|
||||
const isNew = b.badges?.includes('new');
|
||||
const expiring = b.badges?.includes('expiring_soon');
|
||||
return (
|
||||
<a href={b.url} target="_blank" rel="noopener noreferrer" style={{ textDecoration:'none' }}>
|
||||
<div style={{
|
||||
...S.card, padding:0, overflow:'hidden', cursor:'pointer',
|
||||
border: expiring ? '1px solid rgba(239,68,68,0.4)' : '1px solid rgba(255,255,255,0.08)',
|
||||
}}>
|
||||
{b.image && (
|
||||
<img src={`/api/tools/koepi/img?url=${encodeURIComponent(b.image)}`}
|
||||
alt={b.title} style={{ width:'100%', height:110, objectFit:'cover', display:'block' }}
|
||||
onError={e => { e.target.style.display='none'; }}
|
||||
/>
|
||||
)}
|
||||
<div style={{ padding:'10px 12px' }}>
|
||||
<div style={{ display:'flex', alignItems:'center', gap:6, marginBottom:4, flexWrap:'wrap' }}>
|
||||
<div style={{ background:`${color}22`, border:`1px solid ${color}44`,
|
||||
borderRadius:4, padding:'2px 8px', color, fontFamily:'monospace', fontSize:10, fontWeight:700 }}>
|
||||
{b.publisher}
|
||||
</div>
|
||||
{expiring && <span style={{ background:'#ef4444', color:'#fff', borderRadius:4, padding:'2px 6px', fontSize:9, fontFamily:'monospace', fontWeight:700 }}>ENDET BALD</span>}
|
||||
</div>
|
||||
<div style={{ color:'#fff', fontFamily:'monospace', fontSize:11, marginBottom:3 }}>{b.title}</div>
|
||||
<div style={{ color:'rgba(255,255,255,0.4)', fontFamily:'monospace', fontSize:10, marginBottom:2 }}>
|
||||
📍 {b.street || b.store}
|
||||
</div>
|
||||
{(b.validFrom || b.validTo) && (
|
||||
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:9, marginTop:4 }}>
|
||||
{b.validFrom && `Ab ${fmtDate(b.validFrom)}`}{b.validFrom&&b.validTo&&' · '}{b.validTo&&`Bis ${fmtDate(b.validTo)}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Hauptkomponente ───────────────────────────────────────────────────────────
|
||||
export default function Koepi({ toast }) {
|
||||
const [tab, setTab] = useState('angebote');
|
||||
const [offers, setOffers] = useState(null);
|
||||
const [prospekte, setProspekte] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const isAdmin = getMyRole() === 'admin';
|
||||
|
||||
const loadOffers = async (force=false) => {
|
||||
if (offers && !force) return;
|
||||
setLoading(true);
|
||||
try { setOffers(await api('/tools/koepi/offers')); }
|
||||
catch(e) { toast?.(e.message||'Fehler','error'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
const loadProspekte = async (force=false) => {
|
||||
if (prospekte && !force) return;
|
||||
setLoading(true);
|
||||
try { setProspekte(await api('/tools/koepi/prospekte')); }
|
||||
catch(e) { toast?.(e.message||'Fehler','error'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (tab === 'angebote') loadOffers();
|
||||
else loadProspekte();
|
||||
}, [tab]);
|
||||
|
||||
const clearCache = async () => {
|
||||
try {
|
||||
await api('/tools/koepi/clear-cache', { body:{} });
|
||||
setOffers(null); setProspekte(null);
|
||||
toast('Cache geleert');
|
||||
setTimeout(() => { if (tab==='angebote') loadOffers(true); else loadProspekte(true); }, 100);
|
||||
} catch(e) { toast?.(e.message,'error'); }
|
||||
};
|
||||
|
||||
const [cronRunning, setCronRunning] = useState(false);
|
||||
const testDailyCheck = async () => {
|
||||
setCronRunning(true);
|
||||
try {
|
||||
const r = await api('/tools/koepi/run-daily-check', { method:'POST', body:{} });
|
||||
toast(`🔔 Test: ${r.offerCount} Angebot(e) gefunden, Pushover ${r.sent ? 'versendet' : 'nicht versendet'}${r.changed ? ' (Änderung erkannt)' : ' (unverändert)'}`);
|
||||
} catch(e) { toast?.(e.message||'Fehler','error'); }
|
||||
finally { setCronRunning(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth:700, margin:'0 auto' }}>
|
||||
{/* Header */}
|
||||
<div style={{ display:'flex', alignItems:'center', gap:10, marginBottom:20 }}>
|
||||
<h2 style={{ margin:0, fontSize:15, fontFamily:'monospace', color:'rgba(255,255,255,0.55)', letterSpacing:2, fontWeight:400 }}>
|
||||
🍺 KÖPI — König Pilsener
|
||||
</h2>
|
||||
<div style={{ flex:1 }}/>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<button onClick={testDailyCheck} disabled={cronRunning} style={{ ...S.btn('#f59e0b',true), fontSize:10, opacity:cronRunning?0.5:1 }}>
|
||||
{cronRunning ? '⏳ läuft…' : '🔔 Cron testen'}
|
||||
</button>
|
||||
<button onClick={clearCache} style={{ ...S.btn('#666666',true), fontSize:10 }}>🗑 Cache leeren</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:10, marginBottom:14 }}>
|
||||
Raum Duisburg 47259 · EDEKA · REWE · Netto · Kaufland · Penny · Trinkgut · Cache 1h
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{ display:'flex', gap:8, marginBottom:20 }}>
|
||||
{[['angebote','🍺 Angebote'],['prospekte','📋 Prospekte']].map(([t,l]) => (
|
||||
<button key={t} onClick={()=>setTab(t)} style={{ ...S.btn(tab===t?GOLD:'#444444',true), fontSize:12, padding:'6px 16px' }}>{l}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Laden */}
|
||||
{loading && (
|
||||
<div style={{ color:'rgba(255,255,255,0.4)', fontFamily:'monospace', fontSize:13, textAlign:'center', padding:'40px 0' }}>
|
||||
⏳ Lade… (kann bis zu 20 Sekunden dauern)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Angebote */}
|
||||
{!loading && tab==='angebote' && offers && (
|
||||
<div>
|
||||
{offers.offers?.length > 0 ? (
|
||||
<>
|
||||
<div style={{ ...S.head, marginBottom:12 }}>
|
||||
{offers.offers.length} AKTUELLE ANGEBOTE · Quelle: marktguru.de
|
||||
</div>
|
||||
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fill,minmax(180px,1fr))', gap:14 }}>
|
||||
{offers.offers.map((o,i) => <OfferCard key={i} offer={o}/>)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:13, textAlign:'center', padding:'40px 0' }}>
|
||||
Aktuell keine König Pilsener Angebote bei deinen Märkten gefunden.
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop:20, textAlign:'center' }}>
|
||||
<a href="https://www.marktguru.de/search/k%C3%B6nig%20pilsener?zipCode=47259" target="_blank" rel="noopener noreferrer"
|
||||
style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:10 }}>
|
||||
→ Alle Angebote auf marktguru.de
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Prospekte */}
|
||||
{!loading && tab==='prospekte' && prospekte && (
|
||||
<div>
|
||||
{prospekte.targeted?.length > 0 && (
|
||||
<>
|
||||
<div style={{ ...S.head, marginBottom:12 }}>DEINE MÄRKTE ({prospekte.targeted.length})</div>
|
||||
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fill,minmax(180px,1fr))', gap:12, marginBottom:16 }}>
|
||||
{prospekte.targeted.map(b=><ProspektCard key={b.id} b={b}/>)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{prospekte.others?.length > 0 && (
|
||||
<>
|
||||
<button onClick={()=>setShowAll(v=>!v)} style={{ ...S.btn('#444444',true), marginBottom:10, fontSize:11 }}>
|
||||
{showAll ? '▾ Weniger' : `▸ Weitere Prospekte (${prospekte.others.length})`}
|
||||
</button>
|
||||
{showAll && (
|
||||
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fill,minmax(180px,1fr))', gap:12 }}>
|
||||
{prospekte.others.map(b=><ProspektCard key={b.id} b={b}/>)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!prospekte.targeted?.length && !prospekte.others?.length && (
|
||||
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:13, textAlign:'center', padding:'40px 0' }}>
|
||||
Keine Prospekte gefunden.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
728
frontend/src/tools/linkliste.jsx
Normal file
@@ -0,0 +1,728 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { api, S } from '../lib.js';
|
||||
|
||||
const LINK_ICONS = ['🔗','📄','🌐','📊','🎯','⚙️','📁','🔧','📱','💡','🏠','🛒','📺','🎮','🎵','📧','💬','📰','🔬','🏥','🏦','✈️','🚀','🔐','📂','⭐','🔑','💻','🖥','📡'];
|
||||
const FOLDER_ICONS = ['📁','📂','🗂','💼','🗃','📦','🏷','🔖','📋','📌','🗓','🖇'];
|
||||
|
||||
// ── Alle DockStation-Tools & Unterfunktionen ──────────────────────────────────
|
||||
const TOOL_ENTRIES = [
|
||||
{ url:'tool://statistik', label:'📊 Statistik', group:'3D-Druck' },
|
||||
{ url:'tool://bestellungen', label:'📦 Bestellungen', group:'3D-Druck' },
|
||||
{ url:'tool://kalkulator3d', label:'🧮 Kostenrechner', group:'3D-Druck' },
|
||||
{ url:'tool://kanban', label:'🗂 Kanban', group:'Werkzeuge' },
|
||||
{ url:'tool://whiteboard', label:'🖊 Whiteboard', group:'Werkzeuge' },
|
||||
{ url:'tool://dateien', label:'📁 Dateien', group:'Werkzeuge' },
|
||||
{ url:'tool://nachrichten', label:'💬 Nachrichten', group:'Werkzeuge' },
|
||||
{ url:'tool://linkliste', label:'🔗 Linkliste', group:'Werkzeuge' },
|
||||
{ url:'tool://codeschnipsel', label:'</> Code-Schnipsel', group:'Werkzeuge' },
|
||||
{ url:'tool://media', label:'🎬 Media / Kino', group:'Freizeit' },
|
||||
{ url:'tool://gebietseroberung', label:'⬡ Hex Wars', group:'Freizeit' },
|
||||
{ url:'tool://koepi', label:'🍺 KöPi', group:'Freizeit' },
|
||||
{ url:'tool://devtools', label:'🔧 Dev-Tools (alle)', group:'Dev-Tools' },
|
||||
{ url:'tool://devtools?sub=cron', label:'⏱ Crontab', group:'Dev-Tools' },
|
||||
{ url:'tool://devtools?sub=elektro', label:'⚡ Elektro', group:'Dev-Tools' },
|
||||
{ url:'tool://devtools?sub=json', label:'{ } JSON', group:'Dev-Tools' },
|
||||
{ url:'tool://devtools?sub=regex', label:'🔍 Regex', group:'Dev-Tools' },
|
||||
{ url:'tool://devtools?sub=diff', label:'± Text Diff', group:'Dev-Tools' },
|
||||
{ url:'tool://devtools?sub=netzwerk', label:'🌐 Netzwerk', group:'Dev-Tools' },
|
||||
{ url:'tool://devtools?sub=datetime', label:'📅 Datetime', group:'Dev-Tools' },
|
||||
{ url:'tool://devtools?sub=farben', label:'🎨 Farben', group:'Dev-Tools' },
|
||||
{ url:'tool://devtools?sub=keygen', label:'🔐 Key-Gen', group:'Dev-Tools' },
|
||||
{ url:'tool://devtools?sub=qrcode', label:'◻ QR-Code', group:'Dev-Tools' },
|
||||
];
|
||||
|
||||
// ── Tool-Picker Modal ─────────────────────────────────────────────────────────
|
||||
function ToolPicker({ folders, onSave, onClose, toast }) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [folderId, setFolderId] = useState(null);
|
||||
const [saving, setSaving] = useState(null);
|
||||
const mob = isMob();
|
||||
|
||||
const filtered = TOOL_ENTRIES.filter(t =>
|
||||
!search || t.label.toLowerCase().includes(search.toLowerCase()) || t.group.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
const groups = [...new Set(filtered.map(t => t.group))];
|
||||
|
||||
const pick = async (entry) => {
|
||||
setSaving(entry.url);
|
||||
try {
|
||||
await onSave({ title: entry.label.replace(/^[^\s]+\s/, ''), url: entry.url, icon: entry.label.split(' ')[0], folder_id: folderId, in_quickaccess: 1 });
|
||||
toast(`${entry.label} zum Schnellzugriff hinzugefügt ✓`);
|
||||
onClose();
|
||||
} catch(e) { toast(e.message, 'error'); setSaving(null); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div onClick={e=>e.target===e.currentTarget&&onClose()}
|
||||
style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.75)',zIndex:6000,
|
||||
display:'flex',alignItems:mob?'flex-end':'center',justifyContent:'center',padding:mob?0:20}}>
|
||||
<div style={{background:'#1a1a1e',border:'1px solid rgba(255,255,255,0.12)',
|
||||
borderRadius:mob?'16px 16px 0 0':14,width:'100%',maxWidth:480,
|
||||
display:'flex',flexDirection:'column',maxHeight:mob?'85vh':'80vh',
|
||||
paddingBottom:mob?'calc(56px + env(safe-area-inset-bottom,0px))':0}}>
|
||||
|
||||
{/* Header */}
|
||||
<div style={{padding:'18px 20px 0',flexShrink:0}}>
|
||||
{mob && <div style={{width:36,height:4,background:'rgba(255,255,255,0.15)',borderRadius:2,margin:'0 auto 14px'}}/>}
|
||||
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:12}}>
|
||||
<div style={{...S.head,marginBottom:0}}>APP HINZUFÜGEN</div>
|
||||
<button onClick={onClose} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:18}}>✕</button>
|
||||
</div>
|
||||
{/* Ordner-Auswahl */}
|
||||
{folders.length > 0 && (
|
||||
<div style={{marginBottom:10}}>
|
||||
<div style={{fontSize:9,fontFamily:'monospace',color:'rgba(255,255,255,0.35)',letterSpacing:1,marginBottom:5}}>IN ORDNER (optional)</div>
|
||||
<div style={{display:'flex',gap:5,flexWrap:'wrap'}}>
|
||||
<button onClick={()=>setFolderId(null)} style={{
|
||||
...S.btn(folderId===null?'#4ecdc4':'#888888',true),fontSize:10,padding:'3px 8px'}}>
|
||||
Kein Ordner
|
||||
</button>
|
||||
{folders.map(f=>(
|
||||
<button key={f.id} onClick={()=>setFolderId(f.id)} style={{
|
||||
...S.btn(folderId===f.id?'#4ecdc4':'#888888',true),fontSize:10,padding:'3px 8px'}}>
|
||||
{f.icon||'📁'} {f.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<input value={search} onChange={e=>setSearch(e.target.value)}
|
||||
placeholder="App oder Funktion suchen…"
|
||||
style={{...S.inp,marginBottom:10,fontSize:12}}/>
|
||||
</div>
|
||||
|
||||
{/* Liste */}
|
||||
<div style={{overflowY:'auto',padding:'0 20px 20px',scrollbarWidth:'thin',scrollbarColor:'rgba(255,255,255,0.08) transparent'}}>
|
||||
{groups.map(group=>(
|
||||
<div key={group} style={{marginBottom:10}}>
|
||||
<div style={{fontSize:9,fontFamily:'monospace',color:'rgba(255,255,255,0.3)',letterSpacing:1,marginBottom:5,paddingTop:4}}>
|
||||
{group.toUpperCase()}
|
||||
</div>
|
||||
{filtered.filter(t=>t.group===group).map(entry=>(
|
||||
<button key={entry.url} onClick={()=>pick(entry)} disabled={!!saving}
|
||||
style={{display:'flex',alignItems:'center',gap:10,width:'100%',
|
||||
background:saving===entry.url?'rgba(78,205,196,0.1)':'rgba(255,255,255,0.03)',
|
||||
border:'1px solid rgba(255,255,255,0.07)',borderRadius:8,
|
||||
padding:'9px 12px',marginBottom:4,cursor:'pointer',textAlign:'left',
|
||||
transition:'background 0.12s'}}>
|
||||
<span style={{fontSize:16,flexShrink:0}}>{entry.label.split(' ')[0]}</span>
|
||||
<span style={{flex:1,fontSize:12,fontFamily:'monospace',color:'#fff'}}>
|
||||
{entry.label.replace(/^[^\s]+\s/, '')}
|
||||
</span>
|
||||
{saving===entry.url
|
||||
? <span style={{fontSize:10,color:'#4ecdc4',fontFamily:'monospace'}}>…</span>
|
||||
: <span style={{fontSize:10,color:'rgba(255,255,255,0.25)',fontFamily:'monospace'}}>+ hinzufügen</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function extIcon(url) {
|
||||
try { return `https://www.google.com/s2/favicons?sz=32&domain=${new URL(url).hostname}`; }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
function isMob() { return window.innerWidth < 768; }
|
||||
|
||||
// ── Folder Share Modal ────────────────────────────────────────────────────────
|
||||
function FolderShareModal({ folder, onClose, toast }) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [shares, setShares] = useState([]);
|
||||
const load = () => api(`/tools/linkliste/folders/${folder.id}/shares`).then(setShares).catch(()=>{});
|
||||
useEffect(()=>{ load(); },[]);
|
||||
const share = async () => {
|
||||
if (!username.trim()) return;
|
||||
try { await api(`/tools/linkliste/folders/${folder.id}/share`,{body:{username:username.trim()}}); toast('Geteilt ✓'); setUsername(''); load(); }
|
||||
catch(e) { toast(e.message,'error'); }
|
||||
};
|
||||
const unshare = async uid => {
|
||||
try { await api(`/tools/linkliste/folders/${folder.id}/share/${uid}`,{method:'DELETE'}); setShares(p=>p.filter(s=>s.id!==uid)); }
|
||||
catch(e) { toast(e.message,'error'); }
|
||||
};
|
||||
const mob = isMob();
|
||||
return (
|
||||
<div style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.8)',zIndex:6000,
|
||||
display:'flex',alignItems:mob?'flex-end':'center',justifyContent:'center',padding:mob?0:24}}
|
||||
onClick={e=>e.target===e.currentTarget&&onClose()}>
|
||||
<div style={{background:'#1a1a1e',borderRadius:mob?'16px 16px 0 0':14,width:'100%',maxWidth:400,
|
||||
padding:'20px 20px 28px',border:'1px solid rgba(255,255,255,0.12)'}}>
|
||||
{mob&&<div style={{width:36,height:4,background:'rgba(255,255,255,0.15)',borderRadius:2,margin:'0 auto 14px'}}/>}
|
||||
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:10}}>
|
||||
<div style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:13,fontWeight:700}}>
|
||||
🤝 {folder.icon} {folder.name}
|
||||
</div>
|
||||
<button onClick={onClose} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:18}}>✕</button>
|
||||
</div>
|
||||
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10,marginBottom:10}}>
|
||||
Ganzer Ordner inkl. aller Links wird geteilt
|
||||
</div>
|
||||
<div style={{display:'flex',gap:8,marginBottom:14}}>
|
||||
<input value={username} onChange={e=>setUsername(e.target.value)} onKeyDown={e=>e.key==='Enter'&&share()}
|
||||
placeholder="Benutzername" autoCapitalize="none" style={{...S.inp,flex:1}}/>
|
||||
<button onClick={share} style={S.btn('#4ecdc4')}>Teilen</button>
|
||||
</div>
|
||||
{shares.length>0 ? shares.map(s=>(
|
||||
<div key={s.id} style={{display:'flex',justifyContent:'space-between',alignItems:'center',
|
||||
padding:'8px 0',borderBottom:'1px solid rgba(255,255,255,0.05)'}}>
|
||||
<span style={{color:'rgba(255,255,255,0.7)',fontFamily:'monospace',fontSize:13}}>{s.username}</span>
|
||||
<button onClick={()=>unshare(s.id)} style={S.btn('#ff6b9d',true)}>✕</button>
|
||||
</div>
|
||||
)) : <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:11}}>Noch mit niemandem geteilt.</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Share Modal ───────────────────────────────────────────────────────────────
|
||||
function ShareModal({ link, onClose, toast }) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [shares, setShares] = useState([]);
|
||||
const load = () => api(`/tools/linkliste/${link.id}/shares`).then(setShares).catch(()=>{});
|
||||
useEffect(()=>{ load(); },[]);
|
||||
const share = async () => {
|
||||
if (!username.trim()) return;
|
||||
try { await api(`/tools/linkliste/${link.id}/share`,{body:{username:username.trim()}}); toast('Geteilt ✓'); setUsername(''); load(); }
|
||||
catch(e) { toast(e.message,'error'); }
|
||||
};
|
||||
const unshare = async uid => {
|
||||
try { await api(`/tools/linkliste/${link.id}/share/${uid}`,{method:'DELETE'}); setShares(p=>p.filter(s=>s.id!==uid)); }
|
||||
catch(e) { toast(e.message,'error'); }
|
||||
};
|
||||
const mob = isMob();
|
||||
return (
|
||||
<div style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.8)',zIndex:6000,
|
||||
display:'flex',alignItems:mob?'flex-end':'center',justifyContent:'center',padding:mob?0:24}}
|
||||
onClick={e=>e.target===e.currentTarget&&onClose()}>
|
||||
<div style={{background:'#1a1a1e',borderRadius:mob?'16px 16px 0 0':14,width:'100%',maxWidth:400,
|
||||
padding:'20px 20px 28px',border:'1px solid rgba(255,255,255,0.12)'}}>
|
||||
{mob&&<div style={{width:36,height:4,background:'rgba(255,255,255,0.15)',borderRadius:2,margin:'0 auto 14px'}}/>}
|
||||
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:10}}>
|
||||
<div style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:13,fontWeight:700}}>🤝 {link.title}</div>
|
||||
<button onClick={onClose} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:18}}>✕</button>
|
||||
</div>
|
||||
<div style={{display:'flex',gap:8,marginBottom:14}}>
|
||||
<input value={username} onChange={e=>setUsername(e.target.value)} onKeyDown={e=>e.key==='Enter'&&share()}
|
||||
placeholder="Benutzername" autoCapitalize="none" style={{...S.inp,flex:1}}/>
|
||||
<button onClick={share} style={S.btn('#4ecdc4')}>Teilen</button>
|
||||
</div>
|
||||
{shares.length>0 ? shares.map(s=>(
|
||||
<div key={s.id} style={{display:'flex',justifyContent:'space-between',alignItems:'center',
|
||||
padding:'8px 0',borderBottom:'1px solid rgba(255,255,255,0.05)'}}>
|
||||
<span style={{color:'rgba(255,255,255,0.7)',fontFamily:'monospace',fontSize:13}}>{s.username}</span>
|
||||
<button onClick={()=>unshare(s.id)} style={S.btn('#ff6b9d',true)}>✕</button>
|
||||
</div>
|
||||
)) : <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:11}}>Noch mit niemandem geteilt.</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Link Form ─────────────────────────────────────────────────────────────────
|
||||
function LinkForm({ initial, folders, onSave, onCancel, toast }) {
|
||||
const [form, setForm] = useState({ title:'', url:'', icon:'🔗', description:'', folder_id:null, ...initial });
|
||||
const [busy, setBusy] = useState(false);
|
||||
const set = (k,v) => setForm(p=>({...p,[k]:v}));
|
||||
const save = async () => {
|
||||
if (!form.title.trim() || !form.url.trim()) { toast('Titel und URL erforderlich','error'); return; }
|
||||
let url = form.url.trim();
|
||||
if (!/^https?:\/\//i.test(url)) url = 'https://' + url;
|
||||
setBusy(true);
|
||||
try { await onSave({...form, url}); }
|
||||
catch(e) { toast(e.message,'error'); }
|
||||
setBusy(false);
|
||||
};
|
||||
return (
|
||||
<div style={{...S.card,marginBottom:12}}>
|
||||
<div style={{...S.head,marginBottom:10}}>{initial?.id?'LINK BEARBEITEN':'NEUER LINK'}</div>
|
||||
<div style={{marginBottom:8}}>
|
||||
<div style={{...S.head,fontSize:9,marginBottom:4}}>ICON</div>
|
||||
<div style={{display:'flex',flexWrap:'wrap',gap:4,marginBottom:6}}>
|
||||
{LINK_ICONS.map(ic=>(
|
||||
<button key={ic} onClick={()=>set('icon',ic)} style={{
|
||||
width:32,height:32,borderRadius:7,fontSize:16,cursor:'pointer',border:'none',
|
||||
background:form.icon===ic?'rgba(78,205,196,0.2)':'rgba(255,255,255,0.05)',
|
||||
outline:form.icon===ic?'1px solid #4ecdc4':'none'}}>{ic}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<input value={form.title} onChange={e=>set('title',e.target.value)} placeholder="Titel *" style={{...S.inp,marginBottom:8}}/>
|
||||
<input value={form.url} onChange={e=>set('url',e.target.value)} placeholder="URL *" autoCapitalize="none" style={{...S.inp,marginBottom:8}}/>
|
||||
<input value={form.description} onChange={e=>set('description',e.target.value)} placeholder="Beschreibung (optional)" style={{...S.inp,marginBottom:8,fontSize:13}}/>
|
||||
{folders.length>0 && (
|
||||
<div style={{marginBottom:12}}>
|
||||
<div style={{...S.head,fontSize:9,marginBottom:4}}>IN ORDNER</div>
|
||||
<select value={form.folder_id||''} onChange={e=>set('folder_id',e.target.value?parseInt(e.target.value):null)}
|
||||
style={{...S.inp,cursor:'pointer'}}>
|
||||
<option value="">— Kein Ordner —</option>
|
||||
{folders.map(f=><option key={f.id} value={f.id}>{f.icon} {f.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div style={{display:'flex',gap:8}}>
|
||||
<button onClick={save} disabled={busy} style={{...S.btn('#4ecdc4'),flex:1,textAlign:'center',padding:'10px 0',opacity:busy?0.5:1}}>
|
||||
{busy?'…':'✓ Speichern'}
|
||||
</button>
|
||||
<button onClick={onCancel} style={{...S.btn('#ff6b9d',true),padding:'0 16px'}}>Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Folder Form ───────────────────────────────────────────────────────────────
|
||||
function FolderForm({ initial, onSave, onCancel, toast }) {
|
||||
const [form, setForm] = useState({ name:'', icon:'📁', ...initial });
|
||||
const [busy, setBusy] = useState(false);
|
||||
const save = async () => {
|
||||
if (!form.name.trim()) { toast('Name erforderlich','error'); return; }
|
||||
setBusy(true);
|
||||
try { await onSave(form); }
|
||||
catch(e) { toast(e.message,'error'); }
|
||||
setBusy(false);
|
||||
};
|
||||
return (
|
||||
<div style={{...S.card,marginBottom:12}}>
|
||||
<div style={{...S.head,marginBottom:10}}>{initial?.id?'ORDNER BEARBEITEN':'NEUER ORDNER'}</div>
|
||||
<div style={{marginBottom:8}}>
|
||||
<div style={{...S.head,fontSize:9,marginBottom:4}}>ICON</div>
|
||||
<div style={{display:'flex',flexWrap:'wrap',gap:4,marginBottom:6}}>
|
||||
{FOLDER_ICONS.map(ic=>(
|
||||
<button key={ic} onClick={()=>setForm(p=>({...p,icon:ic}))} style={{
|
||||
width:32,height:32,borderRadius:7,fontSize:16,cursor:'pointer',border:'none',
|
||||
background:form.icon===ic?'rgba(78,205,196,0.2)':'rgba(255,255,255,0.05)',
|
||||
outline:form.icon===ic?'1px solid #4ecdc4':'none'}}>{ic}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<input value={form.name} onChange={e=>setForm(p=>({...p,name:e.target.value}))} placeholder="Ordner-Name *"
|
||||
style={{...S.inp,marginBottom:12}}/>
|
||||
<div style={{display:'flex',gap:8}}>
|
||||
<button onClick={save} disabled={busy} style={{...S.btn('#4ecdc4'),flex:1,textAlign:'center',padding:'10px 0',opacity:busy?0.5:1}}>
|
||||
{busy?'…':'✓ Speichern'}
|
||||
</button>
|
||||
<button onClick={onCancel} style={{...S.btn('#ff6b9d',true),padding:'0 16px'}}>Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Component ────────────────────────────────────────────────────────────
|
||||
export default function Linkliste({ toast, mobile }) {
|
||||
const [own, setOwn] = useState([]);
|
||||
const [folders, setFolders] = useState([]);
|
||||
const [shared, setShared] = useState([]);
|
||||
const [byMe, setByMe] = useState([]);
|
||||
const [sharedFolders, setSharedFolders] = useState([]);
|
||||
const [sharedFoldersByMe, setSharedFoldersByMe] = useState([]);
|
||||
const [tab, setTab] = useState('own');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [showFolderForm, setShowFolderForm] = useState(false);
|
||||
const [showToolPicker, setShowToolPicker] = useState(false);
|
||||
const [editItem, setEditItem] = useState(null);
|
||||
const [editFolder,setEditFolder]= useState(null);
|
||||
const [shareItem, setShareItem] = useState(null);
|
||||
const [shareFolderItem, setShareFolderItem] = useState(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [expanded, setExpanded] = useState({}); // folder_id → bool
|
||||
const [sortMode, setSortMode] = useState(false);
|
||||
const dragRef = useRef(null);
|
||||
|
||||
const load = () => api('/tools/linkliste')
|
||||
.then(d=>{ setOwn(d.own||[]); setFolders(d.folders||[]); setShared(d.shared||[]); setByMe(d.sharedByMe||[]); setSharedFolders(d.sharedFolders||[]); setSharedFoldersByMe(d.sharedFoldersByMe||[]); })
|
||||
.catch(()=>{});
|
||||
|
||||
useEffect(()=>{ load(); },[]);
|
||||
|
||||
// ── CRUD Links ──────────────────────────────────────────────────────────────
|
||||
const addLink = async form => {
|
||||
const r = await api('/tools/linkliste',{body:form});
|
||||
setOwn(p=>[...p,r]); setShowForm(false);
|
||||
};
|
||||
const updateLink = async form => {
|
||||
const r = await api(`/tools/linkliste/${editItem.id}`,{method:'PUT',body:form});
|
||||
setOwn(p=>p.map(l=>l.id===r.id?r:l)); setEditItem(null);
|
||||
};
|
||||
const deleteLink = async id => {
|
||||
await api(`/tools/linkliste/${id}`,{method:'DELETE'});
|
||||
setOwn(p=>p.filter(l=>l.id!==id)); toast('Gelöscht');
|
||||
};
|
||||
const toggleQA = async link => {
|
||||
const val = link.in_quickaccess ? 0 : 1;
|
||||
const r = await api(`/tools/linkliste/${link.id}`,{method:'PUT',body:{in_quickaccess:val}});
|
||||
setOwn(p=>p.map(l=>l.id===r.id?r:l));
|
||||
toast(val ? `„${link.title}" zum Schnellzugriff hinzugefügt ✓` : `„${link.title}" aus Schnellzugriff entfernt`);
|
||||
};
|
||||
|
||||
// ── CRUD Folders ────────────────────────────────────────────────────────────
|
||||
const addFolder = async form => {
|
||||
const r = await api('/tools/linkliste/folders',{body:form});
|
||||
setFolders(p=>[...p,r]); setShowFolderForm(false);
|
||||
};
|
||||
const updateFolder = async form => {
|
||||
const r = await api(`/tools/linkliste/folders/${editFolder.id}`,{method:'PUT',body:form});
|
||||
setFolders(p=>p.map(f=>f.id===r.id?r:f)); setEditFolder(null);
|
||||
};
|
||||
const deleteFolder = async id => {
|
||||
await api(`/tools/linkliste/folders/${id}`,{method:'DELETE'});
|
||||
setFolders(p=>p.filter(f=>f.id!==id));
|
||||
// Links aus Ordner herauslösen (Backend macht das, Frontend nachführen)
|
||||
setOwn(p=>p.map(l=>l.folder_id===id?{...l,folder_id:null}:l));
|
||||
toast('Ordner gelöscht');
|
||||
};
|
||||
const toggleFolderQA = async folder => {
|
||||
const val = folder.in_quickaccess ? 0 : 1;
|
||||
const r = await api(`/tools/linkliste/folders/${folder.id}`,{method:'PUT',body:{in_quickaccess:val}});
|
||||
setFolders(p=>p.map(f=>f.id===r.id?r:f));
|
||||
toast(val ? `Ordner „${folder.name}" zum Schnellzugriff hinzugefügt ✓` : `Ordner „${folder.name}" aus Schnellzugriff entfernt`);
|
||||
};
|
||||
|
||||
// ── Sortierung ──────────────────────────────────────────────────────────────
|
||||
const moveLink = (id, dir) => {
|
||||
setOwn(prev => {
|
||||
const arr = [...prev];
|
||||
const idx = arr.findIndex(l=>l.id===id);
|
||||
const nxt = idx+dir;
|
||||
if (nxt<0||nxt>=arr.length) return prev;
|
||||
[arr[idx],arr[nxt]] = [arr[nxt],arr[idx]];
|
||||
// Persist
|
||||
api('/tools/linkliste/sort',{method:'PUT',body:{links:arr.map(l=>l.id)}}).catch(()=>{});
|
||||
return arr;
|
||||
});
|
||||
};
|
||||
const moveFolder = (id, dir) => {
|
||||
setFolders(prev => {
|
||||
const arr = [...prev];
|
||||
const idx = arr.findIndex(f=>f.id===id);
|
||||
const nxt = idx+dir;
|
||||
if (nxt<0||nxt>=arr.length) return prev;
|
||||
[arr[idx],arr[nxt]] = [arr[nxt],arr[idx]];
|
||||
api('/tools/linkliste/sort',{method:'PUT',body:{folders:arr.map(f=>f.id)}}).catch(()=>{});
|
||||
return arr;
|
||||
});
|
||||
};
|
||||
|
||||
// ── Suche ───────────────────────────────────────────────────────────────────
|
||||
const q = search.trim().toLowerCase();
|
||||
const matchLink = l =>
|
||||
l.title.toLowerCase().includes(q) ||
|
||||
l.url.toLowerCase().includes(q) ||
|
||||
(l.description||'').toLowerCase().includes(q);
|
||||
const matchFolder = f => f.name.toLowerCase().includes(q);
|
||||
|
||||
// Für "Meine" Tab: flache Liste aller Links die passen + Ordner die passen
|
||||
const filteredOwn = q ? own.filter(matchLink) : own;
|
||||
const filteredFolders = q ? folders.filter(f => matchFolder(f) || own.some(l=>l.folder_id===f.id&&matchLink(l))) : folders;
|
||||
const filteredShared = shared.filter(l=>!q||matchLink(l));
|
||||
const filteredByMe = byMe.filter(l=>!q||matchLink(l));
|
||||
|
||||
// ── Drag für Sortierung ─────────────────────────────────────────────────────
|
||||
const onDragStart = (e,id) => { dragRef.current = id; e.dataTransfer.effectAllowed='move'; };
|
||||
const onDragOver = (e,id,type) => {
|
||||
e.preventDefault();
|
||||
if (dragRef.current===id) return;
|
||||
if (type==='link') {
|
||||
const arr=[...own]; const from=arr.findIndex(l=>l.id===dragRef.current); const to=arr.findIndex(l=>l.id===id);
|
||||
if (from<0||to<0) return;
|
||||
arr.splice(to,0,arr.splice(from,1)[0]); setOwn(arr);
|
||||
}
|
||||
if (type==='folder') {
|
||||
const arr=[...folders]; const from=arr.findIndex(f=>f.id===dragRef.current); const to=arr.findIndex(f=>f.id===id);
|
||||
if (from<0||to<0) return;
|
||||
arr.splice(to,0,arr.splice(from,1)[0]); setFolders(arr);
|
||||
}
|
||||
};
|
||||
const onDrop = (type) => {
|
||||
if (type==='link') api('/tools/linkliste/sort',{method:'PUT',body:{links:own.map(l=>l.id)}}).catch(()=>{});
|
||||
if (type==='folder') api('/tools/linkliste/sort',{method:'PUT',body:{folders:folders.map(f=>f.id)}}).catch(()=>{});
|
||||
dragRef.current=null;
|
||||
};
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────────────
|
||||
const QABadge = ({active}) => (
|
||||
<span style={{
|
||||
display:'inline-block', width:7, height:7, borderRadius:'50%',
|
||||
background:active?'#4ecdc4':'rgba(255,255,255,0.15)',
|
||||
boxShadow:active?'0 0 5px #4ecdc4':'none',
|
||||
marginRight:3, flexShrink:0,
|
||||
}}/>
|
||||
);
|
||||
|
||||
function LinkCard({ link, isShared=false, isByMe=false, inSort=false }) {
|
||||
const [imgOk, setImgOk] = useState(true);
|
||||
const favicon = (link.icon==='🔗'||!link.icon) ? extIcon(link.url) : null;
|
||||
const showFavicon = !!favicon && imgOk;
|
||||
return (
|
||||
<div style={{...S.card,marginBottom:6,padding:'10px 12px'}}
|
||||
draggable={sortMode&&!isShared&&!isByMe}
|
||||
onDragStart={sortMode?e=>onDragStart(e,link.id):undefined}
|
||||
onDragOver={sortMode?e=>onDragOver(e,link.id,'link'):undefined}
|
||||
onDrop={sortMode?()=>onDrop('link'):undefined}>
|
||||
<div style={{display:'flex',alignItems:'center',gap:10}}>
|
||||
{/* Sort handle */}
|
||||
{sortMode && !isShared && !isByMe && (
|
||||
<span style={{color:'rgba(255,255,255,0.2)',fontSize:14,cursor:'grab',flexShrink:0}}>⠿</span>
|
||||
)}
|
||||
{/* Icon */}
|
||||
<div style={{width:32,height:32,borderRadius:8,flexShrink:0,overflow:'hidden',
|
||||
background:'rgba(255,255,255,0.05)',border:'1px solid rgba(255,255,255,0.08)',
|
||||
display:'flex',alignItems:'center',justifyContent:'center',fontSize:16}}>
|
||||
{showFavicon
|
||||
? <img src={favicon} onError={()=>setImgOk(false)} style={{width:18,height:18,objectFit:'contain'}} alt=""/>
|
||||
: <span>{link.icon||'🔗'}</span>}
|
||||
</div>
|
||||
{/* Info */}
|
||||
<div style={{flex:1,minWidth:0}}>
|
||||
<a href={link.url} target="_blank" rel="noopener noreferrer"
|
||||
style={{color:'#fff',fontFamily:'monospace',fontSize:13,fontWeight:700,
|
||||
textDecoration:'none',display:'block',overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}>
|
||||
{link.title}
|
||||
</a>
|
||||
<div style={{color:'rgba(78,205,196,0.55)',fontFamily:'monospace',fontSize:10,
|
||||
overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap',marginTop:1}}>
|
||||
{link.url}
|
||||
</div>
|
||||
{link.description&&<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10,marginTop:1}}>{link.description}</div>}
|
||||
{isShared&&<div style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:9,marginTop:1}}>von {link.owner_name}</div>}
|
||||
{isByMe&&<div style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:9,marginTop:1}}>→ {link.shared_with_name}</div>}
|
||||
</div>
|
||||
{/* Sort buttons */}
|
||||
{sortMode && !isShared && !isByMe && (
|
||||
<div style={{display:'flex',flexDirection:'column',gap:2,flexShrink:0}}>
|
||||
<button onClick={()=>moveLink(link.id,-1)} style={{background:'rgba(255,255,255,0.05)',border:'none',
|
||||
borderRadius:4,color:'rgba(255,255,255,0.5)',cursor:'pointer',padding:'1px 7px',fontSize:11}}>▲</button>
|
||||
<button onClick={()=>moveLink(link.id,+1)} style={{background:'rgba(255,255,255,0.05)',border:'none',
|
||||
borderRadius:4,color:'rgba(255,255,255,0.5)',cursor:'pointer',padding:'1px 7px',fontSize:11}}>▼</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Aktionen */}
|
||||
{!sortMode && (
|
||||
<div style={{display:'flex',gap:5,marginTop:8,flexWrap:'wrap'}}>
|
||||
{!isShared && !isByMe && (
|
||||
<button onClick={()=>toggleQA(link)} style={{
|
||||
background: link.in_quickaccess?'rgba(78,205,196,0.15)':'rgba(255,255,255,0.04)',
|
||||
border: `1px solid ${link.in_quickaccess?'#4ecdc4':'rgba(255,255,255,0.15)'}`,
|
||||
borderRadius:6,color:link.in_quickaccess?'#4ecdc4':'rgba(255,255,255,0.5)',
|
||||
cursor:'pointer',fontFamily:'monospace',fontSize:10,padding:'4px 10px',
|
||||
display:'flex',alignItems:'center',gap:4,whiteSpace:'nowrap'}}>
|
||||
<QABadge active={!!link.in_quickaccess}/> Schnellzugriff
|
||||
</button>
|
||||
)}
|
||||
{!isShared && !isByMe && (
|
||||
<>
|
||||
<button onClick={()=>setShareItem(link)} style={{...S.btn('#ffe66d',true),fontSize:10,padding:'4px 10px'}}>
|
||||
🤝{link.share_count>0?` (${link.share_count})`:''}
|
||||
</button>
|
||||
<button onClick={()=>setEditItem(link)} style={{...S.btn('#4ecdc4',true),fontSize:10,padding:'4px 10px'}}>✎</button>
|
||||
<button onClick={()=>deleteLink(link.id)} style={{...S.btn('#ff6b9d',true),fontSize:10,padding:'4px 10px'}}>✕</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderSection({ folder }) {
|
||||
const isOpen = !!expanded[folder.id];
|
||||
const folderLinks = filteredOwn.filter(l=>l.folder_id===folder.id);
|
||||
return (
|
||||
<div style={{marginBottom:8}}>
|
||||
<div style={{...S.card,padding:'10px 12px',marginBottom:isOpen?0:0,
|
||||
borderRadius:isOpen?'10px 10px 0 0':'10px',borderBottom:isOpen?'1px solid rgba(255,255,255,0.06)':undefined}}
|
||||
draggable={sortMode}
|
||||
onDragStart={sortMode?e=>onDragStart(e,folder.id):undefined}
|
||||
onDragOver={sortMode?e=>onDragOver(e,folder.id,'folder'):undefined}
|
||||
onDrop={sortMode?()=>onDrop('folder'):undefined}>
|
||||
<div style={{display:'flex',alignItems:'center',gap:10}}>
|
||||
{sortMode && <span style={{color:'rgba(255,255,255,0.2)',fontSize:14,cursor:'grab',flexShrink:0}}>⠿</span>}
|
||||
<button onClick={()=>setExpanded(p=>({...p,[folder.id]:!isOpen}))}
|
||||
style={{background:'none',border:'none',cursor:'pointer',color:'rgba(255,255,255,0.4)',
|
||||
fontSize:12,padding:0,flexShrink:0,width:16}}>
|
||||
{isOpen?'▾':'▸'}
|
||||
</button>
|
||||
<span style={{fontSize:18,flexShrink:0}}>{folder.icon}</span>
|
||||
<div style={{flex:1,minWidth:0}}>
|
||||
<span style={{color:'#fff',fontFamily:'monospace',fontSize:13,fontWeight:700}}>{folder.name}</span>
|
||||
<span style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:10,marginLeft:8}}>
|
||||
{folderLinks.length} Link{folderLinks.length!==1?'s':''}
|
||||
</span>
|
||||
</div>
|
||||
{sortMode && (
|
||||
<div style={{display:'flex',flexDirection:'column',gap:2,flexShrink:0}}>
|
||||
<button onClick={()=>moveFolder(folder.id,-1)} style={{background:'rgba(255,255,255,0.05)',border:'none',
|
||||
borderRadius:4,color:'rgba(255,255,255,0.5)',cursor:'pointer',padding:'1px 7px',fontSize:11}}>▲</button>
|
||||
<button onClick={()=>moveFolder(folder.id,+1)} style={{background:'rgba(255,255,255,0.05)',border:'none',
|
||||
borderRadius:4,color:'rgba(255,255,255,0.5)',cursor:'pointer',padding:'1px 7px',fontSize:11}}>▼</button>
|
||||
</div>
|
||||
)}
|
||||
{!sortMode && (
|
||||
<div style={{display:'flex',gap:5,flexShrink:0}}>
|
||||
<button onClick={()=>toggleFolderQA(folder)} style={{
|
||||
background: folder.in_quickaccess?'rgba(78,205,196,0.15)':'rgba(255,255,255,0.04)',
|
||||
border: `1px solid ${folder.in_quickaccess?'#4ecdc4':'rgba(255,255,255,0.15)'}`,
|
||||
borderRadius:6,color:folder.in_quickaccess?'#4ecdc4':'rgba(255,255,255,0.5)',
|
||||
cursor:'pointer',fontFamily:'monospace',fontSize:10,padding:'4px 10px',
|
||||
display:'flex',alignItems:'center',gap:4,whiteSpace:'nowrap'}}>
|
||||
<QABadge active={!!folder.in_quickaccess}/> Schnellzugriff
|
||||
</button>
|
||||
<button onClick={()=>setEditFolder(folder)} style={{...S.btn('#4ecdc4',true),fontSize:10,padding:'4px 10px'}}>✎</button>
|
||||
<button onClick={()=>setShareFolderItem(folder)} style={{...S.btn('#ffe66d',true),fontSize:10,padding:'4px 10px'}}>🤝</button>
|
||||
<button onClick={()=>deleteFolder(folder.id)} style={{...S.btn('#ff6b9d',true),fontSize:10,padding:'4px 10px'}}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div style={{background:'rgba(255,255,255,0.02)',border:'1px solid rgba(255,255,255,0.07)',
|
||||
borderTop:'none',borderRadius:'0 0 10px 10px',padding:'8px 8px 4px'}}>
|
||||
{folderLinks.length===0
|
||||
? <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:11,padding:'8px 4px',textAlign:'center'}}>
|
||||
Ordner ist leer – bearbeite einen Link und wähle diesen Ordner
|
||||
</div>
|
||||
: folderLinks.map(link=>(
|
||||
<LinkCard key={link.id} link={link}/>
|
||||
))
|
||||
}
|
||||
{!sortMode && (
|
||||
<button onClick={()=>{ setShowForm(true); setShowFolderForm(false); setEditItem({folder_id:folder.id}); }}
|
||||
style={{...S.btn('#4ecdc4',true),fontSize:10,padding:'5px 12px',width:'100%',textAlign:'center',marginBottom:4}}>
|
||||
+ Link in diesem Ordner hinzufügen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const unfoldered = filteredOwn.filter(l=>!l.folder_id);
|
||||
|
||||
return (
|
||||
<div style={{padding:mobile?'14px 14px 90px':'36px 44px',maxWidth:760}}>
|
||||
{shareItem && <ShareModal link={shareItem} onClose={()=>{setShareItem(null);load();}} toast={toast}/>}
|
||||
{shareFolderItem && <FolderShareModal folder={shareFolderItem} onClose={()=>{setShareFolderItem(null);load();}} toast={toast}/>}
|
||||
|
||||
{/* Header */}
|
||||
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:14,gap:8,flexWrap:'wrap'}}>
|
||||
<h1 style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:mobile?17:22,margin:0}}>Linkliste</h1>
|
||||
<div style={{display:'flex',gap:6,flexWrap:'wrap'}}>
|
||||
<button onClick={load} style={{background:'transparent',border:'1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius:8,color:'rgba(255,255,255,0.4)',cursor:'pointer',padding:'6px 10px',fontSize:14,fontFamily:'monospace'}}>↺</button>
|
||||
{tab==='own' && (
|
||||
<>
|
||||
<button onClick={()=>setSortMode(p=>!p)} style={{
|
||||
background: sortMode?'rgba(255,230,109,0.15)':'rgba(255,255,255,0.04)',
|
||||
border: `1px solid ${sortMode?'#ffe66d':'rgba(255,255,255,0.15)'}`,
|
||||
borderRadius:6,color:sortMode?'#ffe66d':'rgba(255,255,255,0.6)',
|
||||
cursor:'pointer',fontFamily:'monospace',fontSize:11,padding:'6px 12px',whiteSpace:'nowrap'}}>
|
||||
{sortMode?'✓ Fertig':'⇅ Sortieren'}
|
||||
</button>
|
||||
{!showFolderForm && !editFolder && !sortMode && (
|
||||
<button onClick={()=>{setShowFolderForm(true);setShowForm(false);}}
|
||||
style={{...S.btn('#ffe66d',true),fontSize:11,padding:'6px 12px'}}>+ Ordner</button>
|
||||
)}
|
||||
{!showForm && !editItem && !sortMode && (
|
||||
<button onClick={()=>{setShowToolPicker(true);setShowForm(false);setShowFolderForm(false);}}
|
||||
style={{...S.btn('#a78bfa',true),fontSize:11,padding:'6px 12px'}}>+ App</button>
|
||||
)}
|
||||
{!showForm && !editItem && !sortMode && (
|
||||
<button onClick={()=>{setShowForm(true);setShowFolderForm(false);setEditItem(null);}}
|
||||
style={{...S.btn('#4ecdc4'),padding:'7px 16px',fontSize:12}}>+ Link</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Formulare */}
|
||||
{showToolPicker && <ToolPicker folders={folders} onSave={addLink} onClose={()=>setShowToolPicker(false)} toast={toast}/>}
|
||||
{showFolderForm && !editFolder && <FolderForm onSave={addFolder} onCancel={()=>setShowFolderForm(false)} toast={toast}/>}
|
||||
{editFolder && <FolderForm initial={editFolder} onSave={updateFolder} onCancel={()=>setEditFolder(null)} toast={toast}/>}
|
||||
{showForm && !editItem && <LinkForm folders={folders} onSave={addLink} onCancel={()=>setShowForm(false)} toast={toast}/>}
|
||||
{editItem && <LinkForm initial={editItem} folders={folders} onSave={updateLink} onCancel={()=>setEditItem(null)} toast={toast}/>}
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{display:'flex',gap:6,marginBottom:12,flexWrap:'wrap'}}>
|
||||
{[['own','Meine',own.length+folders.length],['shared','Geteilt mit mir',shared.length],['byMe','Geteilt von mir',byMe.length]].map(([k,l,cnt])=>(
|
||||
<button key={k} onClick={()=>{setTab(k);setSearch('');setSortMode(false);}} style={{
|
||||
padding:'6px 14px',borderRadius:20,fontFamily:'monospace',fontSize:11,cursor:'pointer',
|
||||
background:tab===k?'#4ecdc4':'rgba(255,255,255,0.05)',
|
||||
color:tab===k?'#0d0d0f':'rgba(255,255,255,0.55)',
|
||||
border:tab===k?'none':'1px solid rgba(255,255,255,0.1)',
|
||||
fontWeight:tab===k?700:400,
|
||||
}}>{l}{cnt>0&&<span style={{marginLeft:4,opacity:0.7}}>({cnt})</span>}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Suche */}
|
||||
<div style={{position:'relative',marginBottom:12}}>
|
||||
<span style={{position:'absolute',left:12,top:'50%',transform:'translateY(-50%)',color:'rgba(255,255,255,0.5)',pointerEvents:'none'}}>⌕</span>
|
||||
<input value={search} onChange={e=>setSearch(e.target.value)} placeholder="Suchen in Links, Ordnern, URLs…"
|
||||
style={{...S.inp,paddingLeft:34}}/>
|
||||
{search&&<button onClick={()=>setSearch('')} style={{position:'absolute',right:10,top:'50%',transform:'translateY(-50%)',
|
||||
background:'transparent',border:'none',color:'rgba(255,255,255,0.5)',cursor:'pointer',fontSize:16}}>✕</button>}
|
||||
</div>
|
||||
|
||||
{/* Inhalt */}
|
||||
{tab==='own' && (
|
||||
<div>
|
||||
{/* Ordner */}
|
||||
{filteredFolders.map(folder=><FolderSection key={folder.id} folder={folder}/>)}
|
||||
{/* Links ohne Ordner */}
|
||||
{unfoldered.length>0 && filteredFolders.length>0 && (
|
||||
<div style={{...S.head,fontSize:9,margin:'12px 0 6px',color:'rgba(255,255,255,0.3)'}}>OHNE ORDNER</div>
|
||||
)}
|
||||
{unfoldered.map(link=><LinkCard key={link.id} link={link}/>)}
|
||||
{filteredFolders.length===0 && unfoldered.length===0 && (
|
||||
<div style={{...S.card,textAlign:'center',padding:40}}>
|
||||
<div style={{fontSize:28,marginBottom:10}}>🔗</div>
|
||||
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:12}}>
|
||||
{search?'Keine Treffer.':'Noch keine Links – füge deinen ersten Link oder Ordner hinzu.'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{tab==='shared' && (
|
||||
<div>
|
||||
{/* Geteilte Ordner */}
|
||||
{sharedFolders.map(folder=>(
|
||||
<div key={`sf-${folder.id}`} style={{marginBottom:8}}>
|
||||
<div style={{...S.card,padding:'10px 12px',borderRadius:'10px 10px 0 0',
|
||||
borderBottom:'1px solid rgba(255,255,255,0.06)'}}>
|
||||
<div style={{display:'flex',alignItems:'center',gap:10}}>
|
||||
<span style={{fontSize:18}}>{folder.icon}</span>
|
||||
<div style={{flex:1}}>
|
||||
<span style={{color:'#fff',fontFamily:'monospace',fontSize:13,fontWeight:700}}>{folder.name}</span>
|
||||
<span style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:9,marginLeft:8}}>von {folder.owner_name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{background:'rgba(255,255,255,0.02)',border:'1px solid rgba(255,255,255,0.07)',
|
||||
borderTop:'none',borderRadius:'0 0 10px 10px',padding:'8px 8px 4px'}}>
|
||||
{(folder.links||[]).map(link=><LinkCard key={link.id} link={link} isShared/>)}
|
||||
{(!folder.links||folder.links.length===0) && <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:11,padding:'8px',textAlign:'center'}}>Ordner ist leer</div>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* Einzelne Links */}
|
||||
{filteredShared.map(link=><LinkCard key={link.id} link={link} isShared/>)}
|
||||
{sharedFolders.length===0&&filteredShared.length===0 && (
|
||||
<div style={{...S.card,textAlign:'center',padding:40}}><div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:12}}>Nichts geteilt.</div></div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{tab==='byMe' && (
|
||||
filteredByMe.length===0
|
||||
? <div style={{...S.card,textAlign:'center',padding:40}}><div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:12}}>Noch nichts geteilt.</div></div>
|
||||
: filteredByMe.map(link=><LinkCard key={link.id} link={link} isByMe/>)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1395
frontend/src/tools/media.jsx
Normal file
650
frontend/src/tools/nachrichten.jsx
Normal file
@@ -0,0 +1,650 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { api, S } from '../lib.js';
|
||||
import { UserIcon, TrashIcon } from '../icons.jsx';
|
||||
import {
|
||||
getOrCreateKeyPair,
|
||||
getPublicKeyJwk,
|
||||
getFingerprint,
|
||||
deriveSharedKey,
|
||||
encryptMsg,
|
||||
decryptMsg,
|
||||
} from '../crypto.js';
|
||||
|
||||
// ── Emoji-Konvertierung ───────────────────────────────────────────────────────
|
||||
const EMOJI_MAP = {
|
||||
':D':'😁', ':)':'🙂', ':-)':'🙂', '(:':'🙂', ';)':'😉', ';-)':'😉',
|
||||
':P':'😛', ':-P':'😛', ':p':'😛', ':O':'😮', ':-O':'😮', ':o':'😮',
|
||||
':(':'😢', ':-(':'😢', ":'(":'😭', '>:(':"😠", '>:-(':"😠",
|
||||
':*':'😘', ':-*':'😘', '<3':'❤️', '</3':'💔',
|
||||
'xD':'😆', 'XD':'😆', 'x)':"😄",
|
||||
':/':"😕", ':-/':"😕", ':|':"😐", ':-|':"😐",
|
||||
'B)':"😎", 'B-)':"😎", '^_^':"😊", '-_-':"😑",
|
||||
'O:)':"😇", 'O:-)':"😇", '>:)':"😈",
|
||||
':3':"🐱", ':$':"😳",
|
||||
};
|
||||
|
||||
// Ersetze Kürzel am Ende des Textes (nach Leerzeichen oder Satzanfang)
|
||||
function convertEmojis(text) {
|
||||
// Letztes "Wort" vor dem Leerzeichen/Newline prüfen
|
||||
return text.replace(/(\S+)(\s)$/, (_, word, space) => {
|
||||
return (EMOJI_MAP[word] ?? word) + space;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Emoji-Picker Daten ────────────────────────────────────────────────────────
|
||||
const EMOJI_GROUPS = [
|
||||
{ label:'Smileys', emojis:['😀','😁','😂','🤣','😊','😇','🙂','😉','😌','😍','🥰','😘','😎','🤩','😏','😒','😞','😢','😭','😤','😠','😡','🤬','😈','👿','💀','😱','😨','😰','😓','🤔','🤐','😶','😐','😑','😬','🙄','😴','🤧','🤒','😷','🤕'] },
|
||||
{ label:'Gesten', emojis:['👍','👎','👌','✌️','🤞','🤟','🤙','👋','🤚','🖐','✋','🤜','🤛','👏','🙌','🤝','🙏','💪','🫶','❤️','🧡','💛','💚','💙','💜','🖤','💔','💯','💥','✨','🎉','🔥','⭐','🌟'] },
|
||||
{ label:'Dinge', emojis:['😂💀','🎮','🏆','🎯','🎲','🃏','🎸','🎵','🎤','📱','💻','⌨️','🖱️','📷','🎬','📺','🚀','🛸','🌍','🌈','☀️','🌙','⭐','🍕','🍔','🍟','🌮','🍩','🍺','☕','🧃'] },
|
||||
];
|
||||
|
||||
function EmojiPicker({ onSelect, onClose }) {
|
||||
const [group, setGroup] = useState(0);
|
||||
return (
|
||||
<div style={{ position:'absolute', bottom:'100%', right:0, marginBottom:6, zIndex:100,
|
||||
background:'#1a1a1e', border:'1px solid rgba(255,255,255,0.12)', borderRadius:12,
|
||||
width:272, boxShadow:'0 8px 32px rgba(0,0,0,0.5)' }}
|
||||
onMouseDown={e => e.preventDefault()}>
|
||||
<div style={{ display:'flex', borderBottom:'1px solid rgba(255,255,255,0.07)', padding:'6px 8px', gap:4 }}>
|
||||
{EMOJI_GROUPS.map((g,i) => (
|
||||
<button key={i} onClick={() => setGroup(i)} style={{ flex:1, background: group===i ?
|
||||
'rgba(78,205,196,0.15)' : 'transparent', border:'none', borderRadius:7,
|
||||
color: group===i ? '#4ecdc4' : 'rgba(255,255,255,0.4)', cursor:'pointer',
|
||||
fontSize:9, fontFamily:'monospace', padding:'4px 2px' }}>{g.label}</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display:'flex', flexWrap:'wrap', padding:8, gap:2, maxHeight:180, overflowY:'auto' }}>
|
||||
{EMOJI_GROUPS[group].emojis.filter(e => e.length <= 2 || [...e].length <= 2).concat(
|
||||
EMOJI_GROUPS[group].emojis.filter(e => [...e].length > 2)
|
||||
).map((em,i) => (
|
||||
<button key={i} onClick={() => onSelect(em)} style={{ background:'transparent', border:'none',
|
||||
borderRadius:6, cursor:'pointer', fontSize:20, padding:'4px 5px',
|
||||
transition:'background 0.1s' }}
|
||||
onMouseEnter={e => e.target.style.background='rgba(255,255,255,0.08)'}
|
||||
onMouseLeave={e => e.target.style.background='transparent'}>
|
||||
{em}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Nachrichten({ toast, mobile, nav }) {
|
||||
const [kp, setKp] = useState(null);
|
||||
const [initError, setInitError] = useState(false);
|
||||
const [users, setUsers] = useState([]);
|
||||
const [activeUser, setActiveUser] = useState(null);
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [sharedKey, setSharedKey] = useState(null); // null | CryptoKey | 'error'
|
||||
const [decrypted, setDecrypted] = useState({});
|
||||
const [partnerFp, setPartnerFp] = useState('');
|
||||
const [text, setText] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
const [mirc, setMirc] = useState(false);
|
||||
// Preference vom Server laden
|
||||
useEffect(() => {
|
||||
api('/auth/preferences').then(p => { if (p.chat_design === 'mirc') setMirc(true); }).catch(()=>{});
|
||||
}, []);
|
||||
const toggleDesign = () => setMirc(v => {
|
||||
const next = !v;
|
||||
api('/auth/preferences', { method:'PUT', body:{ chat_design: next ? 'mirc' : 'modern' } }).catch(()=>{});
|
||||
return next;
|
||||
});
|
||||
const bottomRef = useRef(null);
|
||||
const pollRef = useRef(null);
|
||||
const pickerRef = useRef(null);
|
||||
const heartbeatRef = useRef(null);
|
||||
|
||||
// Presence Heartbeat: solange Nachrichten offen + Tab sichtbar → aktiv
|
||||
useEffect(() => {
|
||||
const sendPresence = (active) => api('/tools/nachrichten/presence', { method:'POST', body:{ active } }).catch(()=>{});
|
||||
|
||||
const start = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
sendPresence(true);
|
||||
heartbeatRef.current = setInterval(() => {
|
||||
if (document.visibilityState === 'visible') sendPresence(true);
|
||||
}, 30000);
|
||||
}
|
||||
};
|
||||
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
sendPresence(true);
|
||||
if (!heartbeatRef.current) heartbeatRef.current = setInterval(() => sendPresence(true), 30000);
|
||||
} else {
|
||||
sendPresence(false);
|
||||
clearInterval(heartbeatRef.current);
|
||||
heartbeatRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
start();
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
return () => {
|
||||
sendPresence(false);
|
||||
clearInterval(heartbeatRef.current);
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showPicker) return;
|
||||
const handler = e => {
|
||||
if (pickerRef.current && !pickerRef.current.contains(e.target)) setShowPicker(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
document.addEventListener('touchstart', handler);
|
||||
return () => { document.removeEventListener('mousedown', handler); document.removeEventListener('touchstart', handler); };
|
||||
}, [showPicker]);
|
||||
|
||||
// ── Initialisierung ──────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
getOrCreateKeyPair()
|
||||
.then(async k => {
|
||||
setKp(k);
|
||||
try {
|
||||
const jwk = await getPublicKeyJwk(k);
|
||||
await api('/tools/nachrichten/keys', { body: { public_key: JSON.stringify(jwk) } });
|
||||
} catch {}
|
||||
loadUsers();
|
||||
})
|
||||
.catch(() => setInitError(true));
|
||||
}, []);
|
||||
|
||||
// ── Polling ──────────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
clearInterval(pollRef.current);
|
||||
if (!activeUser) return;
|
||||
loadMessages(activeUser.id);
|
||||
pollRef.current = setInterval(() => loadMessages(activeUser.id), 10_000);
|
||||
return () => clearInterval(pollRef.current);
|
||||
}, [activeUser?.id]);
|
||||
|
||||
// ── Shared Key + Partner-Fingerprint ─────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
setSharedKey(null);
|
||||
setDecrypted({});
|
||||
setPartnerFp('');
|
||||
if (!kp || !activeUser?.public_key) return;
|
||||
try {
|
||||
const pubJwk = JSON.parse(activeUser.public_key);
|
||||
deriveSharedKey(kp.privateKey, pubJwk)
|
||||
.then(setSharedKey)
|
||||
.catch(() => setSharedKey('error'));
|
||||
// Fingerprint des Partner-Keys berechnen (aus dem auf dem Server gespeicherten Public Key)
|
||||
crypto.subtle.importKey('jwk', pubJwk, { name:'X25519' }, true, [])
|
||||
.then(k => getFingerprint({ publicKey: k }))
|
||||
.then(setPartnerFp)
|
||||
.catch(() => setPartnerFp('?'));
|
||||
} catch {
|
||||
setSharedKey('error');
|
||||
}
|
||||
}, [activeUser?.id, kp]);
|
||||
|
||||
// ── Entschlüsseln ────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (sharedKey === null || sharedKey === 'error' || !messages.length) {
|
||||
setDecrypted({});
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const result = {};
|
||||
for (const m of messages) {
|
||||
if (cancelled) return;
|
||||
try {
|
||||
result[m.id] = await decryptMsg(sharedKey, m.encrypted_content, m.iv);
|
||||
} catch {
|
||||
result[m.id] = null; // null = nicht entschlüsselbar
|
||||
}
|
||||
}
|
||||
if (!cancelled) setDecrypted(result);
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [messages, sharedKey]);
|
||||
|
||||
// ── Scroll to bottom ─────────────────────────────────────────────────────────
|
||||
const prevLengthRef = useRef(0);
|
||||
const prevUserRef = useRef(null);
|
||||
|
||||
function scrollToBottom(behavior = 'instant') {
|
||||
bottomRef.current?.scrollIntoView({ behavior });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!bottomRef.current) return;
|
||||
const userChanged = prevUserRef.current !== activeUser?.id;
|
||||
const isFirstLoad = prevLengthRef.current === 0 && messages.length > 0;
|
||||
prevLengthRef.current = messages.length;
|
||||
prevUserRef.current = activeUser?.id;
|
||||
if (userChanged || isFirstLoad) {
|
||||
scrollToBottom('instant');
|
||||
} else if (messages.length > 0) {
|
||||
scrollToBottom('smooth');
|
||||
}
|
||||
}, [messages.length, activeUser?.id]);
|
||||
|
||||
// Wenn Handy-Tastatur aufklappt → ans Ende scrollen NUR wenn schon am Ende war
|
||||
useEffect(() => {
|
||||
if (!window.visualViewport) return;
|
||||
const onResize = () => {
|
||||
// Prüfe ob bottomRef nah am sichtbaren Bereich war (= User war am Ende)
|
||||
if (!bottomRef.current) return;
|
||||
const rect = bottomRef.current.getBoundingClientRect();
|
||||
const wasAtBottom = rect.top <= (window.visualViewport?.height ?? window.innerHeight) + 100;
|
||||
if (wasAtBottom) {
|
||||
setTimeout(() => scrollToBottom('instant'), 50);
|
||||
}
|
||||
};
|
||||
window.visualViewport.addEventListener('resize', onResize);
|
||||
return () => window.visualViewport.removeEventListener('resize', onResize);
|
||||
}, []);
|
||||
|
||||
// ── Handlers ─────────────────────────────────────────────────────────────────
|
||||
const loadUsers = async () => {
|
||||
try { const u = await api('/tools/nachrichten/users'); setUsers(u);
|
||||
// Nav aus Suche: direkt zu User springen
|
||||
if (nav?.userId) {
|
||||
const target = u.find(x => x.id === nav.userId);
|
||||
if (target) setActiveUser(target);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const loadMessages = async userId => {
|
||||
try {
|
||||
setMessages(await api(`/tools/nachrichten/messages/${userId}`));
|
||||
await loadUsers();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const selectUser = u => {
|
||||
setActiveUser(u);
|
||||
setMessages([]);
|
||||
setDecrypted({});
|
||||
prevLengthRef.current = 0;
|
||||
};
|
||||
|
||||
const send = async (overrideText) => {
|
||||
const msg = (overrideText ?? text).trim();
|
||||
if (!msg || !kp || !activeUser) return;
|
||||
if (!activeUser.public_key) { toast('Empfänger hat noch keinen Schlüssel', 'error'); return; }
|
||||
if (sharedKey === 'error') { toast('Schlüssel inkompatibel – Empfänger muss Schlüssel neu generieren', 'error'); return; }
|
||||
if (!sharedKey) { toast('Schlüssel wird vorbereitet…', 'error'); return; }
|
||||
setSending(true);
|
||||
try {
|
||||
const { encrypted_content, iv } = await encryptMsg(sharedKey, msg);
|
||||
await api(`/tools/nachrichten/messages/${activeUser.id}`, { body: { encrypted_content, iv } });
|
||||
setText('');
|
||||
await loadMessages(activeUser.id);
|
||||
} catch(e) { toast(e.message, 'error'); }
|
||||
setSending(false);
|
||||
};
|
||||
|
||||
const deleteMsg = async id => {
|
||||
try {
|
||||
await api(`/tools/nachrichten/messages/${id}`, { method:'DELETE' });
|
||||
setMessages(p => p.filter(m => m.id !== id));
|
||||
} catch(e) { toast(e.message, 'error'); }
|
||||
};
|
||||
|
||||
const deleteConversation = async () => {
|
||||
if (!window.confirm(`Gesamten Verlauf mit ${activeUser.username} löschen?`)) return;
|
||||
try {
|
||||
await api(`/tools/nachrichten/conversation/${activeUser.id}`, { method:'DELETE' });
|
||||
setMessages([]);
|
||||
await loadUsers();
|
||||
} catch(e) { toast(e.message, 'error'); }
|
||||
};
|
||||
|
||||
// ── Layout ───────────────────────────────────────────────────────────────────
|
||||
const showList = !mobile || !activeUser;
|
||||
const showChat = !mobile || !!activeUser;
|
||||
|
||||
if (initError) return (
|
||||
<div style={{ padding:32, color:'rgba(255,255,255,0.45)', fontFamily:'monospace', fontSize:12, lineHeight:2 }}>
|
||||
⚠ Verschlüsselung konnte nicht initialisiert werden.<br/>
|
||||
Dein Browser unterstützt X25519 möglicherweise nicht (erfordert Chrome 113+/Firefox 130+/Safari 17.4+).
|
||||
</div>
|
||||
);
|
||||
|
||||
const mobileStyle = mobile ? {
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 'calc(56px + env(safe-area-inset-bottom, 0px))',
|
||||
zIndex: 10,
|
||||
} : {
|
||||
height: '100vh',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display:'flex', overflow:'hidden', background:'#111114', ...mobileStyle }}>
|
||||
|
||||
{/* ── Konversationsliste ─────────────────────────────────────────── */}
|
||||
{showList && (
|
||||
<div style={{
|
||||
width: mobile ? '100%' : 230,
|
||||
borderRight: mobile ? 'none' : '1px solid rgba(255,255,255,0.06)',
|
||||
display:'flex', flexDirection:'column', background:'#0d0d0f', flexShrink:0,
|
||||
}}>
|
||||
<div style={{ padding:'16px 14px 12px', borderBottom:'1px solid rgba(255,255,255,0.06)',
|
||||
display:'flex', alignItems:'center', justifyContent:'space-between' }}>
|
||||
<div style={{ ...S.head, marginBottom:0 }}>NACHRICHTEN</div>
|
||||
<div style={{ display:'flex', gap:5 }}>
|
||||
<button onClick={toggleDesign} title={mirc ? 'Zu Modern wechseln' : 'Zu mIRC wechseln'}
|
||||
style={{ background: 'rgba(255,255,255,0.05)',
|
||||
border:'1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius:7, color:'rgba(255,255,255,0.55)',
|
||||
cursor:'pointer', padding:'3px 8px', fontSize:10, fontFamily:'monospace', lineHeight:1 }}>
|
||||
{mirc ? '◈ mIRC' : '◈ Modern'}
|
||||
</button>
|
||||
<button onClick={() => { loadUsers(); if (activeUser) loadMessages(activeUser.id); }} title="Neu laden"
|
||||
style={{ background:'transparent', border:'1px solid rgba(255,255,255,0.1)', borderRadius:7,
|
||||
color:'rgba(255,255,255,0.35)', cursor:'pointer', padding:'3px 8px',
|
||||
fontSize:13, fontFamily:'monospace', lineHeight:1 }}>↺</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex:1, overflowY:'auto' }}>
|
||||
{users.length === 0 ? (
|
||||
<div style={{ color:'rgba(255,255,255,0.28)', fontSize:11, fontFamily:'monospace',
|
||||
padding:18, textAlign:'center', lineHeight:1.8 }}>
|
||||
Keine anderen Benutzer vorhanden
|
||||
</div>
|
||||
) : users.map(u => (
|
||||
<button key={u.id} onClick={() => selectUser(u)} style={{
|
||||
width:'100%', padding:'11px 14px', border:'none', textAlign:'left', cursor:'pointer',
|
||||
background: activeUser?.id === u.id ? 'rgba(78,205,196,0.07)' : 'transparent',
|
||||
borderLeft: `2px solid ${activeUser?.id === u.id ? '#4ecdc4' : 'transparent'}`,
|
||||
display:'flex', alignItems:'center', gap:10,
|
||||
}}>
|
||||
<div style={{ width:32, height:32, borderRadius:'50%', flexShrink:0,
|
||||
background:'linear-gradient(135deg,rgba(255,107,157,0.2),rgba(78,205,196,0.2))',
|
||||
border:'1px solid rgba(255,255,255,0.07)',
|
||||
display:'flex', alignItems:'center', justifyContent:'center' }}>
|
||||
<UserIcon size={14} color="rgba(255,255,255,0.5)"/>
|
||||
</div>
|
||||
<div style={{ flex:1, minWidth:0 }}>
|
||||
<div style={{
|
||||
color: u.unread > 0 ? '#fff' : 'rgba(255,255,255,0.65)',
|
||||
fontWeight: u.unread > 0 ? 700 : 400,
|
||||
fontSize:12, fontFamily:'monospace',
|
||||
whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis',
|
||||
}}>{u.username}</div>
|
||||
{u.last_message_at && (
|
||||
<div style={{ color:'rgba(255,255,255,0.26)', fontSize:9, fontFamily:'monospace', marginTop:2 }}>
|
||||
{new Date(u.last_message_at).toLocaleDateString('de-DE',
|
||||
{ day:'numeric', month:'short', hour:'2-digit', minute:'2-digit' })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{u.unread > 0 && (
|
||||
<span style={{ background:'#4ecdc4', color:'#000', borderRadius:10,
|
||||
fontSize:9, fontFamily:'monospace', padding:'2px 7px', fontWeight:700, flexShrink:0 }}>
|
||||
{u.unread}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Chat ──────────────────────────────────────────────────────── */}
|
||||
{showChat && (
|
||||
<div style={{ flex:1, minHeight:0, display:'flex', flexDirection:'column', overflow:'hidden', minWidth:0 }}>
|
||||
{!activeUser ? (
|
||||
<div style={{ flex:1, display:'flex', alignItems:'center', justifyContent:'center',
|
||||
flexDirection:'column', gap:10 }}>
|
||||
<div style={{ fontSize:38, opacity:0.1 }}>✉</div>
|
||||
<div style={{ color:'rgba(255,255,255,0.18)', fontFamily:'monospace', fontSize:11 }}>
|
||||
Benutzer auswählen
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div style={{ padding:'10px 14px', borderBottom:'1px solid rgba(255,255,255,0.06)',
|
||||
display:'flex', alignItems:'center', gap:10, background:'#0d0d0f', flexShrink:0 }}>
|
||||
{mobile && (
|
||||
<button onClick={() => setActiveUser(null)} style={{ background:'transparent', border:'none',
|
||||
cursor:'pointer', color:'#4ecdc4', fontFamily:'monospace', fontSize:20, padding:'0 4px', lineHeight:1 }}>
|
||||
‹
|
||||
</button>
|
||||
)}
|
||||
<div style={{ width:28, height:28, borderRadius:'50%', flexShrink:0,
|
||||
background:'linear-gradient(135deg,rgba(255,107,157,0.2),rgba(78,205,196,0.2))',
|
||||
border:'1px solid rgba(255,255,255,0.07)',
|
||||
display:'flex', alignItems:'center', justifyContent:'center' }}>
|
||||
<UserIcon size={12} color="rgba(255,255,255,0.5)"/>
|
||||
</div>
|
||||
<div style={{ flex:1, minWidth:0 }}>
|
||||
<div style={{ color:'#fff', fontSize:13, fontFamily:'monospace',
|
||||
whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis' }}>
|
||||
{activeUser.username}
|
||||
</div>
|
||||
{sharedKey === 'error' ? (
|
||||
<div style={{ color:'#ff6b9d', fontSize:8, fontFamily:'monospace', letterSpacing:1.5, marginTop:1 }}>
|
||||
⚠ SCHLÜSSEL INKOMPATIBEL
|
||||
</div>
|
||||
) : partnerFp ? (
|
||||
<div title={`Fingerprint von ${activeUser.username} – vergleiche mit dessen Anzeige unter Einstellungen → Sicherheit`}
|
||||
style={{ color:'rgba(78,205,196,0.55)', fontSize:8, fontFamily:'monospace', letterSpacing:1, marginTop:2,
|
||||
cursor:'help', whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis' }}>
|
||||
🔒 {partnerFp}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color:'rgba(78,205,196,0.35)', fontSize:8, fontFamily:'monospace', letterSpacing:1.5, marginTop:1 }}>
|
||||
🔒 E2E VERSCHLÜSSELT · X25519 + AES-256-GCM
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={deleteConversation} style={{ ...S.btn('#ff6b9d', true), fontSize:10, flexShrink:0 }}>
|
||||
Verlauf löschen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Nachrichten */}
|
||||
<div style={{ flex:1, minHeight:0, overflowY:'auto', padding: mirc ? '0' : '14px',
|
||||
display:'flex', flexDirection:'column', gap: mirc ? 0 : 10,
|
||||
background: mirc ? '#000' : undefined,
|
||||
fontFamily: mirc ? "'Courier New', Courier, monospace" : undefined }}>
|
||||
{!activeUser.public_key && (
|
||||
<InfoBox color="#ffe66d">
|
||||
⚠ Dieser Benutzer hat die Nachrichten noch nicht geöffnet und besitzt noch kein Schlüsselpaar.
|
||||
</InfoBox>
|
||||
)}
|
||||
{sharedKey === 'error' && (
|
||||
<InfoBox color="#ff6b9d">
|
||||
⚠ Schlüssel inkompatibel – der Empfänger verwendet ein anderes Schlüsselformat.
|
||||
Er muss in Einstellungen → Sicherheit einen neuen Schlüssel generieren.
|
||||
</InfoBox>
|
||||
)}
|
||||
{messages.length === 0 && activeUser.public_key && sharedKey !== 'error' && (
|
||||
<div style={{ flex:1, display:'flex', alignItems:'center', justifyContent:'center',
|
||||
color: mirc ? '#808080' : 'rgba(255,255,255,0.18)',
|
||||
fontFamily: mirc ? "'Courier New', monospace" : 'monospace', fontSize:11 }}>
|
||||
{mirc ? '*** Noch keine Nachrichten ***' : 'Noch keine Nachrichten'}
|
||||
</div>
|
||||
)}
|
||||
{Object.values(decrypted).some(v => v === null) && (
|
||||
<InfoBox color="#ffe66d">
|
||||
🔑 Einige Nachrichten können nicht entschlüsselt werden.
|
||||
Importiere deinen alten Schlüssel unter Einstellungen → Sicherheit.
|
||||
</InfoBox>
|
||||
)}
|
||||
|
||||
{mirc ? (
|
||||
/* ── mIRC Design ─────────────────────────────────────── */
|
||||
<div style={{ padding:'4px 0' }}>
|
||||
{messages.map(m => {
|
||||
const isMine = !!m.is_mine;
|
||||
const txt = decrypted[m.id];
|
||||
const time = m.created_at ? (() => { const d = new Date(m.created_at.replace(' ','T')); return isNaN(d)?'':d.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'}); })() : '';
|
||||
const nick = isMine ? 'du' : activeUser.username;
|
||||
const nickColor = isMine ? '#00ff00' : '#00ffff';
|
||||
return (
|
||||
<div key={m.id} style={{ display:'flex', alignItems:'baseline', gap:0,
|
||||
padding:'1px 8px', lineHeight:1.5,
|
||||
background: isMine ? 'rgba(0,255,0,0.03)' : 'transparent' }}
|
||||
onMouseEnter={e=>e.currentTarget.style.background='rgba(255,255,255,0.04)'}
|
||||
onMouseLeave={e=>e.currentTarget.style.background=isMine?'rgba(0,255,0,0.03)':'transparent'}>
|
||||
<span style={{ color:'#808080', fontSize:11, flexShrink:0, marginRight:2 }}>[{time}]</span>
|
||||
<span style={{ color:'#808080', fontSize:11, flexShrink:0, marginRight:2 }}><</span>
|
||||
<span style={{ color:nickColor, fontSize:11, fontWeight:700, flexShrink:0 }}>{nick}</span>
|
||||
<span style={{ color:'#808080', fontSize:11, flexShrink:0, marginRight:6 }}>></span>
|
||||
<span style={{ color: txt===null?'#404040':'#d0d0d0', fontSize:12, wordBreak:'break-word', flex:1 }}>
|
||||
{txt===undefined ? '…' : txt===null ? '[verschlüsselt]' : txt}
|
||||
</span>
|
||||
{isMine && m.read_by_recipient && <span style={{color:'#004400',fontSize:9,marginLeft:4,flexShrink:0}}>✓✓</span>}
|
||||
<button onClick={() => deleteMsg(m.id)} style={{ background:'transparent', border:'none',
|
||||
cursor:'pointer', color:'#400000', fontSize:9, padding:'0 0 0 6px', flexShrink:0, opacity:0.5 }}>✕</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
/* ── Modernes Design ─────────────────────────────────── */
|
||||
<>
|
||||
{messages.map(m => {
|
||||
const isMine = !!m.is_mine;
|
||||
const txt = decrypted[m.id];
|
||||
return (
|
||||
<div key={m.id} style={{ display:'flex',
|
||||
justifyContent: isMine ? 'flex-end' : 'flex-start', gap:6, alignItems:'flex-end' }}>
|
||||
{!isMine && (
|
||||
<div style={{ width:22, height:22, borderRadius:'50%', flexShrink:0,
|
||||
background:'rgba(255,255,255,0.04)', border:'1px solid rgba(255,255,255,0.07)',
|
||||
display:'flex', alignItems:'center', justifyContent:'center', fontSize:10 }}>👤</div>
|
||||
)}
|
||||
<div style={{ maxWidth:'75%', display:'flex', flexDirection:'column',
|
||||
alignItems: isMine ? 'flex-end' : 'flex-start', gap:4 }}>
|
||||
<div style={{
|
||||
background: isMine ? 'rgba(78,205,196,0.09)' : 'rgba(255,255,255,0.04)',
|
||||
border: `1px solid ${isMine ? 'rgba(78,205,196,0.15)' : 'rgba(255,255,255,0.07)'}`,
|
||||
borderRadius: isMine ? '12px 12px 3px 12px' : '12px 12px 12px 3px',
|
||||
padding:'8px 12px',
|
||||
}}>
|
||||
{txt === undefined ? (
|
||||
<span style={{ color:'rgba(255,255,255,0.18)', fontSize:12 }}>⋯</span>
|
||||
) : txt === null ? (
|
||||
<span style={{ color:'rgba(255,107,157,0.5)', fontSize:11, fontFamily:'monospace' }}>🔒 Nicht entschlüsselbar</span>
|
||||
) : (
|
||||
<span style={{ color:'#e6e6e6', fontSize:13, fontFamily:'monospace',
|
||||
lineHeight:1.55, whiteSpace:'pre-wrap', wordBreak:'break-word' }}>{txt}</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display:'flex', alignItems:'center', gap:5 }}>
|
||||
<span style={{ color:'rgba(255,255,255,0.18)', fontSize:9, fontFamily:'monospace', whiteSpace:'nowrap' }}>
|
||||
{formatMsgTime(m.created_at)}
|
||||
</span>
|
||||
{isMine && (
|
||||
<span title={m.read_by_recipient ? 'Gelesen' : 'Gesendet'}
|
||||
style={{ fontSize:10, lineHeight:1, color: m.read_by_recipient ? '#4ecdc4' : 'rgba(255,255,255,0.2)' }}>
|
||||
{m.read_by_recipient ? '✓✓' : '✓'}
|
||||
</span>
|
||||
)}
|
||||
<button onClick={() => deleteMsg(m.id)} style={{ background:'transparent', border:'none',
|
||||
cursor:'pointer', padding:'0 2px', opacity:0.3, lineHeight:1,
|
||||
display:'flex', alignItems:'center' }}>
|
||||
<TrashIcon size={10} color="#ff6b9d"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
<div ref={bottomRef}/>
|
||||
</div>
|
||||
|
||||
{/* Eingabe */}
|
||||
<div style={{ padding:'10px 14px', flexShrink:0,
|
||||
borderTop:`1px solid ${mirc?'#333':'rgba(255,255,255,0.06)'}`,
|
||||
background: mirc ? '#000' : '#0d0d0f' }}>
|
||||
{!activeUser.public_key || sharedKey === 'error' ? (
|
||||
<div style={{ color:'rgba(255,255,255,0.2)', fontSize:11, fontFamily:'monospace',
|
||||
textAlign:'center', padding:'8px 0' }}>
|
||||
Senden nicht möglich
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display:'flex', gap:8, alignItems:'stretch', position:'relative' }}>
|
||||
<div ref={pickerRef} style={{ position:'absolute', bottom:'100%', right:0 }}>
|
||||
{showPicker && (
|
||||
<EmojiPicker
|
||||
onSelect={em => { setText(t => t + em); setShowPicker(false); }}
|
||||
onClose={() => setShowPicker(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{mirc && (
|
||||
<span style={{ color:'#00ff00', fontFamily:"'Courier New',monospace", fontSize:12,
|
||||
alignSelf:'center', flexShrink:0 }}>[du]</span>
|
||||
)}
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={e => {
|
||||
const val = e.target.value;
|
||||
const last = val[val.length - 1];
|
||||
if (last === ' ' || last === '\n') setText(convertEmojis(val));
|
||||
else setText(val);
|
||||
}}
|
||||
onFocus={() => setShowPicker(false)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const converted = convertEmojis(text + ' ').trimEnd();
|
||||
if (converted !== text) setText(converted);
|
||||
send(converted);
|
||||
}
|
||||
}}
|
||||
placeholder={mirc ? 'Nachricht…' : 'Nachricht… (Enter senden · Shift+Enter neue Zeile)'}
|
||||
rows={2}
|
||||
style={{ ...S.inp, resize:'none', flex:1, fontSize: mirc ? 12 : 13,
|
||||
padding:'8px 10px', lineHeight:1.45, minHeight:42,
|
||||
...(mirc ? { background:'#111', border:'1px solid #444', color:'#d0d0d0',
|
||||
fontFamily:"'Courier New',monospace", borderRadius:2 } : {}) }}
|
||||
/>
|
||||
<button onClick={() => setShowPicker(v => !v)} style={{
|
||||
background: showPicker ? 'rgba(78,205,196,0.12)' : (mirc ? '#111' : 'rgba(255,255,255,0.05)'),
|
||||
border:`1px solid ${showPicker ? 'rgba(78,205,196,0.3)' : (mirc ? '#444' : 'rgba(255,255,255,0.1)')}`,
|
||||
borderRadius: mirc ? 2 : 8, cursor:'pointer', fontSize:17,
|
||||
padding:'0 10px', flexShrink:0, alignSelf:'stretch' }}>😊</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoBox({ color, children }) {
|
||||
return (
|
||||
<div style={{ background:`${color}08`, border:`1px solid ${color}25`,
|
||||
borderRadius:8, padding:'10px 14px', color, fontSize:11,
|
||||
fontFamily:'monospace', textAlign:'center', lineHeight:1.7 }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatMsgTime(isoStr) {
|
||||
if (!isoStr) return '';
|
||||
const d = new Date(isoStr.replace(' ', 'T'));
|
||||
if (isNaN(d)) return '';
|
||||
const now = new Date();
|
||||
const isToday = d.toDateString() === now.toDateString();
|
||||
const isThisYear = d.getFullYear() === now.getFullYear();
|
||||
const time = d.toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' });
|
||||
if (isToday) return `Heute, ${time}`;
|
||||
if (isThisYear) return `${d.toLocaleDateString('de-DE', { day:'numeric', month:'short' })}, ${time}`;
|
||||
return `${d.toLocaleDateString('de-DE', { day:'numeric', month:'short', year:'numeric' })}, ${time}`;
|
||||
}
|
||||
191
frontend/src/tools/paywallkiller.jsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { S } from '../lib.js';
|
||||
|
||||
const C = {
|
||||
accent: '#f59e0b',
|
||||
success: '#4ade80',
|
||||
error: '#f87171',
|
||||
muted: '#6b7280',
|
||||
};
|
||||
|
||||
export default function PaywallKiller() {
|
||||
const [inputUrl, setInputUrl] = useState('');
|
||||
const [archiveUrl, setArchiveUrl] = useState('');
|
||||
const [status, setStatus] = useState('idle');
|
||||
const inputRef = useRef(null);
|
||||
|
||||
function getTargetUrl() {
|
||||
let url = inputUrl.trim();
|
||||
if (!url.startsWith('http')) url = 'https://' + url;
|
||||
// Mobile Subdomains auf Desktop normalisieren (m.bild.de → www.bild.de)
|
||||
// damit archive.ph den richtigen Snapshot findet
|
||||
try {
|
||||
const u = new URL(url);
|
||||
if (u.hostname.startsWith('m.')) {
|
||||
u.hostname = 'www.' + u.hostname.slice(2);
|
||||
url = u.toString();
|
||||
} else if (u.hostname.startsWith('mobile.')) {
|
||||
u.hostname = 'www.' + u.hostname.slice(7);
|
||||
url = u.toString();
|
||||
}
|
||||
} catch {}
|
||||
return url;
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
if (!inputUrl.trim()) return;
|
||||
const targetUrl = getTargetUrl();
|
||||
|
||||
// archive.ph Submit (no-cors, fire-and-forget) – triggert Archivierung
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('url', targetUrl);
|
||||
formData.append('anyway', '1');
|
||||
fetch('https://archive.ph/submit/', {
|
||||
method: 'POST',
|
||||
mode: 'no-cors',
|
||||
body: formData,
|
||||
}).catch(() => {});
|
||||
} catch {}
|
||||
|
||||
setArchiveUrl(`https://archive.ph/newest/${targetUrl}`);
|
||||
setStatus('ready');
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
setInputUrl('');
|
||||
setArchiveUrl('');
|
||||
setStatus('idle');
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px 16px', maxWidth: 640, margin: '0 auto' }}>
|
||||
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 4 }}>
|
||||
<span style={{ fontSize: 20 }}>🔓</span>
|
||||
<span style={{ color: C.accent, fontFamily: 'monospace', fontSize: 13, letterSpacing: 1 }}>PAYWALL-KILLER</span>
|
||||
</div>
|
||||
<div style={{ color: C.muted, fontSize: 11, fontFamily: 'monospace', marginBottom: 10 }}>archive.ph · Als PDF speichern</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||
<span style={{ color: 'rgba(255,255,255,0.25)', fontFamily: 'monospace', fontSize: 9, letterSpacing: 0.5 }}>GETESTET MIT</span>
|
||||
{[
|
||||
{ name: 'waz.de', domain: 'waz.de' },
|
||||
{ name: 'bild.de', domain: 'bild.de' },
|
||||
{ name: 'heise.de', domain: 'heise.de' },
|
||||
].map(({ name, domain }) => (
|
||||
<div key={name} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '2px 7px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 4 }}>
|
||||
<img src={`https://www.google.com/s2/favicons?domain=${domain}&sz=16`} width={12} height={12} style={{ borderRadius: 2, opacity: 0.7 }} alt="" />
|
||||
<span style={{ color: 'rgba(255,255,255,0.35)', fontFamily: 'monospace', fontSize: 9 }}>{name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Eingabe */}
|
||||
<div style={{ ...S.card, marginBottom: 16 }}>
|
||||
<div style={{ ...S.head, marginBottom: 10 }}>ARTIKEL-URL</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={inputUrl}
|
||||
onChange={e => setInputUrl(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && status === 'idle' && handleSearch()}
|
||||
placeholder="URL einfügen (Long-Press → Einfügen)"
|
||||
style={{ ...S.inp, width: '100%', boxSizing: 'border-box', marginBottom: 10 }}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{status === 'idle' ? (
|
||||
<button onClick={handleSearch} disabled={!inputUrl.trim()}
|
||||
style={{ ...S.btn(C.accent), flex: 1, opacity: !inputUrl.trim() ? 0.45 : 1, cursor: !inputUrl.trim() ? 'default' : 'pointer' }}>
|
||||
🔍 Archiv suchen
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={handleReset}
|
||||
style={{ background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.14)', borderRadius: 6, padding: '8px 14px', color: 'rgba(255,255,255,0.6)', cursor: 'pointer', fontFamily: 'monospace', fontSize: 12 }}>
|
||||
✕ Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ergebnis */}
|
||||
{status === 'ready' && (
|
||||
<div style={{ ...S.card, borderColor: `${C.accent}55`, marginBottom: 12 }}>
|
||||
<div style={{ ...S.head, marginBottom: 12 }}>ARTIKEL ÖFFNEN & ALS PDF SPEICHERN</div>
|
||||
|
||||
{/* Schritt 1 */}
|
||||
<div style={{ display: 'flex', gap: 10, marginBottom: 4 }}>
|
||||
<span style={{ background: `${C.accent}20`, border: `1px solid ${C.accent}44`, borderRadius: '50%', width: 22, height: 22, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 11, color: C.accent, fontFamily: 'monospace', flexShrink: 0, marginTop: 1 }}>1</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ color: 'rgba(255,255,255,0.7)', fontFamily: 'monospace', fontSize: 11, marginBottom: 8 }}>
|
||||
Öffne den Artikel auf archive.ph. Falls CAPTCHA erscheint: einmalig lösen. Falls "No results": auf <strong style={{ color: 'rgba(255,255,255,0.5)' }}>"archive this url"</strong> klicken, kurz warten, dann nochmal öffnen.
|
||||
</div>
|
||||
<a href={archiveUrl} target="_blank" rel="noreferrer"
|
||||
style={{ ...S.btn(C.accent), display: 'block', textAlign: 'center', textDecoration: 'none', boxSizing: 'border-box', fontSize: 13 }}>
|
||||
🌐 Artikel auf archive.ph öffnen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nochmal öffnen nach Archivierung */}
|
||||
<div style={{ margin: '10px 0 16px 32px', padding: '8px 12px', background: 'rgba(255,255,255,0.03)', borderRadius: 6, border: '1px solid rgba(255,255,255,0.07)' }}>
|
||||
<div style={{ color: C.muted, fontSize: 10, fontFamily: 'monospace', marginBottom: 6 }}>
|
||||
Nach "archive this url" ~15–30s warten, dann:
|
||||
</div>
|
||||
<a href={archiveUrl} target="_blank" rel="noreferrer"
|
||||
style={{ ...S.btn(C.muted), display: 'inline-block', textDecoration: 'none', fontSize: 11 }}>
|
||||
🔄 Nochmal öffnen
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Schritt 2 */}
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<span style={{ background: `${C.success}20`, border: `1px solid ${C.success}44`, borderRadius: '50%', width: 22, height: 22, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 11, color: C.success, fontFamily: 'monospace', flexShrink: 0, marginTop: 1 }}>2</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ color: 'rgba(255,255,255,0.7)', fontFamily: 'monospace', fontSize: 11, marginBottom: 8 }}>
|
||||
Wenn der Artikel sichtbar ist, als PDF speichern:
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{[
|
||||
['📱', 'Android Chrome', 'Menü (⋮) → Teilen → Drucken → Als PDF · Kopf-/Fußzeilen: aus'],
|
||||
['🍎', 'iOS Safari', 'Teilen-Button → Als PDF sichern'],
|
||||
['💻', 'Desktop', 'Strg+P / Cmd+P → Als PDF · Kopf-/Fußzeilen: aus'],
|
||||
].map(([icon, label, desc]) => (
|
||||
<div key={label} style={{ padding: '8px 10px', background: 'rgba(255,255,255,0.03)', borderRadius: 6, border: '1px solid rgba(255,255,255,0.07)' }}>
|
||||
<div style={{ color: 'rgba(255,255,255,0.6)', fontFamily: 'monospace', fontSize: 11, marginBottom: 2 }}>{icon} {label}</div>
|
||||
<div style={{ color: C.muted, fontFamily: 'monospace', fontSize: 10, lineHeight: 1.5 }}>{desc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: 10, padding: '8px 10px', background: 'rgba(255,255,255,0.02)', borderRadius: 6, border: '1px solid rgba(255,255,255,0.05)' }}>
|
||||
<div style={{ color: 'rgba(255,255,255,0.3)', fontFamily: 'monospace', fontSize: 10, lineHeight: 1.5 }}>
|
||||
ℹ️ Der archive.ph-Balken oben ist Teil der archivierten Seite und kann nicht automatisch entfernt werden.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Idle-Info */}
|
||||
{status === 'idle' && (
|
||||
<div style={{ ...S.card }}>
|
||||
<div style={{ ...S.head, marginBottom: 10 }}>SO FUNKTIONIERT ES</div>
|
||||
{[
|
||||
['1', 'URL des gesperrten Artikels einfügen'],
|
||||
['2', 'Artikel auf archive.ph öffnen'],
|
||||
['3', 'Falls nicht archiviert: "archive this url" klicken, kurz warten, nochmal öffnen'],
|
||||
['4', 'Über Drucken-Funktion als PDF speichern'],
|
||||
].map(([num, text]) => (
|
||||
<div key={num} style={{ display: 'flex', gap: 10, alignItems: 'flex-start', marginBottom: 8 }}>
|
||||
<span style={{ background: `${C.accent}20`, border: `1px solid ${C.accent}44`, borderRadius: '50%', width: 20, height: 20, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 10, color: C.accent, fontFamily: 'monospace', marginTop: 2 }}>{num}</span>
|
||||
<span style={{ color: C.muted, fontSize: 11, fontFamily: 'monospace', lineHeight: 1.6 }}>{text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
686
frontend/src/tools/schocken.jsx
Normal file
@@ -0,0 +1,686 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { api, S } from '../lib.js';
|
||||
|
||||
// Testmodus: API-Call als anderer User (nur Admin, nur Schocken)
|
||||
async function apiAs(userId, path, opts = {}) {
|
||||
const token = localStorage.getItem('sk_token');
|
||||
const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` };
|
||||
if (userId) headers['X-Schocken-Test-As'] = String(userId);
|
||||
const res = await fetch(`/api${path}`, {
|
||||
method: opts.method || (opts.body ? 'POST' : 'GET'),
|
||||
headers,
|
||||
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
||||
});
|
||||
if (!res.ok) { const e = await res.json().catch(()=>({error:'Fehler'})); throw new Error(e.error||'Fehler'); }
|
||||
return res.json();
|
||||
}
|
||||
|
||||
const MY_COLOR = '#4ecdc4';
|
||||
|
||||
function getMyId() {
|
||||
try { return JSON.parse(atob(localStorage.getItem('sk_token').split('.')[1])).id; } catch { return null; }
|
||||
}
|
||||
function getMyRole() {
|
||||
try { return JSON.parse(atob(localStorage.getItem('sk_token').split('.')[1])).role; } catch { return null; }
|
||||
}
|
||||
|
||||
// ── Würfel-Visualisierung ─────────────────────────────────────────────────────
|
||||
const DOTS = {
|
||||
1: [[50,50]],
|
||||
2: [[25,25],[75,75]],
|
||||
3: [[25,25],[50,50],[75,75]],
|
||||
4: [[25,25],[75,25],[25,75],[75,75]],
|
||||
5: [[25,25],[75,25],[50,50],[25,75],[75,75]],
|
||||
6: [[25,25],[75,25],[25,50],[75,50],[25,75],[75,75]],
|
||||
};
|
||||
|
||||
function Die({ value, kept, selected, onClick, dark, size=56 }) {
|
||||
const dots = value ? DOTS[value] : [];
|
||||
const bg = dark ? 'rgba(40,40,60,0.9)' : kept ? 'rgba(78,205,196,0.15)' : 'rgba(255,255,255,0.08)';
|
||||
const border = selected ? '2px solid #4ecdc4' : kept ? '2px solid rgba(78,205,196,0.4)' : '1px solid rgba(255,255,255,0.15)';
|
||||
return (
|
||||
<div onClick={onClick} style={{
|
||||
width:size, height:size, borderRadius:10, background:bg, border,
|
||||
cursor:onClick?'pointer':'default', position:'relative', flexShrink:0,
|
||||
transition:'all 0.15s', boxShadow: selected ? '0 0 12px rgba(78,205,196,0.4)' : 'none',
|
||||
}}>
|
||||
{dark && <div style={{position:'absolute',inset:0,display:'flex',alignItems:'center',justifyContent:'center',color:'rgba(255,255,255,0.2)',fontSize:22}}>🎲</div>}
|
||||
{!dark && dots.map(([x,y],i) => (
|
||||
<div key={i} style={{
|
||||
position:'absolute',
|
||||
left:`${x}%`, top:`${y}%`,
|
||||
transform:'translate(-50%,-50%)',
|
||||
width:size>40?10:7, height:size>40?10:7,
|
||||
borderRadius:'50%', background:'#fff',
|
||||
}}/>
|
||||
))}
|
||||
{!value && !dark && <div style={{position:'absolute',inset:0,display:'flex',alignItems:'center',justifyContent:'center',color:'rgba(255,255,255,0.15)',fontSize:10,fontFamily:'monospace'}}>?</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Spieler-Zeile ─────────────────────────────────────────────────────────────
|
||||
function PlayerRow({ player, chips, has16th, isMe, isCurrent, result, drinkLosses, game }) {
|
||||
const isLoserH1 = game.has_16th === player.id;
|
||||
return (
|
||||
<div style={{
|
||||
...S.card, padding:'10px 14px', marginBottom:8,
|
||||
border: isCurrent ? '1px solid rgba(78,205,196,0.4)' : '1px solid rgba(255,255,255,0.07)',
|
||||
background: isMe ? 'rgba(78,205,196,0.04)' : 'rgba(255,255,255,0.02)',
|
||||
}}>
|
||||
<div style={{display:'flex',alignItems:'center',gap:10}}>
|
||||
<div style={{flex:1}}>
|
||||
<div style={{color:'#fff',fontFamily:'monospace',fontSize:13,display:'flex',alignItems:'center',gap:6}}>
|
||||
{isCurrent && <span style={{color:MY_COLOR}}>▶</span>}
|
||||
<strong>{player.username}</strong>
|
||||
{isMe && <span style={{color:'rgba(255,255,255,0.3)',fontSize:10}}>(du)</span>}
|
||||
{isLoserH1 && <span title="Verlierer 1. Hälfte" style={{fontSize:14}}>🪶</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{display:'flex',alignItems:'center',gap:12}}>
|
||||
{drinkLosses > 0 && (
|
||||
<span style={{color:'#f59e0b',fontFamily:'monospace',fontSize:11}}>🍺×{drinkLosses}</span>
|
||||
)}
|
||||
<div style={{textAlign:'center'}}>
|
||||
<div style={{color:chips>0?'#ff6b9d':MY_COLOR,fontFamily:'Space Mono,monospace',fontSize:18,fontWeight:700,lineHeight:1}}>{chips}</div>
|
||||
<div style={{color:'rgba(255,255,255,0.25)',fontSize:9,fontFamily:'monospace'}}>Scheiben</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{result && (
|
||||
<div style={{marginTop:6,padding:'4px 8px',background:'rgba(255,255,255,0.04)',borderRadius:6,
|
||||
color:MY_COLOR,fontFamily:'monospace',fontSize:11}}>
|
||||
{result.label}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Würfelbereich ─────────────────────────────────────────────────────────────
|
||||
function DiceArea({ game, myId, onRoll, onReady, onEvaluate }) {
|
||||
const [selectedKeep, setSelectedKeep] = useState([]);
|
||||
const [goDark, setGoDark] = useState(false);
|
||||
const [flipSixes, setFlipSixes] = useState(false);
|
||||
const [rolling, setRolling] = useState(false);
|
||||
|
||||
const round = game.current_round;
|
||||
const players = game.players;
|
||||
const activePlayers = getActivePlayers(game);
|
||||
const myState = round?.playerStates?.[myId];
|
||||
const currentPlayer = activePlayers[game.current_player_idx % activePlayers.length];
|
||||
const isMyTurn = currentPlayer?.id === myId;
|
||||
const allReady = activePlayers.every(p => round?.ready?.[p.id]);
|
||||
const allDone = round?.phase === 'reveal';
|
||||
const isFirstRound = !!game.first_round;
|
||||
const myDice = myState?.dice || [null, null, null];
|
||||
const myRolls = myState?.roll_count || 0;
|
||||
const maxRolls = round?.max_rolls || 3;
|
||||
const canFlipSixes = myDice.filter(d => d===6).length >= 2 && myRolls < maxRolls - 1;
|
||||
|
||||
// Becher umdrehen Phase — warte bis alle ready
|
||||
if (!round || !allReady) {
|
||||
const iMReady = round?.ready?.[myId];
|
||||
const readyCount = Object.keys(round?.ready || {}).length;
|
||||
const activeCount = activePlayers.length;
|
||||
return (
|
||||
<div>
|
||||
<div style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:12,textAlign:'center',marginBottom:12}}>
|
||||
{readyCount}/{activeCount} haben den Becher umgedreht
|
||||
</div>
|
||||
{!iMReady && (
|
||||
<button onClick={onReady} style={{...S.btn('#4ecdc4'),width:'100%',fontSize:14,padding:'12px'}}>
|
||||
🎲 Becher umdrehen
|
||||
</button>
|
||||
)}
|
||||
{iMReady && (
|
||||
<div style={{color:MY_COLOR,fontFamily:'monospace',fontSize:12,textAlign:'center',padding:'12px 0'}}>
|
||||
✓ Becher umgedreht — warte auf andere…
|
||||
</div>
|
||||
)}
|
||||
{isFirstRound && iMReady && readyCount === activeCount && (
|
||||
<div style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:11,textAlign:'center',marginTop:8}}>
|
||||
Alle bereit — Becher werden gleichzeitig gehoben!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Auswertungsphase — alle Würfel sichtbar
|
||||
if (allDone) {
|
||||
const isFirstPlayer = activePlayers[0]?.id === myId || players[0]?.id === myId;
|
||||
return (
|
||||
<div>
|
||||
<div style={{color:MY_COLOR,fontFamily:'monospace',fontSize:12,textAlign:'center',marginBottom:14}}>
|
||||
🎲 Alle Becher oben — wer hat was?
|
||||
</div>
|
||||
{/* Alle Spieler mit Würfeln und Ergebnis */}
|
||||
{activePlayers.map(p => {
|
||||
const ps = round.playerStates?.[p.id];
|
||||
const isP = p.id === myId;
|
||||
return (
|
||||
<div key={p.id} style={{
|
||||
...S.card, padding:'10px 14px', marginBottom:8,
|
||||
border: isP ? '1px solid rgba(78,205,196,0.3)' : '1px solid rgba(255,255,255,0.07)',
|
||||
}}>
|
||||
<div style={{display:'flex',alignItems:'center',gap:10,marginBottom:8}}>
|
||||
<span style={{color:isP?MY_COLOR:'rgba(255,255,255,0.7)',fontFamily:'monospace',fontSize:12,fontWeight:700}}>
|
||||
{p.username}{isP?' (du)':''}
|
||||
</span>
|
||||
{ps?.dark && <span style={{color:'rgba(255,255,255,0.4)',fontSize:10,fontFamily:'monospace'}}>🌑 war dunkel</span>}
|
||||
{ps?.roll_count && <span style={{color:'rgba(255,255,255,0.3)',fontSize:10,fontFamily:'monospace',marginLeft:'auto'}}>{ps.roll_count}. Wurf</span>}
|
||||
</div>
|
||||
<div style={{display:'flex',gap:8,marginBottom:8}}>
|
||||
{(ps?.dice||[null,null,null]).map((d,i) => <Die key={i} value={d} size={44}/>)}
|
||||
</div>
|
||||
{ps?.result && (
|
||||
<div style={{
|
||||
padding:'4px 10px', borderRadius:6, display:'inline-block',
|
||||
background: ps.result.type==='schock_aus'?'rgba(239,68,68,0.15)':
|
||||
ps.result.type==='julchen'?'rgba(255,230,109,0.15)':
|
||||
ps.result.type==='general'?'rgba(78,205,196,0.12)':'rgba(255,255,255,0.05)',
|
||||
border: ps.result.type==='schock_aus'?'1px solid rgba(239,68,68,0.4)':
|
||||
ps.result.type==='julchen'?'1px solid rgba(255,230,109,0.4)':
|
||||
ps.result.type==='general'?'1px solid rgba(78,205,196,0.3)':'1px solid rgba(255,255,255,0.08)',
|
||||
color: ps.result.type==='schock_aus'?'#ef4444':
|
||||
ps.result.type==='julchen'?'#ffe66d':
|
||||
ps.result.type==='general'?MY_COLOR:'rgba(255,255,255,0.6)',
|
||||
fontFamily:'monospace', fontSize:11,
|
||||
}}>
|
||||
{ps.result.label} {ps.result.scheiben > 0 ? `→ ${ps.result.scheiben} Scheibe${ps.result.scheiben!==1?'n':''}` : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{isFirstPlayer && (
|
||||
<button onClick={onEvaluate} style={{...S.btn('#ffe66d'),width:'100%',marginTop:4}}>
|
||||
✓ Scheiben verteilen
|
||||
</button>
|
||||
)}
|
||||
{!isFirstPlayer && (
|
||||
<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:11,textAlign:'center',padding:'8px 0'}}>
|
||||
Warte auf {activePlayers[0]?.username} zum Auswerten…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Mein Würfeln
|
||||
return (
|
||||
<div>
|
||||
{/* Status */}
|
||||
<div style={{color:isMyTurn?MY_COLOR:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:11,textAlign:'center',marginBottom:10}}>
|
||||
{isMyTurn ? `▶ Dein Wurf (${myRolls}/${maxRolls||'?'})` : `⏳ ${currentPlayer?.username} würfelt…`}
|
||||
</div>
|
||||
|
||||
{/* Würfel */}
|
||||
<div style={{display:'flex',gap:10,justifyContent:'center',marginBottom:14}}>
|
||||
{myDice.map((d, i) => (
|
||||
<Die
|
||||
key={i}
|
||||
value={d}
|
||||
dark={myState?.dark}
|
||||
kept={selectedKeep.includes(i)}
|
||||
selected={selectedKeep.includes(i)}
|
||||
onClick={isMyTurn && myRolls > 0 && !myState?.done ? () => {
|
||||
setSelectedKeep(prev =>
|
||||
prev.includes(i) ? prev.filter(x=>x!==i) : [...prev,i]
|
||||
);
|
||||
} : undefined}
|
||||
size={64}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Optionen */}
|
||||
{isMyTurn && !myState?.done && (
|
||||
<div style={{display:'flex',flexDirection:'column',gap:8}}>
|
||||
{myRolls > 0 && (
|
||||
<div style={{display:'flex',gap:8,fontSize:11,fontFamily:'monospace',color:'rgba(255,255,255,0.5)'}}>
|
||||
{selectedKeep.length > 0 && <span>📌 {selectedKeep.length} Würfel stehen lassen</span>}
|
||||
</div>
|
||||
)}
|
||||
{canFlipSixes && (
|
||||
<label style={{display:'flex',alignItems:'center',gap:8,cursor:'pointer',fontFamily:'monospace',fontSize:11,color:'rgba(255,255,255,0.6)'}}>
|
||||
<input type="checkbox" checked={flipSixes} onChange={e=>setFlipSixes(e.target.checked)}/>
|
||||
Zwei Sechsen zu Einer Eins umdrehen
|
||||
</label>
|
||||
)}
|
||||
{myRolls > 0 && myRolls < (maxRolls||3) && (
|
||||
<label style={{display:'flex',alignItems:'center',gap:8,cursor:'pointer',fontFamily:'monospace',fontSize:11,color:'rgba(255,255,255,0.6)'}}>
|
||||
<input type="checkbox" checked={goDark} onChange={e=>setGoDark(e.target.checked)}/>
|
||||
Dunkel legen (Becher nicht heben)
|
||||
</label>
|
||||
)}
|
||||
<button
|
||||
disabled={rolling}
|
||||
onClick={async () => {
|
||||
setRolling(true);
|
||||
try { await onRoll(selectedKeep, flipSixes, goDark); setSelectedKeep([]); setFlipSixes(false); setGoDark(false); }
|
||||
finally { setRolling(false); }
|
||||
}}
|
||||
style={{...S.btn(MY_COLOR), padding:'10px', fontSize:13}}
|
||||
>
|
||||
{rolling ? '🎲 Würfle…' : myRolls === 0 ? '🎲 Becher heben' : goDark ? '🎲 Dunkel würfeln' : '🎲 Nochmal würfeln'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{myState?.done && !allDone && (
|
||||
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:11,textAlign:'center',padding:'8px 0'}}>
|
||||
{myState.dark ? '🎲 Du bist dunkel — warte auf andere…' : `✓ Du stehst im ${myRolls}. — warte auf andere…`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Andere Spieler Status */}
|
||||
<div style={{marginTop:12,display:'flex',flexWrap:'wrap',gap:6}}>
|
||||
{activePlayers.filter(p => p.id !== myId).map(p => {
|
||||
const ps = round.playerStates?.[p.id];
|
||||
return (
|
||||
<div key={p.id} style={{
|
||||
padding:'4px 10px', borderRadius:20,
|
||||
background: ps?.done ? 'rgba(78,205,196,0.1)' : 'rgba(255,255,255,0.04)',
|
||||
border: ps?.done ? '1px solid rgba(78,205,196,0.3)' : '1px solid rgba(255,255,255,0.08)',
|
||||
color: ps?.done ? MY_COLOR : 'rgba(255,255,255,0.4)',
|
||||
fontFamily:'monospace', fontSize:10,
|
||||
}}>
|
||||
{ps?.done ? '✓' : '⏳'} {p.username} {ps?.dark && '🌑'}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Schockstock ───────────────────────────────────────────────────────────────
|
||||
function Schockstock({ total=15, stock, players, chips }) {
|
||||
const used = total - stock;
|
||||
return (
|
||||
<div style={{...S.card,padding:'10px 14px',marginBottom:12}}>
|
||||
<div style={{display:'flex',alignItems:'center',gap:8,marginBottom:8}}>
|
||||
<span style={{color:'rgba(255,255,255,0.55)',fontFamily:'monospace',fontSize:11,letterSpacing:1}}>SCHOCKSTOCK</span>
|
||||
<span style={{color:MY_COLOR,fontFamily:'monospace',fontSize:11,marginLeft:'auto'}}>{stock} verbleibend</span>
|
||||
</div>
|
||||
<div style={{display:'flex',gap:3,flexWrap:'wrap'}}>
|
||||
{Array.from({length:16}).map((_,i) => {
|
||||
const is16th = i === 15;
|
||||
const isUsed = !is16th && i >= stock;
|
||||
return (
|
||||
<div key={i} style={{
|
||||
width:14, height:14, borderRadius:'50%',
|
||||
background: is16th
|
||||
? 'rgba(245,158,11,0.6)'
|
||||
: isUsed
|
||||
? 'rgba(255,255,255,0.1)'
|
||||
: '#4ecdc4',
|
||||
border: is16th ? '1px solid #f59e0b' : '1px solid rgba(255,255,255,0.1)',
|
||||
title: is16th ? '16. Scheibe' : '',
|
||||
}}/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Neue Runde Button ─────────────────────────────────────────────────────────
|
||||
function NewRoundButton({ game, myId, onReady }) {
|
||||
// Der Beginner der neuen Runde startet mit Becher umdrehen
|
||||
const isStarter = game.beginner_id === myId;
|
||||
return (
|
||||
<div style={{...S.card,padding:'14px',textAlign:'center',marginBottom:12,border:'1px solid rgba(78,205,196,0.2)'}}>
|
||||
<div style={{color:'rgba(255,255,255,0.6)',fontFamily:'monospace',fontSize:12,marginBottom:10}}>
|
||||
Neue Runde — {isStarter ? 'du fängst an!' : `${game.players.find(p=>p.id===game.beginner_id)?.username} fängt an`}
|
||||
</div>
|
||||
<button onClick={onReady} style={{...S.btn('#4ecdc4'),width:'100%',fontSize:14,padding:'12px'}}>
|
||||
🎲 Becher umdrehen
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Spielende ─────────────────────────────────────────────────────────────────
|
||||
function GameOver({ game }) {
|
||||
const dl = game.drink_losses;
|
||||
const loser = game.players.find(p => dl[p.id] > 0);
|
||||
const isDoppelfeige = game.loser_h1 === game.loser_h2;
|
||||
return (
|
||||
<div style={{...S.card,padding:'20px',textAlign:'center',border:'1px solid rgba(245,158,11,0.3)'}}>
|
||||
<div style={{fontSize:48,marginBottom:12}}>{isDoppelfeige ? '🪶🪶' : '🍺'}</div>
|
||||
{isDoppelfeige ? (
|
||||
<div style={{color:'#f59e0b',fontFamily:'Space Mono,monospace',fontSize:16,fontWeight:700,marginBottom:8}}>
|
||||
DOPPELFEIGE!
|
||||
</div>
|
||||
) : (
|
||||
<div style={{color:'#f59e0b',fontFamily:'Space Mono,monospace',fontSize:16,fontWeight:700,marginBottom:8}}>
|
||||
RUNDE VORBEI
|
||||
</div>
|
||||
)}
|
||||
{loser && (
|
||||
<div style={{color:'rgba(255,255,255,0.7)',fontFamily:'monospace',fontSize:13,marginBottom:16}}>
|
||||
{loser.username} zahlt die nächste Runde! 🍺
|
||||
</div>
|
||||
)}
|
||||
<div style={{marginTop:12}}>
|
||||
<div style={{...S.head,marginBottom:8}}>GETRÄNKE-STATISTIK</div>
|
||||
{game.players.map(p => (
|
||||
<div key={p.id} style={{display:'flex',justifyContent:'space-between',padding:'4px 0',
|
||||
borderBottom:'1px solid rgba(255,255,255,0.05)',fontFamily:'monospace',fontSize:12}}>
|
||||
<span style={{color:'rgba(255,255,255,0.7)'}}>{p.username}</span>
|
||||
<span style={{color:dl[p.id]>0?'#f59e0b':MY_COLOR}}>🍺 {dl[p.id]||0}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Aktive Spieler berechnen (Frontend) ───────────────────────────────────────
|
||||
function getActivePlayers(game) {
|
||||
const { players, player_chips, stock, status, loser_h1, loser_h2 } = game;
|
||||
if (status === 'endkampf') {
|
||||
return players.filter(p => p.id === loser_h1 || p.id === loser_h2);
|
||||
}
|
||||
if (stock > 0) return players;
|
||||
return players.filter(p => player_chips[p.id] > 0);
|
||||
}
|
||||
|
||||
// ── Neues Spiel Modal ─────────────────────────────────────────────────────────
|
||||
function NewGameModal({ onClose, onCreate, toast }) {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [picked, setPicked] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => { api('/tools/schocken/users').then(setUsers).catch(()=>{}); }, []);
|
||||
|
||||
const toggle = id => setPicked(p => p.includes(id) ? p.filter(x=>x!==id) : [...p,id]);
|
||||
|
||||
const create = async () => {
|
||||
if (!picked.length) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await api('/tools/schocken', { body: { player_ids: picked } });
|
||||
toast('Spiel erstellt! 🎲');
|
||||
onCreate(r.id);
|
||||
} catch(e) { toast(e.message||'Fehler','error'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.85)',display:'flex',alignItems:'center',justifyContent:'center',zIndex:1000,padding:20}}>
|
||||
<div style={{...S.card,maxWidth:380,width:'100%'}}>
|
||||
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:18}}>
|
||||
<span style={{color:'#fff',fontFamily:'Space Mono,monospace',fontSize:13,fontWeight:700,letterSpacing:1}}>NEUES SPIEL</span>
|
||||
<button onClick={onClose} style={S.btn('#666666',true)}>✕</button>
|
||||
</div>
|
||||
<div style={{...S.head,marginBottom:8}}>MITSPIELER WÄHLEN</div>
|
||||
<div style={{maxHeight:240,overflowY:'auto',marginBottom:16}}>
|
||||
{users.map(u => (
|
||||
<div key={u.id} onClick={()=>toggle(u.id)} style={{
|
||||
display:'flex',alignItems:'center',gap:10,padding:'8px 10px',
|
||||
cursor:'pointer',borderRadius:8,marginBottom:4,
|
||||
background: picked.includes(u.id) ? 'rgba(78,205,196,0.1)' : 'rgba(255,255,255,0.03)',
|
||||
border: picked.includes(u.id) ? '1px solid rgba(78,205,196,0.3)' : '1px solid rgba(255,255,255,0.06)',
|
||||
}}>
|
||||
<span style={{color:picked.includes(u.id)?MY_COLOR:'rgba(255,255,255,0.5)',fontSize:14}}>
|
||||
{picked.includes(u.id)?'☑':'☐'}
|
||||
</span>
|
||||
<span style={{color:'#fff',fontFamily:'monospace',fontSize:13}}>{u.username}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{display:'flex',gap:8}}>
|
||||
<button onClick={onClose} style={{...S.btn('#666666'),flex:1}}>Abbrechen</button>
|
||||
<button onClick={create} disabled={!picked.length||loading} style={{...S.btn('#4ecdc4'),flex:1}}>
|
||||
{loading ? 'Erstelle…' : `Starten (${picked.length+1} Spieler)`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Spielliste ────────────────────────────────────────────────────────────────
|
||||
function GameList({ games, myId, onSelect, onNew, onDelete, isAdmin }) {
|
||||
const active = games.filter(g => g.status !== 'finished');
|
||||
const finished = games.filter(g => g.status === 'finished');
|
||||
|
||||
const statusLabel = s => ({
|
||||
lobby:'Lobby', half1:'1. Hälfte', half2:'2. Hälfte', endkampf:'Endkampf', finished:'Beendet'
|
||||
}[s]||s);
|
||||
|
||||
const GameRow = ({ g }) => {
|
||||
const others = g.players.filter(p=>p.id!==myId).map(p=>p.username).join(', ');
|
||||
return (
|
||||
<div style={{...S.card,padding:'12px 14px',marginBottom:8,display:'flex',alignItems:'center',gap:10,border:'1px solid rgba(255,255,255,0.07)'}}>
|
||||
<button onClick={()=>onSelect(g.id)} style={{flex:1,background:'none',border:'none',cursor:'pointer',textAlign:'left',padding:0}}>
|
||||
<div style={{color:'#fff',fontFamily:'monospace',fontSize:13,marginBottom:3}}>
|
||||
🎲 mit <strong>{others}</strong>
|
||||
</div>
|
||||
<div style={{color:'rgba(255,255,255,0.35)',fontSize:11,fontFamily:'monospace'}}>
|
||||
{g.players.length} Spieler · {statusLabel(g.status)}
|
||||
</div>
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button onClick={async e => {
|
||||
e.stopPropagation();
|
||||
if (!window.confirm('Spiel löschen?')) return;
|
||||
try {
|
||||
await apiAs(myId, `/tools/schocken/${g.id}/cancel`, { body:{} });
|
||||
onDelete(g.id);
|
||||
} catch(e) { alert(e.message||'Fehler'); }
|
||||
}} style={{...S.btn('#ff6b9d',true),flexShrink:0}}>🗑</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={onNew} style={{...S.btn('#4ecdc4'),width:'100%',marginBottom:20,padding:'10px 14px',fontSize:13}}>
|
||||
+ Neues Spiel starten
|
||||
</button>
|
||||
{active.length > 0 && <>
|
||||
<div style={{...S.head,marginBottom:8}}>LAUFEND ({active.length})</div>
|
||||
{active.map(g=><GameRow key={g.id} g={g}/>)}
|
||||
</>}
|
||||
{finished.length > 0 && <>
|
||||
<div style={{...S.head,marginTop:16,marginBottom:8}}>BEENDET</div>
|
||||
{finished.map(g=><GameRow key={g.id} g={g}/>)}
|
||||
</>}
|
||||
{games.length===0 && (
|
||||
<div style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:13,textAlign:'center',paddingTop:40}}>
|
||||
Noch keine Spiele — starte eines!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Spielansicht ──────────────────────────────────────────────────────────────
|
||||
function GameView({ game, myId, onRefresh, toast, isAdmin=false, onSeatChange }) {
|
||||
const players = game.players;
|
||||
const chips = game.player_chips;
|
||||
const dl = game.drink_losses;
|
||||
const round = game.current_round;
|
||||
const activePlayers = getActivePlayers(game);
|
||||
const currentPlayer = activePlayers[game.current_player_idx % activePlayers.length];
|
||||
|
||||
const handleRoll = async (keepDice, flipSixes, goDark) => {
|
||||
await apiAs(myId, `/tools/schocken/${game.id}/roll`, {
|
||||
body: { keep_dice: keepDice, flip_sixes: flipSixes, go_dark: goDark }
|
||||
});
|
||||
onRefresh();
|
||||
};
|
||||
|
||||
const handleReady = async () => {
|
||||
await apiAs(myId, `/tools/schocken/${game.id}/ready`, { body: {} });
|
||||
onRefresh();
|
||||
};
|
||||
|
||||
const handleEvaluate = async () => {
|
||||
const result = await apiAs(myId, `/tools/schocken/${game.id}/evaluate`, { body: {} });
|
||||
if (result.round_result?.eventMsg) toast(result.round_result.eventMsg);
|
||||
onRefresh();
|
||||
};
|
||||
|
||||
const handleStart = async () => {
|
||||
await apiAs(myId, `/tools/schocken/${game.id}/start`, { body: {} });
|
||||
onRefresh();
|
||||
};
|
||||
|
||||
const statusLabel = { lobby:'Lobby', half1:'1. Hälfte', half2:'2. Hälfte', endkampf:'Endkampf', finished:'Beendet' };
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Phase-Header */}
|
||||
<div style={{...S.card,padding:'8px 14px',marginBottom:12,display:'flex',alignItems:'center',gap:10}}>
|
||||
<span style={{color:MY_COLOR,fontFamily:'monospace',fontSize:11,letterSpacing:1}}>{statusLabel[game.status]}</span>
|
||||
<span style={{flex:1}}/>
|
||||
<span style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10}}>Runde {game.round_number}</span>
|
||||
</div>
|
||||
|
||||
{/* Testmodus-Switcher (nur Admin) */}
|
||||
{isAdmin && (
|
||||
<div style={{marginBottom:12,padding:'8px 12px',background:'rgba(245,158,11,0.08)',border:'1px solid rgba(245,158,11,0.25)',borderRadius:8}}>
|
||||
<div style={{color:'#f59e0b',fontFamily:'monospace',fontSize:10,letterSpacing:1,marginBottom:6}}>🧪 TESTMODUS — SPIELER WECHSELN</div>
|
||||
<div style={{display:'flex',gap:6,flexWrap:'wrap'}}>
|
||||
{players.map(p => (
|
||||
<button key={p.id} onClick={() => onSeatChange(p.id)}
|
||||
style={{...S.btn(myId===p.id?'#f59e0b':'#666666',true),fontSize:10}}>
|
||||
{p.username}{myId===p.id?' ✓':''}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Schockstock */}
|
||||
<Schockstock stock={game.stock} players={players} chips={chips}/>
|
||||
|
||||
{/* Spieler */}
|
||||
<div style={{marginBottom:12}}>
|
||||
{players.map(p => (
|
||||
<PlayerRow
|
||||
key={p.id}
|
||||
player={p}
|
||||
chips={chips[p.id]||0}
|
||||
has16th={game.has_16th}
|
||||
isMe={p.id===myId}
|
||||
isCurrent={currentPlayer?.id===p.id}
|
||||
result={round?.playerStates?.[p.id]?.result}
|
||||
drinkLosses={dl[p.id]||0}
|
||||
game={game}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Lobby */}
|
||||
{game.status==='lobby' && (
|
||||
<div style={{textAlign:'center'}}>
|
||||
{players[0]?.id===myId ? (
|
||||
<button onClick={handleStart} style={{...S.btn('#4ecdc4'),width:'100%',fontSize:14,padding:'12px'}}>
|
||||
🎲 Spiel starten
|
||||
</button>
|
||||
) : (
|
||||
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:12,padding:'20px 0'}}>
|
||||
Warte auf {players[0]?.username} um das Spiel zu starten…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Spielfeld */}
|
||||
{game.status!=='lobby' && game.status!=='finished' && (
|
||||
<div style={{...S.card,padding:'14px',border:'1px solid rgba(78,205,196,0.15)'}}>
|
||||
<DiceArea
|
||||
game={game}
|
||||
myId={myId}
|
||||
onRoll={handleRoll}
|
||||
onReady={handleReady}
|
||||
onEvaluate={handleEvaluate}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Spielende */}
|
||||
{game.status==='finished' && <GameOver game={game}/>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Hauptkomponente ───────────────────────────────────────────────────────────
|
||||
export default function Schocken({ toast }) {
|
||||
const [games, setGames] = useState([]);
|
||||
const [activeId, setActiveId] = useState(null);
|
||||
const [game, setGame] = useState(null);
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [testSeat, setTestSeat] = useState(null); // null = eigener Account, sonst userId
|
||||
|
||||
const myRealId = getMyId();
|
||||
const isAdmin = getMyRole() === 'admin';
|
||||
// Im Testmodus: als anderen Spieler agieren
|
||||
const myId = (isAdmin && testSeat) ? testSeat : myRealId;
|
||||
|
||||
const loadGames = useCallback(async () => {
|
||||
try { setGames(await api('/tools/schocken')); }
|
||||
catch { toast?.('Fehler beim Laden','error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
const loadGame = useCallback(async () => {
|
||||
if (!activeId) { setGame(null); return; }
|
||||
try { setGame(await api(`/tools/schocken/${activeId}`)); }
|
||||
catch {}
|
||||
}, [activeId]);
|
||||
|
||||
useEffect(() => { loadGames(); }, [loadGames]);
|
||||
useEffect(() => { loadGame(); }, [loadGame]);
|
||||
|
||||
// Polling wenn Spiel aktiv
|
||||
useEffect(() => {
|
||||
if (!activeId) return;
|
||||
const iv = setInterval(loadGame, 5000);
|
||||
return () => clearInterval(iv);
|
||||
}, [activeId, loadGame]);
|
||||
|
||||
if (loading) return <div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',padding:40,textAlign:'center'}}>Lade…</div>;
|
||||
|
||||
return (
|
||||
<div style={{maxWidth:560,margin:'0 auto'}}>
|
||||
<div style={{display:'flex',alignItems:'center',gap:10,marginBottom:24,flexWrap:'wrap'}}>
|
||||
{activeId && (
|
||||
<button onClick={()=>{setActiveId(null);setGame(null);loadGames();}} style={S.btn('#666666',true)}>← Zurück</button>
|
||||
)}
|
||||
<h2 style={{margin:0,fontSize:15,fontFamily:'monospace',color:'rgba(255,255,255,0.55)',letterSpacing:2,fontWeight:400}}>
|
||||
🎲 SCHOCKEN
|
||||
</h2>
|
||||
<div style={{flex:1}}/>
|
||||
{activeId && game?.status !== 'finished' && isAdmin && (
|
||||
<button onClick={async () => {
|
||||
if (!window.confirm('Spiel wirklich abbrechen? Keine Punkte werden vergeben.')) return;
|
||||
try {
|
||||
await apiAs(myRealId, `/tools/schocken/${activeId}/cancel`, { body:{} });
|
||||
setActiveId(null); setGame(null); loadGames();
|
||||
toast('Spiel abgebrochen.');
|
||||
} catch(e) { toast(e.message||'Fehler','error'); }
|
||||
}} style={S.btn('#ff6b9d', true)}>✕ Abbrechen</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!activeId
|
||||
? <GameList games={games} myId={myId} onSelect={setActiveId} onNew={()=>setShowNew(true)} onDelete={id=>setGames(prev=>prev.filter(g=>g.id!==id))} isAdmin={isAdmin}/>
|
||||
: game && myId
|
||||
? <GameView game={game} myId={myId} onRefresh={loadGame} toast={toast} isAdmin={isAdmin} onSeatChange={id=>{setTestSeat(id);}} />
|
||||
: <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',textAlign:'center',paddingTop:40}}>Lade…</div>
|
||||
}
|
||||
|
||||
{showNew && <NewGameModal onClose={()=>setShowNew(false)} toast={toast} onCreate={id=>{setShowNew(false);setActiveId(id);loadGames();}}/>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
492
frontend/src/tools/skizze.jsx
Normal file
@@ -0,0 +1,492 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { api, S } from '../lib.js';
|
||||
|
||||
// ── Hilfsfunktionen ──────────────────────────────────────────────────────────
|
||||
const uid = () => Math.random().toString(36).slice(2,8);
|
||||
const dist = (x1,y1,x2,y2) => Math.sqrt((x2-x1)**2+(y2-y1)**2);
|
||||
const snapToGrid = (v, g) => Math.round(v / g) * g;
|
||||
|
||||
function parseDim(s) {
|
||||
if (!s) return null;
|
||||
const n = parseFloat(String(s).replace(/[^0-9.,\-]/g,'').replace(',','.'));
|
||||
return isNaN(n) ? null : n;
|
||||
}
|
||||
|
||||
function evalFormula(expr, vars) {
|
||||
try {
|
||||
const sanitized = expr.replace(/[^0-9+\-*/().,%a-zA-Z_\s]/g,'');
|
||||
const keys = Object.keys(vars), vals = keys.map(k=>vars[k]);
|
||||
// eslint-disable-next-line no-new-func
|
||||
return new Function(...keys, `"use strict"; return (${sanitized});`)(...vals);
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function buildVars(shapes) {
|
||||
const vars = {};
|
||||
shapes.forEach(s => {
|
||||
const n = (s.name||'').replace(/[^a-zA-Z0-9_]/g,'_');
|
||||
if (!n) return;
|
||||
if (s.type==='rect') {
|
||||
const l=parseDim(s.dimW), b=parseDim(s.dimH);
|
||||
if (l!=null) { vars[`${n}_l`]=l; vars[`${n}_b`]=b??l; }
|
||||
if (l!=null&&b!=null) { vars[`${n}_fl`]=+(l*b).toFixed(4); vars[`${n}_um`]=+(2*(l+b)).toFixed(4); vars[`${n}_d`]=+(Math.sqrt(l*l+b*b)).toFixed(4); }
|
||||
}
|
||||
if (s.type==='line') { const v=parseDim(s.dimLen); if(v!=null) vars[`${n}_len`]=v; }
|
||||
if (s.type==='circle') {
|
||||
const r=parseDim(s.dimR);
|
||||
if(r!=null){ vars[`${n}_r`]=r; vars[`${n}_d`]=+(r*2).toFixed(4); vars[`${n}_fl`]=+(Math.PI*r*r).toFixed(4); vars[`${n}_um`]=+(2*Math.PI*r).toFixed(4); }
|
||||
}
|
||||
});
|
||||
return vars;
|
||||
}
|
||||
|
||||
// ── Row-Komponente (AUSSERHALB um Input-Bug zu vermeiden) ─────────────────────
|
||||
function DimRow({ label, value, field, placeholder, onChange }) {
|
||||
return (
|
||||
<div style={{marginBottom:8}}>
|
||||
<div style={{...S.head,marginBottom:3,fontSize:9}}>{label}</div>
|
||||
<input value={value||''} onChange={e=>onChange({[field]:e.target.value})}
|
||||
placeholder={placeholder} autoCapitalize="none"
|
||||
style={{...S.inp,fontSize:12,padding:'6px 8px'}}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Maßlinie ─────────────────────────────────────────────────────────────────
|
||||
function DimLine({x1,y1,x2,y2,label,color='#4ecdc4',offset=18}) {
|
||||
const mx=(x1+x2)/2, my=(y1+y2)/2;
|
||||
const dx=x2-x1, dy=y2-y1, len=Math.sqrt(dx*dx+dy*dy);
|
||||
if (len<4) return null;
|
||||
const nx=-dy/len*offset, ny=dx/len*offset;
|
||||
return (
|
||||
<g>
|
||||
<line x1={x1+nx} y1={y1+ny} x2={x2+nx} y2={y2+ny} stroke={color} strokeWidth={1} strokeDasharray="3,2" opacity={0.7}/>
|
||||
<line x1={x1} y1={y1} x2={x1+nx} y2={y1+ny} stroke={color} strokeWidth={0.7} opacity={0.4}/>
|
||||
<line x1={x2} y1={y2} x2={x2+nx} y2={y2+ny} stroke={color} strokeWidth={0.7} opacity={0.4}/>
|
||||
<text x={mx+nx} y={my+ny} textAnchor="middle" dominantBaseline="middle"
|
||||
fontSize={9} fill={color} fontFamily="'Courier New',monospace" style={{userSelect:'none'}}
|
||||
stroke="#0a0a0c" strokeWidth={3} paintOrder="stroke">{label}</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Shape + Mittelpunkt + Versatz ─────────────────────────────────────────────
|
||||
function Shape({ s, selected, onClick }) {
|
||||
const sel = selected===s.id;
|
||||
const c = sel ? '#4ecdc4' : (s.color||'#7ecaff');
|
||||
const offset = parseDim(s.offset) || 0; // Versatz in px (scaled)
|
||||
|
||||
if (s.type==='rect') {
|
||||
const x=Math.min(s.x,s.x+s.w), y=Math.min(s.y,s.y+s.h);
|
||||
const w=Math.abs(s.w), h=Math.abs(s.h);
|
||||
const cx=x+w/2, cy=y+h/2;
|
||||
const offPx = s.offsetPx||0;
|
||||
return (
|
||||
<g onClick={e=>{e.stopPropagation();onClick(s.id);}} style={{cursor:'pointer'}}>
|
||||
<rect x={x} y={y} width={w} height={h}
|
||||
fill={sel?'rgba(78,205,196,0.06)':'rgba(126,202,255,0.04)'}
|
||||
stroke={c} strokeWidth={sel?1.5:1}/>
|
||||
{/* Mittelpunkt */}
|
||||
<line x1={cx-6} y1={cy} x2={cx+6} y2={cy} stroke={c} strokeWidth={0.8} opacity={0.5}/>
|
||||
<line x1={cx} y1={cy-6} x2={cx} y2={cy+6} stroke={c} strokeWidth={0.8} opacity={0.5}/>
|
||||
{/* Versatz */}
|
||||
{offPx!==0&&<rect x={x-offPx} y={y-offPx} width={w+2*offPx} height={h+2*offPx}
|
||||
fill="none" stroke={offPx>0?'#ffe66d':'#ff6b9d'} strokeWidth={1} strokeDasharray="4,3" opacity={0.7}/>}
|
||||
{s.name&&<text x={cx} y={cy+2} textAnchor="middle" dominantBaseline="middle"
|
||||
fontSize={10} fill={c} fontFamily="'Courier New',monospace" style={{userSelect:'none'}}
|
||||
stroke="#0a0a0c" strokeWidth={3} paintOrder="stroke">{s.name}</text>}
|
||||
{s.dimW&&<DimLine x1={x} y1={y+h} x2={x+w} y2={y+h} label={s.dimW} offset={14}/>}
|
||||
{s.dimH&&<DimLine x1={x+w} y1={y} x2={x+w} y2={y+h} label={s.dimH} offset={14}/>}
|
||||
{sel&&<>
|
||||
<rect x={x-3} y={y-3} width={6} height={6} fill={c} rx={1}/>
|
||||
<rect x={x+w-3} y={y-3} width={6} height={6} fill={c} rx={1}/>
|
||||
<rect x={x-3} y={y+h-3} width={6} height={6} fill={c} rx={1}/>
|
||||
<rect x={x+w-3} y={y+h-3} width={6} height={6} fill={c} rx={1}/>
|
||||
</>}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
if (s.type==='line') {
|
||||
const len=dist(s.x1,s.y1,s.x2,s.y2);
|
||||
const mx=(s.x1+s.x2)/2, my=(s.y1+s.y2)/2;
|
||||
return (
|
||||
<g onClick={e=>{e.stopPropagation();onClick(s.id);}} style={{cursor:'pointer'}}>
|
||||
<line x1={s.x1} y1={s.y1} x2={s.x2} y2={s.y2} stroke="transparent" strokeWidth={12}/>
|
||||
<line x1={s.x1} y1={s.y1} x2={s.x2} y2={s.y2} stroke={c} strokeWidth={sel?2:1.5} strokeLinecap="round"/>
|
||||
<circle cx={mx} cy={my} r={2} fill={c} opacity={0.6}/>
|
||||
{(s.dimLen||s.name)&&len>20&&<DimLine x1={s.x1} y1={s.y1} x2={s.x2} y2={s.y2} label={s.dimLen||s.name} offset={12}/>}
|
||||
{sel&&<><circle cx={s.x1} cy={s.y1} r={4} fill={c}/><circle cx={s.x2} cy={s.y2} r={4} fill={c}/></>}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
if (s.type==='circle') {
|
||||
const r=Math.max(1,s.r);
|
||||
const offPx=s.offsetPx||0;
|
||||
return (
|
||||
<g onClick={e=>{e.stopPropagation();onClick(s.id);}} style={{cursor:'pointer'}}>
|
||||
<circle cx={s.cx} cy={s.cy} r={r} fill={sel?'rgba(78,205,196,0.06)':'rgba(126,202,255,0.04)'} stroke={c} strokeWidth={sel?1.5:1}/>
|
||||
{offPx!==0&&<circle cx={s.cx} cy={s.cy} r={r+offPx} fill="none" stroke={offPx>0?'#ffe66d':'#ff6b9d'} strokeWidth={1} strokeDasharray="4,3" opacity={0.7}/>}
|
||||
{/* Mittelpunkt */}
|
||||
<line x1={s.cx-6} y1={s.cy} x2={s.cx+6} y2={s.cy} stroke={c} strokeWidth={0.8} opacity={0.5}/>
|
||||
<line x1={s.cx} y1={s.cy-6} x2={s.cx} y2={s.cy+6} stroke={c} strokeWidth={0.8} opacity={0.5}/>
|
||||
{/* Radiuslinie */}
|
||||
<line x1={s.cx} y1={s.cy} x2={s.cx+r} y2={s.cy} stroke={c} strokeWidth={0.7} strokeDasharray="3,2" opacity={0.4}/>
|
||||
{s.name&&<text x={s.cx} y={s.cy+2} textAnchor="middle" dominantBaseline="middle"
|
||||
fontSize={10} fill={c} fontFamily="'Courier New',monospace" style={{userSelect:'none'}}
|
||||
stroke="#0a0a0c" strokeWidth={3} paintOrder="stroke">{s.name}</text>}
|
||||
{s.dimR&&<DimLine x1={s.cx} y1={s.cy} x2={s.cx+r} y2={s.cy} label={'r='+s.dimR} offset={8} color="#ffe66d"/>}
|
||||
{sel&&<circle cx={s.cx} cy={s.cy} r={4} fill={c}/>}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
if (s.type==='text') {
|
||||
return (
|
||||
<g onClick={e=>{e.stopPropagation();onClick(s.id);}} style={{cursor:'pointer'}}>
|
||||
<text x={s.x} y={s.y} fontSize={12} fill={sel?'#4ecdc4':'rgba(255,255,255,0.75)'}
|
||||
fontFamily="'Courier New',monospace" style={{userSelect:'none'}}>{s.label||'Text'}</text>
|
||||
{sel&&<rect x={s.x-2} y={s.y-14} width={Math.max(40,(s.label||'Text').length*7+4)} height={18}
|
||||
fill="none" stroke="#4ecdc4" strokeWidth={1} strokeDasharray="3,2"/>}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Properties Panel ──────────────────────────────────────────────────────────
|
||||
function PropsPanel({ shape, onChange, onDelete, vars, scale, onScaleChange }) {
|
||||
if (!shape) return (
|
||||
<div style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:10,padding:'14px 0',textAlign:'center',lineHeight:1.8}}>
|
||||
Form auswählen oder zeichnen.<br/>
|
||||
<span style={{fontSize:9,opacity:0.6}}>V=Auswählen R=Rect L=Linie C=Kreis</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const shapeVarKeys = Object.keys(vars).filter(k=>k.startsWith((shape.name||'').replace(/[^a-zA-Z0-9_]/g,'_')+'_'));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{...S.head,marginBottom:10}}>EIGENSCHAFTEN</div>
|
||||
<DimRow label="NAME" value={shape.name} field="name" placeholder="z.B. Kasten" onChange={onChange}/>
|
||||
{shape.type==='rect'&&<>
|
||||
<DimRow label="LÄNGE" value={shape.dimW} field="dimW" placeholder="225mm" onChange={onChange}/>
|
||||
<DimRow label="BREITE" value={shape.dimH} field="dimH" placeholder="30mm" onChange={onChange}/>
|
||||
<DimRow label="VERSATZ (+ aussen / − innen)" value={shape.dimOffset} field="dimOffset" placeholder="5mm" onChange={onChange}/>
|
||||
</>}
|
||||
{shape.type==='line'&&<DimRow label="LÄNGE" value={shape.dimLen} field="dimLen" placeholder="100mm" onChange={onChange}/>}
|
||||
{shape.type==='circle'&&<>
|
||||
<DimRow label="RADIUS" value={shape.dimR} field="dimR" placeholder="50mm" onChange={onChange}/>
|
||||
<DimRow label="VERSATZ (+ aussen / − innen)" value={shape.dimOffset} field="dimOffset" placeholder="5mm" onChange={onChange}/>
|
||||
</>}
|
||||
{shape.type==='text'&&<DimRow label="TEXT" value={shape.label} field="label" placeholder="Beschriftung" onChange={onChange}/>}
|
||||
|
||||
<button onClick={onDelete}
|
||||
style={{...S.btn('#ff6b9d',true),width:'100%',textAlign:'center',padding:'7px 0',fontSize:11,marginTop:4}}>
|
||||
✕ Löschen
|
||||
</button>
|
||||
|
||||
{shapeVarKeys.length>0&&(
|
||||
<div style={{marginTop:12}}>
|
||||
<div style={{...S.head,marginBottom:5,fontSize:9}}>VARIABLEN</div>
|
||||
{shapeVarKeys.map(k=>(
|
||||
<div key={k} style={{display:'flex',justifyContent:'space-between',padding:'3px 0',
|
||||
borderBottom:'1px solid rgba(255,255,255,0.04)'}}>
|
||||
<code style={{color:'#4ecdc4',fontSize:10}}>{k}</code>
|
||||
<span style={{color:'rgba(255,255,255,0.45)',fontFamily:'monospace',fontSize:10}}>
|
||||
{typeof vars[k]==='number'?vars[k].toFixed(2):vars[k]}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{marginTop:14}}>
|
||||
<div style={{...S.head,marginBottom:5,fontSize:9}}>MASSSTAB (mm/px)</div>
|
||||
<div style={{display:'flex',gap:6,alignItems:'center'}}>
|
||||
<input type="number" value={scale} onChange={e=>onScaleChange(parseFloat(e.target.value)||1)}
|
||||
step={0.1} min={0.1} style={{...S.inp,flex:1,fontSize:12,padding:'5px 8px'}}/>
|
||||
<span style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:9}}>mm/px</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main ──────────────────────────────────────────────────────────────────────
|
||||
const TOOLS = [
|
||||
{id:'select',label:'▲',title:'Auswählen (V)'},
|
||||
{id:'rect', label:'▭',title:'Rechteck (R)'},
|
||||
{id:'line', label:'╱',title:'Linie (L)'},
|
||||
{id:'circle',label:'○',title:'Kreis (C)'},
|
||||
{id:'text', label:'T', title:'Text (T)'},
|
||||
];
|
||||
const GRID = 10;
|
||||
const CALC_BTNS = ['(',')',' ','7','8','9','×','4','5','6','÷','1','2','3','-','0','.','=','+'];
|
||||
|
||||
export default function Skizze({ toast, mobile }) {
|
||||
const [shapes, setShapes] = useState([]);
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [tool, setTool] = useState('select');
|
||||
const [drawing, setDrawing] = useState(null);
|
||||
const [formula, setFormula] = useState('');
|
||||
const [results, setResults] = useState([]);
|
||||
const [snapGrid, setSnapGrid] = useState(true);
|
||||
const [scale, setScale] = useState(1); // mm per pixel
|
||||
const svgRef = useRef(null);
|
||||
const formulaRef= useRef(null);
|
||||
|
||||
const vars = buildVars(shapes);
|
||||
const selShape = shapes.find(s=>s.id===selected)||null;
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(()=>{
|
||||
const h = e => {
|
||||
if (e.target.tagName==='INPUT'||e.target.tagName==='TEXTAREA') return;
|
||||
const map={v:'select',r:'rect',l:'line',c:'circle',t:'text'};
|
||||
if (map[e.key.toLowerCase()]) setTool(map[e.key.toLowerCase()]);
|
||||
if ((e.key==='Delete'||e.key==='Backspace')&&selected) deleteSelected();
|
||||
if (e.key==='Escape') { setSelected(null); setDrawing(null); }
|
||||
};
|
||||
window.addEventListener('keydown',h);
|
||||
return()=>window.removeEventListener('keydown',h);
|
||||
},[selected]);
|
||||
|
||||
const getSVGPoint = useCallback(e=>{
|
||||
const rect=svgRef.current?.getBoundingClientRect();
|
||||
if (!rect) return{x:0,y:0};
|
||||
const cx=e.touches?e.touches[0].clientX:e.clientX;
|
||||
const cy=e.touches?e.touches[0].clientY:e.clientY;
|
||||
const x=cx-rect.left, y=cy-rect.top;
|
||||
return {x:snapGrid?snapToGrid(x,GRID):x, y:snapGrid?snapToGrid(y,GRID):y};
|
||||
},[snapGrid]);
|
||||
|
||||
const onSVGDown = useCallback(e=>{
|
||||
if (tool==='select'){setSelected(null);return;}
|
||||
const pt=getSVGPoint(e);
|
||||
if (tool==='text'){
|
||||
setShapes(p=>[...p,{id:uid(),type:'text',x:pt.x,y:pt.y,label:'Text',name:''}]);
|
||||
setTool('select'); return;
|
||||
}
|
||||
setDrawing({tool,startX:pt.x,startY:pt.y,curX:pt.x,curY:pt.y});
|
||||
},[tool,getSVGPoint]);
|
||||
|
||||
const onSVGMove = useCallback(e=>{
|
||||
if (!drawing) return;
|
||||
const pt=getSVGPoint(e);
|
||||
setDrawing(d=>({...d,curX:pt.x,curY:pt.y}));
|
||||
},[drawing,getSVGPoint]);
|
||||
|
||||
const onSVGUp = useCallback(e=>{
|
||||
if (!drawing) return;
|
||||
const pt=getSVGPoint(e);
|
||||
const dx=pt.x-drawing.startX, dy=pt.y-drawing.startY;
|
||||
if (Math.abs(dx)<4&&Math.abs(dy)<4){setDrawing(null);return;}
|
||||
let s;
|
||||
if (drawing.tool==='rect') s={id:uid(),type:'rect',x:drawing.startX,y:drawing.startY,w:dx,h:dy,name:'',dimW:'',dimH:'',dimOffset:''};
|
||||
if (drawing.tool==='line') s={id:uid(),type:'line',x1:drawing.startX,y1:drawing.startY,x2:pt.x,y2:pt.y,name:'',dimLen:''};
|
||||
if (drawing.tool==='circle') s={id:uid(),type:'circle',cx:drawing.startX,cy:drawing.startY,r:dist(drawing.startX,drawing.startY,pt.x,pt.y),name:'',dimR:'',dimOffset:''};
|
||||
if (s){setShapes(p=>[...p,s]);setSelected(s.id);}
|
||||
setDrawing(null);
|
||||
},[drawing,getSVGPoint]);
|
||||
|
||||
const deleteSelected = ()=>{setShapes(p=>p.filter(s=>s.id!==selected));setSelected(null);};
|
||||
|
||||
// Update shape + auto-resize visual proportions based on scale
|
||||
const updateShape = patch => {
|
||||
setShapes(p=>p.map(s=>{
|
||||
if (s.id!==selected) return s;
|
||||
const upd={...s,...patch};
|
||||
// Auto-resize rect
|
||||
if (upd.type==='rect') {
|
||||
const mW=parseDim(upd.dimW), mH=parseDim(upd.dimH);
|
||||
if (mW!=null&&mW>0) {
|
||||
upd.w=Math.sign(upd.w||1)*mW/scale;
|
||||
if (mH!=null&&mH>0) upd.h=Math.sign(upd.h||1)*mH/scale;
|
||||
}
|
||||
// Versatz in px
|
||||
const mO=parseDim(upd.dimOffset);
|
||||
upd.offsetPx=mO!=null?mO/scale:0;
|
||||
}
|
||||
// Auto-resize circle
|
||||
if (upd.type==='circle') {
|
||||
const mR=parseDim(upd.dimR);
|
||||
if (mR!=null&&mR>0) upd.r=mR/scale;
|
||||
const mO=parseDim(upd.dimOffset);
|
||||
upd.offsetPx=mO!=null?mO/scale:0;
|
||||
}
|
||||
return upd;
|
||||
}));
|
||||
};
|
||||
|
||||
const calcFormula = () => {
|
||||
if (!formula.trim()) return;
|
||||
const result=evalFormula(formula,vars);
|
||||
const isArea=formula.toLowerCase().includes('_fl');
|
||||
setResults(p=>[{
|
||||
id:uid(), expr:formula,
|
||||
result:result!=null?`${typeof result==='number'?result.toFixed(4).replace(/.?0+$/,''):result}${isArea?' mm²':' mm'}`:null,
|
||||
},...p].slice(0,30));
|
||||
};
|
||||
|
||||
const insertCalc = btn => {
|
||||
if (btn==='='){calcFormula();return;}
|
||||
const map={'×':'*','÷':'/',' ':' '};
|
||||
const char=map[btn]??btn;
|
||||
setFormula(f=>f+char);
|
||||
formulaRef.current?.focus();
|
||||
};
|
||||
|
||||
// Preview while drawing
|
||||
let preview=null;
|
||||
if (drawing){
|
||||
const dx=drawing.curX-drawing.startX, dy=drawing.curY-drawing.startY;
|
||||
const pc='#4ecdc4';
|
||||
if (drawing.tool==='rect') preview=<rect x={Math.min(drawing.startX,drawing.curX)} y={Math.min(drawing.startY,drawing.curY)} width={Math.abs(dx)} height={Math.abs(dy)} fill="rgba(78,205,196,0.04)" stroke={pc} strokeWidth={1} strokeDasharray="4,3"/>;
|
||||
if (drawing.tool==='line') preview=<line x1={drawing.startX} y1={drawing.startY} x2={drawing.curX} y2={drawing.curY} stroke={pc} strokeWidth={1.5} strokeDasharray="4,3"/>;
|
||||
if (drawing.tool==='circle'){const r=dist(drawing.startX,drawing.startY,drawing.curX,drawing.curY); preview=<circle cx={drawing.startX} cy={drawing.startY} r={r} fill="rgba(78,205,196,0.04)" stroke={pc} strokeWidth={1} strokeDasharray="4,3"/>;}
|
||||
}
|
||||
|
||||
const canvasH = mobile ? 260 : 420;
|
||||
|
||||
return (
|
||||
<div style={{padding:mobile?'14px 14px 90px':'36px 44px',maxWidth:1100}}>
|
||||
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:12,flexWrap:'wrap',gap:8}}>
|
||||
<div style={{display:'flex',alignItems:'center',gap:12,flexWrap:'wrap'}}>
|
||||
<h1 style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:mobile?16:22,margin:0}}>Skizzen-Rechner</h1>
|
||||
<div style={{
|
||||
display:'flex',alignItems:'center',gap:6,
|
||||
background:'repeating-linear-gradient(45deg,rgba(255,230,109,0.12),rgba(255,230,109,0.12) 6px,rgba(0,0,0,0) 6px,rgba(0,0,0,0) 12px)',
|
||||
border:'1px solid rgba(255,230,109,0.3)',borderRadius:8,
|
||||
padding:'4px 10px',flexShrink:0,
|
||||
}}>
|
||||
<span style={{fontSize:14}}>🚧</span>
|
||||
<span style={{color:'#ffe66d',fontFamily:'monospace',fontSize:10,fontWeight:700,letterSpacing:0.5}}>IM AUFBAU</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{display:'flex',gap:8,alignItems:'center'}}>
|
||||
<label style={{display:'flex',alignItems:'center',gap:5,cursor:'pointer'}}>
|
||||
<input type="checkbox" checked={snapGrid} onChange={e=>setSnapGrid(e.target.checked)} style={{accentColor:'#4ecdc4'}}/>
|
||||
<span style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10}}>Raster</span>
|
||||
</label>
|
||||
<button onClick={()=>{setShapes([]);setSelected(null);}}
|
||||
style={{...S.btn('#ff6b9d',true),fontSize:10,padding:'5px 10px'}}>✕ Leeren</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div style={{display:'flex',gap:5,marginBottom:10,flexWrap:'wrap'}}>
|
||||
{TOOLS.map(t=>(
|
||||
<button key={t.id} onClick={()=>setTool(t.id)} title={t.title} style={{
|
||||
padding:'7px 13px',borderRadius:8,fontFamily:'monospace',fontSize:t.id==='text'?14:16,
|
||||
cursor:'pointer',fontWeight:700,
|
||||
background:tool===t.id?'#4ecdc4':'rgba(255,255,255,0.06)',
|
||||
color: tool===t.id?'#0d0d0f':'rgba(255,255,255,0.6)',
|
||||
border: tool===t.id?'none':'1px solid rgba(255,255,255,0.1)',
|
||||
}}>{t.label}</button>
|
||||
))}
|
||||
<span style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:9,alignSelf:'center',marginLeft:4}}>
|
||||
{TOOLS.find(t=>t.id===tool)?.title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Canvas + Panel */}
|
||||
<div style={{display:'flex',gap:12,flexDirection:mobile?'column':'row'}}>
|
||||
<svg ref={svgRef} style={{flex:1,minWidth:0,background:'#0a0a0c',border:'1px solid rgba(255,255,255,0.08)',borderRadius:10,display:'block',cursor:tool==='select'?'default':tool==='text'?'text':'crosshair'}}
|
||||
height={canvasH}
|
||||
onMouseDown={onSVGDown} onMouseMove={onSVGMove} onMouseUp={onSVGUp}
|
||||
onTouchStart={onSVGDown} onTouchMove={onSVGMove} onTouchEnd={onSVGUp}>
|
||||
<defs>
|
||||
<pattern id="g10" width={GRID} height={GRID} patternUnits="userSpaceOnUse">
|
||||
<path d={`M ${GRID} 0 L 0 0 0 ${GRID}`} fill="none" stroke="rgba(255,255,255,0.04)" strokeWidth={0.5}/>
|
||||
</pattern>
|
||||
<pattern id="g50" width={50} height={50} patternUnits="userSpaceOnUse">
|
||||
<rect width={50} height={50} fill="url(#g10)"/>
|
||||
<path d="M 50 0 L 0 0 0 50" fill="none" stroke="rgba(255,255,255,0.08)" strokeWidth={0.5}/>
|
||||
</pattern>
|
||||
</defs>
|
||||
{snapGrid&&<rect width="100%" height="100%" fill="url(#g50)"/>}
|
||||
{shapes.map(s=><Shape key={s.id} s={s} selected={selected} onClick={id=>{if(tool==='select')setSelected(id);}}/>)}
|
||||
{preview}
|
||||
</svg>
|
||||
|
||||
<div style={{width:mobile?'100%':210,flexShrink:0}}>
|
||||
<div style={{...S.card}}>
|
||||
<PropsPanel shape={selShape} onChange={updateShape} onDelete={deleteSelected}
|
||||
vars={vars} scale={scale} onScaleChange={setScale}/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rechner */}
|
||||
<div style={{...S.card,marginTop:12}}>
|
||||
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:8}}>
|
||||
<div style={{...S.head,marginBottom:0}}>RECHNER</div>
|
||||
{results.length>0&&<button onClick={()=>setResults([])} style={{...S.btn('#ff6b9d',true),fontSize:10,padding:'3px 8px'}}>✕ Verlauf</button>}
|
||||
</div>
|
||||
|
||||
{/* Alle Variablen */}
|
||||
{Object.keys(vars).length>0&&(
|
||||
<div style={{display:'flex',flexWrap:'wrap',gap:'3px 14px',marginBottom:10,padding:'7px 10px',
|
||||
background:'rgba(0,0,0,0.3)',borderRadius:7,border:'1px solid rgba(255,255,255,0.05)'}}>
|
||||
{Object.entries(vars).map(([k,v])=>(
|
||||
<span key={k} style={{cursor:'pointer'}} onClick={()=>{setFormula(f=>f+k);formulaRef.current?.focus();}}>
|
||||
<code style={{color:'#4ecdc4',fontSize:10}}>{k}</code>
|
||||
<span style={{color:'rgba(255,255,255,0.55)',fontFamily:'monospace',fontSize:10}}>={typeof v==='number'?v.toFixed(2):v}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input */}
|
||||
<div style={{display:'flex',gap:8,marginBottom:8}}>
|
||||
<input ref={formulaRef} value={formula} onChange={e=>setFormula(e.target.value)}
|
||||
onKeyDown={e=>e.key==='Enter'&&calcFormula()}
|
||||
placeholder="z.B. Kasten_l / 2"
|
||||
autoCapitalize="none"
|
||||
style={{...S.inp,flex:1,fontFamily:"'Courier New',monospace",fontSize:13}}/>
|
||||
<button onClick={calcFormula} style={{...S.btn('#4ecdc4'),padding:'0 16px',flexShrink:0}}>= Berechnen</button>
|
||||
</div>
|
||||
|
||||
{/* Operator-Buttons */}
|
||||
<div style={{display:'grid',gridTemplateColumns:'repeat(5,1fr)',gap:5,marginBottom:10}}>
|
||||
{['(',')',' × ',' ÷ ',' '].map((b,i)=>(
|
||||
b.trim()===''?<div key={i}/>:
|
||||
<button key={i} onClick={()=>insertCalc(b.trim())} style={{
|
||||
background:'rgba(255,255,255,0.05)',border:'1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius:7,color:'rgba(255,255,255,0.7)',cursor:'pointer',
|
||||
fontFamily:'monospace',fontSize:14,padding:'6px 0',fontWeight:700,
|
||||
}}>{b.trim()==='×'?'×':b.trim()==='÷'?'÷':b.trim()}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Ergebnisse */}
|
||||
{results.length===0&&Object.keys(vars).length===0&&(
|
||||
<div style={{color:'rgba(255,255,255,0.45)',fontFamily:'monospace',fontSize:11,textAlign:'center',padding:'8px 0'}}>
|
||||
Formen zeichnen → benennen → hier rechnen
|
||||
</div>
|
||||
)}
|
||||
{results.map(r=>(
|
||||
<div key={r.id} style={{display:'flex',justifyContent:'space-between',alignItems:'center',
|
||||
padding:'7px 10px',marginBottom:5,borderRadius:7,
|
||||
background:r.result?'rgba(78,205,196,0.06)':'rgba(255,107,157,0.06)',
|
||||
border:`1px solid ${r.result?'rgba(78,205,196,0.14)':'rgba(255,107,157,0.14)'}`}}>
|
||||
<code style={{color:'rgba(255,255,255,0.45)',fontSize:11,fontFamily:"'Courier New',monospace",
|
||||
overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap',maxWidth:'60%'}}>
|
||||
{r.expr}
|
||||
</code>
|
||||
<div style={{display:'flex',alignItems:'center',gap:8}}>
|
||||
<code style={{color:r.result?'#4ecdc4':'#ff6b9d',fontSize:14,fontFamily:"'Courier New',monospace",fontWeight:700}}>
|
||||
{r.result||'❌ Fehler'}
|
||||
</code>
|
||||
<button onClick={()=>setResults(p=>p.filter(x=>x.id!==r.id))}
|
||||
style={{background:'transparent',border:'none',cursor:'pointer',
|
||||
color:'rgba(255,255,255,0.5)',fontSize:12,padding:'0 2px',lineHeight:1}}>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
510
frontend/src/tools/statistik.jsx
Normal file
@@ -0,0 +1,510 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { BarChart, Bar, LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell, PieChart, Pie, Legend } from 'recharts';
|
||||
|
||||
const api = (path, opts={}) => {
|
||||
const token = localStorage.getItem('sk_token');
|
||||
return fetch('/api' + path, {
|
||||
headers: { 'Content-Type':'application/json', ...(token?{'Authorization':'Bearer '+token}:{}) },
|
||||
...(opts.body ? { method: opts.method||'POST', body: JSON.stringify(opts.body) } : { method: opts.method||'GET' }),
|
||||
}).then(r => r.json());
|
||||
};
|
||||
|
||||
const S = {
|
||||
card: { background:'rgba(255,255,255,0.03)', border:'1px solid rgba(255,255,255,0.07)', borderRadius:12, padding:'16px 18px', marginBottom:12 },
|
||||
head: { color:'rgba(255,255,255,0.4)', fontFamily:'monospace', fontSize:10, letterSpacing:1, marginBottom:10, fontWeight:700 },
|
||||
val: (c='#fff') => ({ color:c, fontFamily:"'Space Mono',monospace", fontSize:22, fontWeight:700 }),
|
||||
sub: { color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:10 },
|
||||
inp: { background:'rgba(255,255,255,0.06)', border:'1px solid rgba(255,255,255,0.12)', borderRadius:8, padding:'8px 12px', color:'#fff', fontFamily:'monospace', fontSize:12, outline:'none', width:'100%', boxSizing:'border-box' },
|
||||
btn: (c='#4ecdc4') => ({ background:`${c}18`, border:`1px solid ${c}44`, borderRadius:8, padding:'8px 14px', color:c, fontFamily:'monospace', fontSize:11, cursor:'pointer' }),
|
||||
};
|
||||
|
||||
const CATS = ['Filament','Zubehör','Ersatzteile','Werkzeug','Sonstiges'];
|
||||
const CAT_COLORS = { Filament:'#4ecdc4', Zubehör:'#ffe66d', Ersatzteile:'#ff6b9d', Werkzeug:'#60a5fa', Sonstiges:'#c084fc' };
|
||||
|
||||
const fmt = v => `${(v||0).toFixed(2)} €`;
|
||||
// Lokales Datum als YYYY-MM-DD (kein UTC-Bug wie bei toISOString)
|
||||
function todayLocal() {
|
||||
const d = new Date();
|
||||
const y = d.getFullYear(), m = String(d.getMonth()+1).padStart(2,'0'), day = String(d.getDate()).padStart(2,'0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
const fmtMonth = m => {
|
||||
if (!m) return '';
|
||||
if (/^\d{4}-\d{2}$/.test(m)) {
|
||||
const [y,mo] = m.split('-');
|
||||
return ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'][parseInt(mo)-1]+' '+y.slice(2);
|
||||
}
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(m)) {
|
||||
const [,mo,d] = m.split('-');
|
||||
return `${d}.${mo}.`;
|
||||
}
|
||||
return m;
|
||||
};
|
||||
|
||||
// Bei Wochen-Granularität: zeigt "15.06. – 21.06." statt nur den Montag
|
||||
const fmtMonthRange = (m, granularity) => {
|
||||
if (!m || granularity !== 'week' || !/^\d{4}-\d{2}-\d{2}$/.test(m)) return fmtMonth(m);
|
||||
const [y,mo,d] = m.split('-').map(Number);
|
||||
const start = new Date(y, mo-1, d);
|
||||
const end = new Date(y, mo-1, d+6);
|
||||
const f = dt => `${String(dt.getDate()).padStart(2,'0')}.${String(dt.getMonth()+1).padStart(2,'0')}.`;
|
||||
return `${f(start)} – ${f(end)}`;
|
||||
};
|
||||
|
||||
const PERIODS = [
|
||||
{ id:'week', label:'Diese Woche' },
|
||||
{ id:'month', label:'Dieser Monat' },
|
||||
{ id:'year', label:'Dieses Jahr' },
|
||||
{ id:'custom', label:'Eigener Zeitraum' },
|
||||
{ id:'all', label:'Gesamt'},
|
||||
];
|
||||
|
||||
// Generiere Monat-Optionen (letzten 24 Monate)
|
||||
function getMonthOptions() {
|
||||
const opts = [];
|
||||
const now = new Date();
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
const val = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}`;
|
||||
const label = d.toLocaleDateString('de-DE', { month:'long', year:'numeric' });
|
||||
opts.push({ val, label });
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
// Generiere Jahr-Optionen
|
||||
function getYearOptions() {
|
||||
const opts = [];
|
||||
const now = new Date().getFullYear();
|
||||
for (let y = now; y >= now - 5; y--) opts.push(y);
|
||||
return opts;
|
||||
}
|
||||
|
||||
function periodRange(id, custom) {
|
||||
const now = new Date();
|
||||
const pad = n => String(n).padStart(2,'0');
|
||||
const iso = d => `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`;
|
||||
if (id==='week') {
|
||||
const d = new Date(now);
|
||||
const day = d.getDay() || 7; // 1=Mo … 7=So
|
||||
d.setDate(d.getDate() - (day - 1)); // zurück auf Montag
|
||||
return { from:iso(d), to:iso(now) };
|
||||
}
|
||||
if (id==='month') { const d=new Date(now.getFullYear(),now.getMonth(),1); return { from:iso(d), to:iso(now) }; }
|
||||
if (id==='year') { return { from:`${now.getFullYear()}-01-01`, to:iso(now) }; }
|
||||
if (id==='custom' && custom) {
|
||||
if (custom.type==='month' && custom.month) {
|
||||
const [y,m] = custom.month.split('-').map(Number);
|
||||
const last = new Date(y, m, 0);
|
||||
return { from:`${y}-${pad(m)}-01`, to:iso(last) };
|
||||
}
|
||||
if (custom.type==='year' && custom.year) {
|
||||
return { from:`${custom.year}-01-01`, to:`${custom.year}-12-31` };
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function KPI({ label, value, color, sub }) {
|
||||
return (
|
||||
<div style={{flex:1,minWidth:130}}>
|
||||
<div style={S.sub}>{label}</div>
|
||||
<div style={S.val(color)}>{value}</div>
|
||||
{sub && <div style={{...S.sub,marginTop:2}}>{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Bestellungen im gewählten Zeitraum
|
||||
function OrdersInPeriod({ fromDate, toDate }) {
|
||||
const [orders, setOrders] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const q = (fromDate || toDate) ? `?from=${fromDate}&to=${toDate}` : '';
|
||||
api(`/tools/statistik/month-orders${q}`).then(setOrders).catch(()=>setOrders([]));
|
||||
}, [fromDate, toDate]);
|
||||
|
||||
const STATUS_ORDER = ['warteliste','in_arbeit','fertig','bezahlt','abgeschlossen'];
|
||||
const STATUS_COLOR = { warteliste:'#ffe66d', in_arbeit:'#4ecdc4', fertig:'#6bcb77', bezahlt:'#c084fc', abgeschlossen:'#4ade80' };
|
||||
const STATUS_LABEL = { warteliste:'Warteliste', in_arbeit:'In Arbeit', fertig:'Fertig', bezahlt:'Bezahlt', abgeschlossen:'Abgeschlossen' };
|
||||
|
||||
const periodLabel = (fromDate || toDate) ? 'IM ZEITRAUM' : 'GESAMT';
|
||||
|
||||
// Gruppieren nach Status
|
||||
const grouped = {};
|
||||
(orders||[]).forEach(o => {
|
||||
const st = !!o.bezahlt && !!o.abgeholt ? 'abgeschlossen' : !!o.bezahlt ? 'bezahlt' : o.status;
|
||||
if (!grouped[st]) grouped[st] = [];
|
||||
grouped[st].push({ ...o, _st: st });
|
||||
});
|
||||
|
||||
// Innerhalb jeder Gruppe nach Datum sortieren (neueste zuerst)
|
||||
Object.values(grouped).forEach(g => g.sort((a,b) => new Date(b.created_at) - new Date(a.created_at)));
|
||||
|
||||
return (
|
||||
<div style={S.card}>
|
||||
<div style={S.head}>BESTELLUNGEN {periodLabel} {orders ? `(${orders.length})` : ''}</div>
|
||||
{!orders
|
||||
? <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:12}}>Lädt…</div>
|
||||
: !orders.length
|
||||
? <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:12}}>Keine Bestellungen im Zeitraum.</div>
|
||||
: STATUS_ORDER.filter(st => grouped[st]?.length).map(st => {
|
||||
const col = STATUS_COLOR[st];
|
||||
const group = grouped[st];
|
||||
const total = group.reduce((s,o)=>s+(o.revenue||0),0);
|
||||
return (
|
||||
<div key={st} style={{marginBottom:16}}>
|
||||
{/* Status-Header mit Summe */}
|
||||
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',
|
||||
padding:'6px 10px',background:`${col}10`,borderRadius:8,marginBottom:6}}>
|
||||
<div style={{display:'flex',alignItems:'center',gap:8}}>
|
||||
<span style={{background:`${col}20`,border:`1px solid ${col}44`,borderRadius:10,
|
||||
padding:'2px 10px',color:col,fontFamily:'monospace',fontSize:10}}>
|
||||
{STATUS_LABEL[st]}
|
||||
</span>
|
||||
<span style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:10}}>
|
||||
{group.length} Bestellung{group.length!==1?'en':''}
|
||||
</span>
|
||||
</div>
|
||||
{total > 0 && (
|
||||
<span style={{color:col,fontFamily:"'Space Mono',monospace",fontSize:12,fontWeight:700}}>
|
||||
Σ {fmt(total)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Einträge */}
|
||||
{group.map(o => (
|
||||
<div key={o.id} style={{display:'flex',alignItems:'center',gap:10,padding:'7px 10px',
|
||||
borderBottom:'1px solid rgba(255,255,255,0.04)'}}>
|
||||
<span style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:10,flexShrink:0,width:60}}>
|
||||
{new Date(o.created_at).toLocaleDateString('de-DE',{day:'2-digit',month:'2-digit'})}
|
||||
</span>
|
||||
<span style={{color:'rgba(255,255,255,0.8)',fontFamily:'monospace',fontSize:12,flex:1}}>
|
||||
{o.name}
|
||||
</span>
|
||||
<span style={{color:o.revenue>0?'#4ade80':'rgba(255,255,255,0.25)',
|
||||
fontFamily:"'Space Mono',monospace",fontSize:12,fontWeight:700,flexShrink:0}}>
|
||||
{fmt(o.revenue||0)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Statistik({ mobile, user, toast }) {
|
||||
const [fromDate, setFromDate] = useState('');
|
||||
const [toDate, setToDate] = useState(todayLocal());
|
||||
const [data, setData] = useState(null);
|
||||
const [loading,setLoading] = useState(false);
|
||||
const [tab, setTab] = useState('overview');
|
||||
|
||||
const [expForm,setExpForm] = useState({ date: todayLocal(), category:'Filament', description:'', amount:'' });
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
function load() {
|
||||
setLoading(true);
|
||||
const q = (fromDate || toDate) ? `?from=${fromDate}&to=${toDate}` : '';
|
||||
api('/tools/statistik/overview'+q)
|
||||
.then(d => setData(d))
|
||||
.catch(()=>{})
|
||||
.finally(()=>setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, [fromDate, toDate]);
|
||||
|
||||
async function addExpense() {
|
||||
if (!expForm.description || !expForm.amount) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await api('/tools/statistik/expenses', { body: expForm });
|
||||
setExpForm(p => ({ ...p, description:'', amount:'' }));
|
||||
load();
|
||||
} finally { setSaving(false); }
|
||||
}
|
||||
|
||||
async function delExpense(id) {
|
||||
await api(`/tools/statistik/expenses/${id}`, { method:'DELETE' });
|
||||
load();
|
||||
}
|
||||
|
||||
const netColor = (data?.netProfit||0) >= 0 ? '#4ade80' : '#f87171';
|
||||
|
||||
return (
|
||||
<div style={{ padding: mobile?'14px 14px 90px':'28px 36px', maxWidth:900 }}>
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:20,flexWrap:'wrap',gap:10}}>
|
||||
<h2 style={{color:'#fff',fontFamily:'monospace',fontSize:18,fontWeight:700,margin:0}}>
|
||||
📊 3D-Druck Statistik
|
||||
</h2>
|
||||
{/* Zeitraum */}
|
||||
<div style={{display:'flex',gap:8,alignItems:'center',flexWrap:'wrap'}}>
|
||||
<div style={{display:'flex',alignItems:'center',gap:6}}>
|
||||
<span style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:11}}>Von</span>
|
||||
<input type="date" value={fromDate} onChange={e=>setFromDate(e.target.value)}
|
||||
style={{background:'rgba(255,255,255,0.06)',border:'1px solid rgba(255,255,255,0.12)',
|
||||
borderRadius:8,padding:'5px 10px',color:'#fff',fontFamily:'monospace',fontSize:11,cursor:'pointer'}}/>
|
||||
</div>
|
||||
<div style={{display:'flex',alignItems:'center',gap:6}}>
|
||||
<span style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:11}}>Bis</span>
|
||||
<input type="date" value={toDate} onChange={e=>setToDate(e.target.value)}
|
||||
style={{background:'rgba(255,255,255,0.06)',border:'1px solid rgba(255,255,255,0.12)',
|
||||
borderRadius:8,padding:'5px 10px',color:'#fff',fontFamily:'monospace',fontSize:11,cursor:'pointer'}}/>
|
||||
</div>
|
||||
{fromDate && (
|
||||
<button onClick={()=>{setFromDate('');setToDate(todayLocal());}}
|
||||
style={{background:'rgba(255,255,255,0.04)',border:'1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius:8,padding:'5px 10px',color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:11,cursor:'pointer'}}>
|
||||
✕ Zurücksetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Tabs ── */}
|
||||
<div style={{display:'flex',gap:4,marginBottom:16,overflowX:'auto',overflowY:'hidden',
|
||||
scrollbarWidth:'none',WebkitOverflowScrolling:'touch',paddingBottom:2}}>
|
||||
{[['overview','📈 Übersicht'],['revenue','💰 Einnahmen'],['expenses','💸 Ausgaben'],['orders','📦 Bestellungen']].map(([id,label])=>(
|
||||
<button key={id} onClick={()=>setTab(id)} style={{
|
||||
background: tab===id ? 'rgba(255,255,255,0.08)' : 'transparent',
|
||||
border: `1px solid ${tab===id ? 'rgba(255,255,255,0.2)' : 'rgba(255,255,255,0.08)'}`,
|
||||
borderRadius:8, padding:'6px 14px', flexShrink:0, whiteSpace:'nowrap',
|
||||
color: tab===id ? '#fff' : 'rgba(255,255,255,0.4)',
|
||||
fontFamily:'monospace', fontSize:11, cursor:'pointer',
|
||||
}}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading && <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:12}}>Lädt…</div>}
|
||||
|
||||
{/* ══ ÜBERSICHT ══ */}
|
||||
{tab==='overview' && data && (
|
||||
<>
|
||||
{/* KPI Kacheln */}
|
||||
<div style={{...S.card}}>
|
||||
<div style={S.head}>EINNAHMEN & GEWINN</div>
|
||||
<div style={{display:'flex',flexWrap:'wrap',gap:20,marginBottom:16}}>
|
||||
<KPI label="Eingenommen" value={fmt(data.totalRevenue)} color="#4ade80" sub="bezahlt"/>
|
||||
<KPI label="Offen" value={fmt(data.openRevenue)} color="#ffe66d" sub="in Arbeit/Fertig"/>
|
||||
<KPI label="Materialkosten" value={fmt(data.totalBaseCost)} color="#f87171" sub="Selbstkosten"/>
|
||||
<KPI label="Rohgewinn" value={fmt(data.totalProfit)} color="#60a5fa" sub="nach Materialkosten"/>
|
||||
</div>
|
||||
<div style={{borderTop:'1px solid rgba(255,255,255,0.06)',paddingTop:16,display:'flex',flexWrap:'wrap',gap:20}}>
|
||||
<KPI label="Ausgaben" value={fmt(data.totalExpenses)} color="#f87171" sub="Einkäufe"/>
|
||||
<KPI label="NETTOGEWINN" value={fmt(data.netProfit)} color={netColor} sub="Rohgewinn − Ausgaben"/>
|
||||
<KPI label="Bestellungen" value={data.totalOrders} color="rgba(255,255,255,0.6)" sub="im Zeitraum"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status-Verteilung */}
|
||||
<div style={{...S.card}}>
|
||||
<div style={S.head}>BESTELLUNGEN NACH STATUS</div>
|
||||
<div style={{display:'flex',flexWrap:'wrap',gap:8}}>
|
||||
{[
|
||||
['Warteliste', data.byStatus.warteliste, '#ffe66d'],
|
||||
['In Arbeit', data.byStatus.in_arbeit, '#4ecdc4'],
|
||||
['Fertig', data.byStatus.fertig, '#6bcb77'],
|
||||
['Bezahlt', data.byStatus.bezahlt, '#c084fc'],
|
||||
['Abgeschlossen', data.byStatus.abgeschlossen, '#4ade80'],
|
||||
].map(([label, val, color]) => val > 0 && (
|
||||
<div key={label} style={{background:`${color}12`,border:`1px solid ${color}33`,
|
||||
borderRadius:8,padding:'8px 14px',textAlign:'center'}}>
|
||||
<div style={{color,fontFamily:"'Space Mono',monospace",fontSize:18,fontWeight:700}}>{val}</div>
|
||||
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:9}}>{label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Balkendiagramm Einnahmen vs Ausgaben */}
|
||||
{data.monthlyData.length > 0 && (
|
||||
<div style={S.card}>
|
||||
<div style={S.head}>EINNAHMEN VS. AUSGABEN {data.granularity==='day'?'(TÄGLICH)':data.granularity==='week'?'(WÖCHENTLICH)':'(MONATLICH)'}</div>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={data.monthlyData} margin={{top:0,right:0,bottom:0,left:0}}>
|
||||
<XAxis dataKey="month" tickFormatter={fmtMonth} tick={{fill:'rgba(255,255,255,0.3)',fontSize:10}} axisLine={false} tickLine={false}/>
|
||||
<YAxis tick={{fill:'rgba(255,255,255,0.3)',fontSize:10}} axisLine={false} tickLine={false} tickFormatter={v=>`${v}€`} width={45}/>
|
||||
<Tooltip contentStyle={{background:'#1a1d2e',border:'1px solid rgba(255,255,255,0.1)',borderRadius:8,fontFamily:'monospace',fontSize:11,color:'#fff'}}
|
||||
labelStyle={{color:'rgba(255,255,255,0.6)'}} itemStyle={{color:'#fff'}}
|
||||
formatter={(v,n)=>[`${v.toFixed(2)} €`, n==='revenue'?'Einnahmen':'Ausgaben']}
|
||||
labelFormatter={m => fmtMonthRange(m, data.granularity)}/>
|
||||
<Bar dataKey="revenue" fill="#4ade80" radius={[4,4,0,0]} maxBarSize={28}/>
|
||||
<Bar dataKey="expenses" fill="#f87171" radius={[4,4,0,0]} maxBarSize={28}/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Kumulierter Gewinn + Ausgaben nach Kategorie */}
|
||||
<div style={{display:'flex',gap:12,flexWrap:'wrap'}}>
|
||||
{data.cumulativeData.length > 1 && (
|
||||
<div style={{...S.card,flex:1,minWidth:200}}>
|
||||
<div style={S.head}>NETTOGEWINN IM ZEITVERLAUF</div>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<LineChart data={data.cumulativeData} margin={{top:5,right:5,bottom:0,left:0}}>
|
||||
<XAxis dataKey="month" tickFormatter={fmtMonth} tick={{fill:'rgba(255,255,255,0.3)',fontSize:9}} axisLine={false} tickLine={false}/>
|
||||
<YAxis tick={{fill:'rgba(255,255,255,0.3)',fontSize:9}} axisLine={false} tickLine={false} tickFormatter={v=>`${v}€`} width={42}/>
|
||||
<Tooltip contentStyle={{background:'#1a1d2e',border:'1px solid rgba(255,255,255,0.1)',borderRadius:8,fontFamily:'monospace',fontSize:11,color:'#fff'}}
|
||||
labelStyle={{color:'rgba(255,255,255,0.6)'}} itemStyle={{color:'#4ecdc4'}}
|
||||
formatter={v=>[`${v.toFixed(2)} €`, 'Gewinn kumuliert']} labelFormatter={m => fmtMonthRange(m, data.granularity)}/>
|
||||
<Line type="monotone" dataKey="value" stroke="#4ecdc4" strokeWidth={2} dot={false}/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
{Object.keys(data.byCategory).length > 0 && (
|
||||
<div style={{...S.card,flex:1,minWidth:200}}>
|
||||
<div style={S.head}>AUSGABEN NACH KATEGORIE</div>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<PieChart>
|
||||
<Pie data={Object.entries(data.byCategory).map(([name,value])=>({name,value}))}
|
||||
cx="50%" cy="50%" innerRadius={40} outerRadius={65}
|
||||
dataKey="value" paddingAngle={3}>
|
||||
{Object.entries(data.byCategory).map(([name],i)=>(
|
||||
<Cell key={name} fill={CAT_COLORS[name]||'#888'}/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip contentStyle={{background:'#1a1d2e',border:'1px solid rgba(255,255,255,0.1)',borderRadius:8,fontFamily:'monospace',fontSize:11,color:'#fff'}}
|
||||
labelStyle={{color:'rgba(255,255,255,0.6)'}} itemStyle={{color:'#fff'}}
|
||||
formatter={v=>[`${v.toFixed(2)} €`]}/>
|
||||
<Legend iconType="circle" iconSize={8}
|
||||
formatter={v=><span style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:9}}>{v}</span>}/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ══ AUSGABEN ══ */}
|
||||
{tab==='expenses' && (
|
||||
<>
|
||||
{/* Formular */}
|
||||
<div style={S.card}>
|
||||
<div style={S.head}>NEUE AUSGABE</div>
|
||||
<div style={{display:'flex',gap:8,flexWrap:'wrap'}}>
|
||||
<input type="date" value={expForm.date}
|
||||
onChange={e=>setExpForm(p=>({...p,date:e.target.value}))}
|
||||
style={{...S.inp,width:140}}/>
|
||||
<select value={expForm.category}
|
||||
onChange={e=>setExpForm(p=>({...p,category:e.target.value}))}
|
||||
style={{...S.inp,width:130}}>
|
||||
{CATS.map(c=><option key={c}>{c}</option>)}
|
||||
</select>
|
||||
<input placeholder="Beschreibung" value={expForm.description}
|
||||
onChange={e=>setExpForm(p=>({...p,description:e.target.value}))}
|
||||
style={{...S.inp,flex:1,minWidth:140}}/>
|
||||
<input type="number" placeholder="0.00" step="0.01" min="0" value={expForm.amount}
|
||||
onChange={e=>setExpForm(p=>({...p,amount:e.target.value}))}
|
||||
onKeyDown={e=>e.key==='Enter'&&addExpense()}
|
||||
style={{...S.inp,width:90,textAlign:'right'}}/>
|
||||
<button onClick={addExpense} disabled={saving}
|
||||
style={{...S.btn('#4ade80'),opacity:saving?0.5:1,whiteSpace:'nowrap'}}>
|
||||
{saving?'…':'+ Hinzufügen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Liste */}
|
||||
<div style={S.card}>
|
||||
<div style={S.head}>AUSGABEN {(fromDate||toDate!==todayLocal())&&'(IM ZEITRAUM)'}</div>
|
||||
{!data?.expenses?.length
|
||||
? <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:12}}>Keine Ausgaben im gewählten Zeitraum.</div>
|
||||
: data.expenses.map(e => (
|
||||
<div key={e.id} style={{display:'flex',alignItems:'center',gap:10,padding:'8px 0',
|
||||
borderBottom:'1px solid rgba(255,255,255,0.05)'}}>
|
||||
<span style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:10,flexShrink:0,width:80}}>
|
||||
{e.date}
|
||||
</span>
|
||||
<span style={{background:`${CAT_COLORS[e.category]||'#888'}18`,
|
||||
border:`1px solid ${CAT_COLORS[e.category]||'#888'}44`,
|
||||
borderRadius:6,padding:'2px 8px',color:CAT_COLORS[e.category]||'#888',
|
||||
fontFamily:'monospace',fontSize:9,flexShrink:0}}>
|
||||
{e.category}
|
||||
</span>
|
||||
<span style={{color:'rgba(255,255,255,0.7)',fontFamily:'monospace',fontSize:12,flex:1}}>
|
||||
{e.description}
|
||||
</span>
|
||||
<span style={{color:'#f87171',fontFamily:"'Space Mono',monospace",fontSize:13,fontWeight:700,flexShrink:0}}>
|
||||
{fmt(e.amount)}
|
||||
</span>
|
||||
<button onClick={()=>delExpense(e.id)}
|
||||
style={{background:'rgba(248,113,113,0.1)',border:'1px solid rgba(248,113,113,0.2)',
|
||||
borderRadius:6,padding:'3px 8px',color:'#f87171',fontFamily:'monospace',fontSize:10,cursor:'pointer',flexShrink:0}}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
{data?.expenses?.length > 0 && (
|
||||
<div style={{display:'flex',justifyContent:'flex-end',paddingTop:10}}>
|
||||
<span style={{color:'#f87171',fontFamily:"'Space Mono',monospace",fontSize:14,fontWeight:700}}>
|
||||
Σ {fmt(data.totalExpenses)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ══ EINNAHMEN ══ */}
|
||||
{tab==='revenue' && data && (
|
||||
<div style={S.card}>
|
||||
<div style={S.head}>
|
||||
IM ZEITRAUM BEZAHLTE AUFTRÄGE ({data.paidOrders?.length||0}) · {fmt(data.totalRevenue)} EINGENOMMEN
|
||||
</div>
|
||||
{!data.paidOrders?.length
|
||||
? <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:12}}>
|
||||
Keine bezahlten Aufträge im gewählten Zeitraum.
|
||||
</div>
|
||||
: data.paidOrders.map(o => (
|
||||
<div key={o.id} style={{display:'flex',alignItems:'center',gap:10,padding:'8px 0',
|
||||
borderBottom:'1px solid rgba(255,255,255,0.05)',flexWrap:'wrap'}}>
|
||||
<span style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:10,flexShrink:0}}>
|
||||
{o.bezahlt_am ? new Date(o.bezahlt_am).toLocaleDateString('de-DE') : '—'}
|
||||
</span>
|
||||
<span style={{color:'rgba(255,255,255,0.8)',fontFamily:'monospace',fontSize:12,flex:1}}>
|
||||
{o.name}
|
||||
</span>
|
||||
<span style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:10,flexShrink:0}}>
|
||||
Erstellt: {new Date(o.created_at).toLocaleDateString('de-DE')}
|
||||
</span>
|
||||
<div style={{display:'flex',gap:6,flexShrink:0}}>
|
||||
<span style={{color:'#f87171',fontFamily:"'Space Mono',monospace",fontSize:11}}>
|
||||
-{fmt(o.base_cost)}
|
||||
</span>
|
||||
<span style={{color:'#4ade80',fontFamily:"'Space Mono',monospace",fontSize:12,fontWeight:700}}>
|
||||
{fmt(o.revenue)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
{data.paidOrders?.length > 0 && (
|
||||
<div style={{display:'flex',justifyContent:'space-between',paddingTop:10,
|
||||
borderTop:'1px solid rgba(255,255,255,0.07)'}}>
|
||||
<span style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:11}}>
|
||||
Materialkosten: {fmt(data.totalBaseCost)}
|
||||
</span>
|
||||
<span style={{color:'#60a5fa',fontFamily:"'Space Mono',monospace",fontSize:13,fontWeight:700}}>
|
||||
Rohgewinn: {fmt(data.totalProfit)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ══ BESTELLUNGEN (dieser Monat) ══ */}
|
||||
{tab==='orders' && (
|
||||
<OrdersInPeriod fromDate={fromDate} toDate={toDate}/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
132
frontend/src/tools/wettrechner.jsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useState } from 'react';
|
||||
import { S } from '../lib.js';
|
||||
|
||||
// ── Wettrechner (Surebet / Arbitrage) ────────────────────────────────────────
|
||||
// Prinzip: bei 3 sich gegenseitig ausschließenden Ergebnissen mit Quoten
|
||||
// o1,o2,o3 existiert ein garantierter Gewinn, wenn 1/o1 + 1/o2 + 1/o3 < 1
|
||||
// ("Arbitrage-Prozentsatz" < 100%). Der Einsatz pro Ergebnis wird proportional
|
||||
// zu 1/o_i verteilt — dadurch ist die Auszahlung bei JEDEM der drei Ausgänge
|
||||
// exakt gleich hoch.
|
||||
|
||||
function parseOdd(v) {
|
||||
const n = parseFloat(String(v).replace(',', '.'));
|
||||
return Number.isFinite(n) && n > 1 ? n : null;
|
||||
}
|
||||
function parseMoney(v) {
|
||||
const n = parseFloat(String(v).replace(',', '.'));
|
||||
return Number.isFinite(n) && n > 0 ? n : null;
|
||||
}
|
||||
const fmtEUR = n => n.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' });
|
||||
const fmtPct = n => n.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' %';
|
||||
|
||||
export default function Wettrechner() {
|
||||
const [stake, setStake] = useState('100');
|
||||
const [outcomes, setOutcomes] = useState([
|
||||
{ label: '', odd: '' },
|
||||
{ label: '', odd: '' },
|
||||
{ label: '', odd: '' },
|
||||
]);
|
||||
|
||||
const setOutcome = (i, field, value) => {
|
||||
setOutcomes(prev => prev.map((o, idx) => idx === i ? { ...o, [field]: value } : o));
|
||||
};
|
||||
|
||||
const budget = parseMoney(stake);
|
||||
const odds = outcomes.map(o => parseOdd(o.odd));
|
||||
const allOddsValid = odds.every(o => o !== null);
|
||||
const canCalculate = allOddsValid && budget !== null;
|
||||
|
||||
let result = null;
|
||||
if (canCalculate) {
|
||||
const invProbs = odds.map(o => 1 / o);
|
||||
const sumInv = invProbs.reduce((a, b) => a + b, 0);
|
||||
const arbitragePercent = sumInv * 100;
|
||||
const isProfitable = sumInv < 1;
|
||||
const stakes = invProbs.map(p => budget * p / sumInv);
|
||||
const payout = budget / sumInv;
|
||||
const profit = payout - budget;
|
||||
const profitPercent = (payout / budget - 1) * 100;
|
||||
result = { arbitragePercent, isProfitable, stakes, payout, profit, profitPercent };
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: 20, maxWidth: 640 }}>
|
||||
<div style={{ ...S.head, marginBottom: 4 }}>WETTRECHNER</div>
|
||||
<div style={{ ...S.sub, marginBottom: 16 }}>
|
||||
Trag die Quoten für 3 sich gegenseitig ausschließende Ergebnisse ein (z.B. Sieg A / Unentschieden / Sieg B).
|
||||
Der Rechner sagt dir, wie viel du auf jedes Ergebnis setzen musst, damit die Auszahlung immer gleich hoch ist —
|
||||
egal welches Ergebnis eintritt.
|
||||
</div>
|
||||
|
||||
<div style={S.card}>
|
||||
<div style={{ ...S.sub, marginBottom: 6 }}>GESAMTEINSATZ</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
|
||||
<span style={{ color: 'rgba(255,255,255,0.4)', fontFamily: "'Space Mono',monospace" }}>€</span>
|
||||
<input value={stake} onChange={e => setStake(e.target.value)} inputMode="decimal"
|
||||
placeholder="100" style={{ ...S.inp, maxWidth: 160 }} />
|
||||
</div>
|
||||
|
||||
{outcomes.map((o, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
|
||||
<input value={o.label} onChange={e => setOutcome(i, 'label', e.target.value)}
|
||||
placeholder={`Ergebnis ${i + 1} (optional, z.B. "Sieg A")`}
|
||||
style={{ ...S.inp, flex: 2, minWidth: 160 }} />
|
||||
<input value={o.odd} onChange={e => setOutcome(i, 'odd', e.target.value)} inputMode="decimal"
|
||||
placeholder="Quote, z.B. 2.50" style={{ ...S.inp, flex: 1, minWidth: 100 }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!canCalculate && (
|
||||
<div style={{ ...S.sub, marginTop: 12 }}>
|
||||
Bitte Gesamteinsatz und alle drei Quoten (jeweils größer als 1) eintragen.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div style={{ ...S.card, marginTop: 16, borderColor: result.isProfitable ? 'rgba(74,222,128,0.35)' : 'rgba(248,113,113,0.35)' }}>
|
||||
<div style={{
|
||||
fontFamily: "'Space Mono',monospace", fontSize: 13, fontWeight: 700, marginBottom: 14,
|
||||
color: result.isProfitable ? '#4ade80' : '#f87171',
|
||||
}}>
|
||||
{result.isProfitable
|
||||
? `✓ Garantierter Gewinn möglich (Arbitrage: ${fmtPct(result.arbitragePercent)})`
|
||||
: `✕ Kein garantierter Gewinn möglich (Summe der Quoten-Kehrwerte: ${fmtPct(result.arbitragePercent)}, muss unter 100 % liegen)`}
|
||||
</div>
|
||||
|
||||
{outcomes.map((o, i) => (
|
||||
<div key={i} style={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'baseline',
|
||||
padding: '8px 0', borderBottom: i < 2 ? '1px solid rgba(255,255,255,0.06)' : 'none',
|
||||
}}>
|
||||
<span style={{ color: 'rgba(255,255,255,0.7)', fontFamily: 'monospace', fontSize: 13 }}>
|
||||
{o.label || `Ergebnis ${i + 1}`} <span style={{ color: 'rgba(255,255,255,0.35)' }}>(Quote {odds[i]})</span>
|
||||
</span>
|
||||
<span style={{ color: '#ffe66d', fontFamily: "'Space Mono',monospace", fontSize: 16, fontWeight: 700 }}>
|
||||
{fmtEUR(result.stakes[i])}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div style={{ marginTop: 14, paddingTop: 14, borderTop: '1px solid rgba(255,255,255,0.1)', display: 'flex', flexWrap: 'wrap', gap: 20 }}>
|
||||
<div>
|
||||
<div style={{ ...S.sub, marginBottom: 2 }}>Auszahlung (immer gleich)</div>
|
||||
<div style={{ color: '#4ecdc4', fontFamily: "'Space Mono',monospace", fontSize: 16, fontWeight: 700 }}>
|
||||
{fmtEUR(result.payout)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ ...S.sub, marginBottom: 2 }}>Gewinn / Verlust</div>
|
||||
<div style={{
|
||||
color: result.isProfitable ? '#4ade80' : '#f87171',
|
||||
fontFamily: "'Space Mono',monospace", fontSize: 16, fontWeight: 700,
|
||||
}}>
|
||||
{result.profit >= 0 ? '+' : ''}{fmtEUR(result.profit)} ({result.profitPercent >= 0 ? '+' : ''}{fmtPct(result.profitPercent)})
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
975
frontend/src/tools/whiteboard.jsx
Normal file
@@ -0,0 +1,975 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { api, S } from '../lib.js';
|
||||
|
||||
// ── Konstanten ────────────────────────────────────────────────────────────────
|
||||
const COLORS = ['#FFFFFF','#FF6B9D','#4ECDC4','#FFE66D','#60A5FA','#A78BFA','#FB923C','#4ADE80','#F87171','#000000'];
|
||||
const SIZES = [2, 4, 8, 14, 22];
|
||||
const FONT_SIZES = [12, 16, 20, 28, 40, 56];
|
||||
const TOOLS = [
|
||||
{ id:'select', label:'↖', tip:'Auswählen / Verschieben' },
|
||||
{ id:'pen', label:'✏', tip:'Stift' },
|
||||
{ id:'line', label:'╱', tip:'Linie' },
|
||||
{ id:'rect', label:'▭', tip:'Rechteck' },
|
||||
{ id:'ellipse', label:'◯', tip:'Ellipse' },
|
||||
{ id:'text', label:'T', tip:'Text' },
|
||||
{ id:'eraser', label:'🧹', tip:'Radierer' },
|
||||
];
|
||||
const CURSOR_COLORS = ['#FF6B9D','#4ECDC4','#FFE66D','#60A5FA','#A78BFA','#FB923C'];
|
||||
const BG = '#0D0D0F';
|
||||
const getId = () => Math.random().toString(36).slice(2);
|
||||
const HANDLE_R = 6; // Resize-Handle Radius in Screen-Px
|
||||
|
||||
// ── Bounding-Box-Berechnung ───────────────────────────────────────────────────
|
||||
function getBounds(el) {
|
||||
switch (el.type) {
|
||||
case 'pen': case 'eraser': {
|
||||
if (!el.points?.length) return null;
|
||||
const xs = el.points.map(p => p.x), ys = el.points.map(p => p.y);
|
||||
const pad = (el.size||4) * (el.type==='eraser'?3:1);
|
||||
return { x: Math.min(...xs)-pad, y: Math.min(...ys)-pad,
|
||||
w: Math.max(...xs)-Math.min(...xs)+pad*2, h: Math.max(...ys)-Math.min(...ys)+pad*2 };
|
||||
}
|
||||
case 'line': {
|
||||
const pad = el.size||4;
|
||||
return { x: Math.min(el.x1,el.x2)-pad, y: Math.min(el.y1,el.y2)-pad,
|
||||
w: Math.abs(el.x2-el.x1)+pad*2, h: Math.abs(el.y2-el.y1)+pad*2 };
|
||||
}
|
||||
case 'rect':
|
||||
return { x: Math.min(el.x,el.x+el.w), y: Math.min(el.y,el.y+el.h),
|
||||
w: Math.abs(el.w), h: Math.abs(el.h) };
|
||||
case 'ellipse':
|
||||
return { x: el.cx-Math.abs(el.rx), y: el.cy-Math.abs(el.ry),
|
||||
w: Math.abs(el.rx)*2, h: Math.abs(el.ry)*2 };
|
||||
case 'text': {
|
||||
const fs = el.fontSize || 20;
|
||||
const w = (el.text?.length || 4) * fs * 0.6;
|
||||
return { x: el.x, y: el.y - fs, w, h: fs * 1.3 };
|
||||
}
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Punkt-in-Bounds Hit-Test
|
||||
function hitTest(el, cx, cy) {
|
||||
const b = getBounds(el);
|
||||
if (!b) return false;
|
||||
return cx >= b.x && cx <= b.x+b.w && cy >= b.y && cy <= b.y+b.h;
|
||||
}
|
||||
|
||||
// ── Element verschieben ───────────────────────────────────────────────────────
|
||||
function moveElement(el, dx, dy) {
|
||||
switch (el.type) {
|
||||
case 'pen': case 'eraser':
|
||||
return { ...el, points: el.points.map(p => ({ x: p.x+dx, y: p.y+dy })) };
|
||||
case 'line':
|
||||
return { ...el, x1: el.x1+dx, y1: el.y1+dy, x2: el.x2+dx, y2: el.y2+dy };
|
||||
case 'rect':
|
||||
return { ...el, x: el.x+dx, y: el.y+dy };
|
||||
case 'ellipse':
|
||||
return { ...el, cx: el.cx+dx, cy: el.cy+dy };
|
||||
case 'text':
|
||||
return { ...el, x: el.x+dx, y: el.y+dy };
|
||||
default: return el;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Element skalieren (Resize via Ecke rechts-unten) ─────────────────────────
|
||||
function resizeElement(el, nx, ny) {
|
||||
const b = getBounds(el);
|
||||
if (!b) return el;
|
||||
const scaleX = b.w > 1 ? Math.max(0.05, (nx - b.x) / b.w) : 1;
|
||||
const scaleY = b.h > 1 ? Math.max(0.05, (ny - b.y) / b.h) : 1;
|
||||
switch (el.type) {
|
||||
case 'pen': case 'eraser':
|
||||
return { ...el, points: el.points.map(p => ({ x: b.x + (p.x-b.x)*scaleX, y: b.y + (p.y-b.y)*scaleY })) };
|
||||
case 'line':
|
||||
return { ...el, x1: b.x+(el.x1-b.x)*scaleX, y1: b.y+(el.y1-b.y)*scaleY,
|
||||
x2: b.x+(el.x2-b.x)*scaleX, y2: b.y+(el.y2-b.y)*scaleY };
|
||||
case 'rect':
|
||||
return { ...el, w: el.w * scaleX, h: el.h * scaleY };
|
||||
case 'ellipse':
|
||||
return { ...el, rx: el.rx * scaleX, ry: el.ry * scaleY };
|
||||
case 'text':
|
||||
return { ...el, fontSize: Math.max(8, Math.round((el.fontSize||20) * ((scaleX+scaleY)/2))) };
|
||||
default: return el;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Canvas rendern ────────────────────────────────────────────────────────────
|
||||
function renderElements(ctx, elements, vp, selectedId, forExport) {
|
||||
const W = ctx.canvas.width, H = ctx.canvas.height;
|
||||
ctx.save();
|
||||
// Hintergrund
|
||||
ctx.fillStyle = BG;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
ctx.translate(vp.x, vp.y);
|
||||
ctx.scale(vp.zoom, vp.zoom);
|
||||
|
||||
for (const el of elements) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.strokeStyle = el.color || '#fff';
|
||||
ctx.fillStyle = el.color || '#fff';
|
||||
ctx.lineWidth = (el.size || 4) / vp.zoom;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
|
||||
switch (el.type) {
|
||||
case 'pen': {
|
||||
if (!el.points?.length) break;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(el.points[0].x, el.points[0].y);
|
||||
for (const p of el.points.slice(1)) ctx.lineTo(p.x, p.y);
|
||||
ctx.stroke();
|
||||
break;
|
||||
}
|
||||
case 'eraser': {
|
||||
if (!el.points?.length) break;
|
||||
ctx.strokeStyle = BG;
|
||||
ctx.lineWidth = (el.size||4) * 3 / vp.zoom;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(el.points[0].x, el.points[0].y);
|
||||
for (const p of el.points.slice(1)) ctx.lineTo(p.x, p.y);
|
||||
ctx.stroke();
|
||||
break;
|
||||
}
|
||||
case 'line': {
|
||||
ctx.beginPath(); ctx.moveTo(el.x1, el.y1); ctx.lineTo(el.x2, el.y2); ctx.stroke();
|
||||
break;
|
||||
}
|
||||
case 'rect':
|
||||
ctx.strokeRect(el.x, el.y, el.w, el.h);
|
||||
break;
|
||||
case 'ellipse':
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(el.cx, el.cy, Math.abs(el.rx||1), Math.abs(el.ry||1), 0, 0, Math.PI*2);
|
||||
ctx.stroke();
|
||||
break;
|
||||
case 'text': {
|
||||
const fs = el.fontSize || 20;
|
||||
ctx.font = `${fs}px monospace`;
|
||||
ctx.fillText(el.text, el.x, el.y);
|
||||
break;
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
// Auswahl-Rahmen + Resize-Handle
|
||||
if (!forExport && selectedId === el.id) {
|
||||
const b = getBounds(el);
|
||||
if (b) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = '#4ECDC4';
|
||||
ctx.lineWidth = 1.5 / vp.zoom;
|
||||
ctx.setLineDash([4/vp.zoom, 3/vp.zoom]);
|
||||
ctx.strokeRect(b.x-2/vp.zoom, b.y-2/vp.zoom, b.w+4/vp.zoom, b.h+4/vp.zoom);
|
||||
ctx.setLineDash([]);
|
||||
// Resize-Handle rechts-unten
|
||||
const hx = b.x+b.w, hy = b.y+b.h, hr = HANDLE_R/vp.zoom;
|
||||
ctx.fillStyle = '#4ECDC4';
|
||||
ctx.beginPath(); ctx.arc(hx, hy, hr, 0, Math.PI*2); ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Offscreen-Export (ohne Auswahl-Rahmen, original Viewport)
|
||||
function exportToPng(elements, title) {
|
||||
if (!elements?.length) return; // Nichts zu exportieren
|
||||
let minX=Infinity,minY=Infinity,maxX=-Infinity,maxY=-Infinity;
|
||||
for (const el of elements) {
|
||||
const b = getBounds(el); if (!b) continue;
|
||||
minX=Math.min(minX,b.x); minY=Math.min(minY,b.y);
|
||||
maxX=Math.max(maxX,b.x+b.w); maxY=Math.max(maxY,b.y+b.h);
|
||||
}
|
||||
if (!isFinite(minX)) { minX=0; minY=0; maxX=800; maxY=600; }
|
||||
const pad=40, W=Math.max(800,maxX-minX+pad*2), H=Math.max(600,maxY-minY+pad*2);
|
||||
const offscreen = document.createElement('canvas');
|
||||
offscreen.width=W; offscreen.height=H;
|
||||
const ctx = offscreen.getContext('2d');
|
||||
renderElements(ctx, elements, { x: -minX+pad, y: -minY+pad, zoom:1 }, null, true);
|
||||
const a = document.createElement('a');
|
||||
a.href = offscreen.toDataURL('image/png');
|
||||
a.download = (title||'whiteboard').replace(/[^\w\s-]/g,'').trim()+'.png';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
|
||||
// ── Share-Modal ───────────────────────────────────────────────────────────────
|
||||
function ShareModal({ wbId, onClose, toast }) {
|
||||
const [allUsers, setAllUsers] = useState([]);
|
||||
const [perms, setPerms] = useState([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
useEffect(() => {
|
||||
Promise.all([api('/tools/whiteboard/users-list'), api(`/tools/whiteboard/${wbId}/data`)])
|
||||
.then(([u,d]) => { setAllUsers(u.users||[]); setPerms(d.permissions||[]); }).catch(()=>{});
|
||||
}, [wbId]);
|
||||
const roleOf = uid => perms.find(p=>p.user_id===uid)?.role||'none';
|
||||
const setRole = (uid, role) => setPerms(prev => {
|
||||
const f = prev.filter(p=>p.user_id!==uid);
|
||||
return role==='none' ? f : [...f,{user_id:uid,role}];
|
||||
});
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await api(`/tools/whiteboard/${wbId}/permissions`,{ method:'PUT',
|
||||
body:{ permissions: allUsers.map(u=>({ user_id:u.id, role: roleOf(u.id)==='none'?null:roleOf(u.id) })) }});
|
||||
toast('Berechtigungen gespeichert ✓'); onClose();
|
||||
} catch(e){ toast(e.message,'error'); } finally { setSaving(false); }
|
||||
};
|
||||
const mob = window.innerWidth < 768;
|
||||
return (
|
||||
<div onClick={e=>e.target===e.currentTarget&&onClose()} style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.75)',zIndex:2000,
|
||||
display:'flex',alignItems:mob?'flex-end':'center',justifyContent:'center',padding:mob?0:20}}>
|
||||
<div style={{background:'#1a1a1e',border:'1px solid rgba(255,255,255,0.12)',borderRadius:mob?'16px 16px 0 0':14,
|
||||
width:'100%',maxWidth:440,display:'flex',flexDirection:'column',maxHeight:mob?'80vh':'75vh',
|
||||
paddingBottom:mob?'calc(56px + env(safe-area-inset-bottom,0px))':0}}>
|
||||
<div style={{padding:'18px 20px 0',flexShrink:0}}>
|
||||
{mob&&<div style={{width:36,height:4,background:'rgba(255,255,255,0.15)',borderRadius:2,margin:'0 auto 14px'}}/>}
|
||||
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:14}}>
|
||||
<div style={{...S.head,marginBottom:0}}>TEILEN</div>
|
||||
<button onClick={onClose} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:18}}>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{overflowY:'auto',padding:'0 20px',flex:1}}>
|
||||
{allUsers.length===0&&<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:12}}>Keine anderen Benutzer.</div>}
|
||||
{allUsers.map(u=>(
|
||||
<div key={u.id} style={{display:'flex',alignItems:'center',gap:8,padding:'8px 0',borderBottom:'1px solid rgba(255,255,255,0.05)'}}>
|
||||
<span style={{flex:1,fontFamily:'monospace',fontSize:13,color:'#fff'}}>{u.username}</span>
|
||||
{['none','view','edit'].map(r=>(
|
||||
<button key={r} onClick={()=>setRole(u.id,r)} style={{...S.btn(r==='edit'?'#4ECDC4':r==='view'?'#60A5FA':'#888888',true),
|
||||
fontSize:10,padding:'3px 8px',
|
||||
background:roleOf(u.id)===r?(r==='edit'?'rgba(78,205,196,0.2)':r==='view'?'rgba(96,165,250,0.2)':'rgba(136,136,136,0.2)'):'transparent',
|
||||
fontWeight:roleOf(u.id)===r?700:400}}>
|
||||
{r==='none'?'✕ kein':r==='view'?'👁 lesen':'✏ bearbeiten'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{padding:'14px 20px 20px',flexShrink:0,borderTop:'1px solid rgba(255,255,255,0.07)',display:'flex',gap:8,justifyContent:'flex-end'}}>
|
||||
<button onClick={onClose} style={S.btn('#888888',true)}>Abbrechen</button>
|
||||
<button onClick={save} disabled={saving} style={S.btn('#4ECDC4',true)}>{saving?'…':'Speichern'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Hauptkomponente ───────────────────────────────────────────────────────────
|
||||
export default function Whiteboard({ toast, mobile }) {
|
||||
// View
|
||||
const [view, setView] = useState('list');
|
||||
const [boards, setBoards] = useState([]);
|
||||
const [unreadIds, setUnreadIds] = useState(new Set());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [current, setCurrent] = useState(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newTitle, setNewTitle] = useState('');
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [editTitle, setEditTitle] = useState('');
|
||||
|
||||
// Canvas
|
||||
const [elements, setElements] = useState([]);
|
||||
const [undoStack, setUndoStack] = useState([]);
|
||||
const [tool, setTool] = useState('pen');
|
||||
const [color, setColor] = useState('#FFFFFF');
|
||||
const [size, setSize] = useState(1);
|
||||
const [fontSize, setFontSize] = useState(2); // Index in FONT_SIZES
|
||||
const [viewport, setViewport] = useState({x:0,y:0,zoom:1});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [shareModal,setShareModal]= useState(false);
|
||||
const [textInput, setTextInput] = useState(null);
|
||||
const [textPreview, setTextPreview] = useState(null); // Live-Vorschau während Tippen
|
||||
const [selectedId,setSelectedId]= useState(null);
|
||||
|
||||
// Kollaboration
|
||||
const [collab, setCollab] = useState([]);
|
||||
const wsRef = useRef(null);
|
||||
const cursorTimers = useRef({});
|
||||
|
||||
// Canvas Refs
|
||||
const canvasRef = useRef(null);
|
||||
const drawing = useRef(false);
|
||||
const currentEl = useRef(null);
|
||||
const startPos = useRef({x:0,y:0});
|
||||
const isPanning = useRef(false);
|
||||
const isDragging = useRef(false); // Element verschieben
|
||||
const isResizing = useRef(false); // Element resize
|
||||
const panStart = useRef({x:0,y:0});
|
||||
const dragStart = useRef({x:0,y:0}); // Startpos für Drag
|
||||
const vpRef = useRef({x:0,y:0,zoom:1});
|
||||
const elementsRef = useRef([]);
|
||||
const selectedRef = useRef(null);
|
||||
const textPreviewRef = useRef(null);
|
||||
|
||||
// ── Hilfsfunktionen ──────────────────────────────────────────────────────
|
||||
const syncVp = (vp) => { vpRef.current=vp; setViewport({...vp}); };
|
||||
|
||||
const toCanvas = (e) => {
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
const sx = (e.clientX ?? e.touches?.[0]?.clientX ?? 0) - rect.left;
|
||||
const sy = (e.clientY ?? e.touches?.[0]?.clientY ?? 0) - rect.top;
|
||||
return { x:(sx-vpRef.current.x)/vpRef.current.zoom, y:(sy-vpRef.current.y)/vpRef.current.zoom, sx, sy };
|
||||
};
|
||||
|
||||
// Ist der Pointer auf dem Resize-Handle des selektierten Elements?
|
||||
const onResizeHandle = (cx, cy) => {
|
||||
if (!selectedRef.current) return false;
|
||||
const el = elementsRef.current.find(e=>e.id===selectedRef.current);
|
||||
if (!el) return false;
|
||||
const b = getBounds(el); if (!b) return false;
|
||||
const hxS = (b.x+b.w)*vpRef.current.zoom + vpRef.current.x;
|
||||
const hyS = (b.y+b.h)*vpRef.current.zoom + vpRef.current.y;
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
const mxS = cx + rect.left - rect.left;
|
||||
const myS = cy + rect.top - rect.top;
|
||||
// cx/cy sind schon in Screen-Koordinaten (sx,sy aus toCanvas)
|
||||
return Math.hypot(cx - (b.x+b.w)*vpRef.current.zoom - vpRef.current.x,
|
||||
cy - (b.y+b.h)*vpRef.current.zoom - vpRef.current.y) <= HANDLE_R + 4;
|
||||
};
|
||||
|
||||
// ── Element-Operationen ──────────────────────────────────────────────────
|
||||
const broadcast = (els) => {
|
||||
wsRef.current?.readyState===1 && wsRef.current.send(JSON.stringify({type:'elements',elements:els}));
|
||||
};
|
||||
|
||||
const pushElement = (el) => {
|
||||
const next = [...elementsRef.current, el];
|
||||
elementsRef.current = next;
|
||||
setUndoStack(prev => [...prev, elementsRef.current.slice(0,-1)]);
|
||||
setElements([...next]);
|
||||
broadcast(next);
|
||||
};
|
||||
|
||||
const updateLastElement = (el) => {
|
||||
const next = [...elementsRef.current.slice(0,-1), el];
|
||||
elementsRef.current = next;
|
||||
setElements([...next]);
|
||||
};
|
||||
|
||||
const finalizeLastElement = (el) => {
|
||||
const next = [...elementsRef.current.slice(0,-1), el];
|
||||
elementsRef.current = next;
|
||||
setElements([...next]);
|
||||
broadcast(next);
|
||||
};
|
||||
|
||||
const updateElement = (id, updater) => {
|
||||
const next = elementsRef.current.map(e => e.id===id ? updater(e) : e);
|
||||
elementsRef.current = next;
|
||||
setElements([...next]);
|
||||
broadcast(next);
|
||||
setUndoStack(prev => [...prev, elementsRef.current]);
|
||||
};
|
||||
|
||||
const undo = () => {
|
||||
if (!undoStack.length) return;
|
||||
const prev = undoStack[undoStack.length-1];
|
||||
setUndoStack(s => s.slice(0,-1));
|
||||
elementsRef.current = prev;
|
||||
setElements([...prev]);
|
||||
broadcast(prev);
|
||||
setSelectedId(null); selectedRef.current=null;
|
||||
};
|
||||
|
||||
// ── Laden / Speichern ────────────────────────────────────────────────────
|
||||
const loadBoards = useCallback(() => {
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
api('/tools/whiteboard'),
|
||||
api('/tools/whiteboard/unread').catch(() => ({ count: 0 })),
|
||||
]).then(([d, u]) => {
|
||||
setBoards(d.whiteboards || []);
|
||||
// unread count kommt global — wir markieren alle als "möglicherweise unread"
|
||||
// getGameForUser gibt uns keine IDs, also nutzen wir den globalen count als Indikator
|
||||
}).catch(() => toast('Fehler', 'error')).finally(() => setLoading(false));
|
||||
// Einzelne unread IDs vom Backend holen
|
||||
api('/tools/whiteboard/unread-ids').then(d => setUnreadIds(new Set(d.ids || []))).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const renameBoard = async (id, title) => {
|
||||
if (!title?.trim()) return;
|
||||
try {
|
||||
await api(`/tools/whiteboard/${id}/title`,{method:'PATCH',body:{title:title.trim()}});
|
||||
setBoards(prev=>prev.map(b=>b.id===id?{...b,title:title.trim()}:b));
|
||||
if (current?.id===id) setCurrent(prev=>({...prev,title:title.trim()}));
|
||||
} catch(e){ toast(e.message,'error'); } finally { setEditingId(null); }
|
||||
};
|
||||
|
||||
useEffect(()=>{ loadBoards(); },[loadBoards]);
|
||||
|
||||
const openBoard = async (board) => {
|
||||
setUnreadIds(prev => { const s = new Set(prev); s.delete(board.id); return s; });
|
||||
try {
|
||||
const d = await api(`/tools/whiteboard/${board.id}/data`);
|
||||
const els = JSON.parse(typeof d.elements==='string'?d.elements:JSON.stringify(d.elements||[]));
|
||||
const vp = JSON.parse(typeof d.viewport==='string'?d.viewport:JSON.stringify(d.viewport||{x:0,y:0,zoom:1}));
|
||||
elementsRef.current=els; vpRef.current=vp;
|
||||
setElements(els); setViewport(vp); setUndoStack([]);
|
||||
setCurrent({id:board.id,title:d.title,role:d.role});
|
||||
setView('canvas'); connectWs(board.id);
|
||||
} catch(e){ toast(e.message,'error'); }
|
||||
};
|
||||
|
||||
const closeBoard = () => {
|
||||
disconnectWs(); setView('list'); setCurrent(null);
|
||||
setElements([]); setUndoStack([]); setCollab([]);
|
||||
setSelectedId(null); selectedRef.current=null;
|
||||
loadBoards();
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (saving) return; setSaving(true);
|
||||
try {
|
||||
await api(`/tools/whiteboard/${current.id}/save`,{body:{elements:elementsRef.current,viewport:vpRef.current}});
|
||||
toast('Gespeichert ✓');
|
||||
} catch(e){ toast(e.message,'error'); } finally { setSaving(false); }
|
||||
};
|
||||
|
||||
// ── WebSocket ─────────────────────────────────────────────────────────────
|
||||
const connectWs = useCallback((wbId) => {
|
||||
const token = localStorage.getItem('sk_token');
|
||||
const proto = location.protocol==='https:'?'wss':'ws';
|
||||
const ws = new WebSocket(`${proto}://${location.host}/ws/whiteboard?token=${token}&id=${wbId}`);
|
||||
wsRef.current = ws;
|
||||
const cc = {}; let ci=0;
|
||||
ws.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data);
|
||||
switch(msg.type) {
|
||||
case 'user_join':
|
||||
if(!cc[msg.userId]) cc[msg.userId]=CURSOR_COLORS[ci++%CURSOR_COLORS.length];
|
||||
setCollab(prev=>[...prev.filter(c=>c.userId!==msg.userId),{userId:msg.userId,username:msg.username,color:cc[msg.userId],x:0,y:0}]); break;
|
||||
case 'user_leave':
|
||||
setCollab(prev=>prev.filter(c=>c.userId!==msg.userId)); break;
|
||||
case 'active_users':
|
||||
msg.users.forEach(u=>{ if(!cc[u.userId]) cc[u.userId]=CURSOR_COLORS[ci++%CURSOR_COLORS.length]; });
|
||||
setCollab(msg.users.map(u=>({...u,color:cc[u.userId]||CURSOR_COLORS[0],x:0,y:0}))); break;
|
||||
case 'cursor':
|
||||
if(!cc[msg.userId]) cc[msg.userId]=CURSOR_COLORS[ci++%CURSOR_COLORS.length];
|
||||
setCollab(prev=>prev.map(c=>c.userId===msg.userId?{...c,x:msg.x,y:msg.y}:c));
|
||||
clearTimeout(cursorTimers.current[msg.userId]);
|
||||
cursorTimers.current[msg.userId]=setTimeout(()=>setCollab(prev=>prev.map(c=>c.userId===msg.userId?{...c,x:-9999,y:-9999}:c)),3000); break;
|
||||
case 'elements':
|
||||
elementsRef.current=msg.elements; setElements([...msg.elements]); break;
|
||||
}
|
||||
};
|
||||
ws.onclose=()=>setCollab([]); ws.onerror=()=>{};
|
||||
},[]);
|
||||
|
||||
const disconnectWs = useCallback(()=>{ wsRef.current?.close(); wsRef.current=null; setCollab([]); },[]);
|
||||
|
||||
// ── Canvas-Rendering ──────────────────────────────────────────────────────
|
||||
const render = useCallback(() => {
|
||||
const canvas = canvasRef.current; if(!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
renderElements(ctx, elementsRef.current, vpRef.current, selectedRef.current, false);
|
||||
// Cursor anderer User
|
||||
for (const c of collab) {
|
||||
if (c.x===-9999) continue;
|
||||
const sx = c.x*vpRef.current.zoom+vpRef.current.x;
|
||||
const sy = c.y*vpRef.current.zoom+vpRef.current.y;
|
||||
ctx.save(); ctx.fillStyle=c.color; ctx.font='10px monospace';
|
||||
ctx.fillText('▶ '+c.username, sx+6, sy-4); ctx.restore();
|
||||
}
|
||||
// Text-Vorschau während Tippen
|
||||
if (textPreviewRef.current) {
|
||||
const { text, x, y, fontSize, color } = textPreviewRef.current;
|
||||
const vp = vpRef.current;
|
||||
const sx = x * vp.zoom + vp.x;
|
||||
const sy = y * vp.zoom + vp.y;
|
||||
ctx.save();
|
||||
ctx.font = `${fontSize * vp.zoom}px monospace`;
|
||||
ctx.fillStyle = color;
|
||||
ctx.globalAlpha = 0.85;
|
||||
ctx.fillText(text || '|', sx, sy);
|
||||
// Cursor-Blinken simulieren: kleiner Strich hinter dem Text
|
||||
if (text) {
|
||||
const tw = ctx.measureText(text).width;
|
||||
ctx.fillRect(sx + tw + 1, sy - fontSize * vp.zoom * 0.8, 2, fontSize * vp.zoom);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
}, [collab, textPreview]);
|
||||
|
||||
useEffect(()=>{ render(); }, [elements, viewport, selectedId, collab, render]);
|
||||
|
||||
// Canvas-Resize
|
||||
useEffect(()=>{
|
||||
if(view!=='canvas') return;
|
||||
const resize=()=>{ const c=canvasRef.current; if(!c) return; c.width=c.offsetWidth; c.height=c.offsetHeight; render(); };
|
||||
resize(); window.addEventListener('resize',resize);
|
||||
return ()=>window.removeEventListener('resize',resize);
|
||||
},[view, render]);
|
||||
|
||||
// ── Pointer-Events ────────────────────────────────────────────────────────
|
||||
const onPointerDown = (e) => {
|
||||
if (current?.role==='view') return;
|
||||
e.preventDefault();
|
||||
const { x, y, sx, sy } = toCanvas(e);
|
||||
|
||||
// Panning: Mittelklick oder Leerzeichen-Drag (über CSS cursor:grab bei select-tool)
|
||||
if (e.button===1) {
|
||||
isPanning.current=true;
|
||||
panStart.current={ x: sx-vpRef.current.x, y: sy-vpRef.current.y }; return;
|
||||
}
|
||||
|
||||
// Select-Tool
|
||||
if (tool==='select') {
|
||||
// Resize-Handle?
|
||||
if (onResizeHandle(sx, sy)) {
|
||||
isResizing.current=true;
|
||||
dragStart.current={x,y}; return;
|
||||
}
|
||||
// Element treffen?
|
||||
const hit = [...elementsRef.current].reverse().find(el=>hitTest(el,x,y));
|
||||
if (hit) {
|
||||
selectedRef.current=hit.id; setSelectedId(hit.id);
|
||||
isDragging.current=true;
|
||||
dragStart.current={x,y}; return;
|
||||
}
|
||||
// Leerer Klick → Auswahl aufheben
|
||||
selectedRef.current=null; setSelectedId(null); return;
|
||||
}
|
||||
|
||||
// Text
|
||||
if (tool==='text') {
|
||||
textPreviewRef.current = { text:'', x, y, fontSize:FONT_SIZES[fontSize], color };
|
||||
render();
|
||||
setTextInput({ sx, sy, x, y }); return;
|
||||
}
|
||||
|
||||
drawing.current=true; startPos.current={x,y};
|
||||
|
||||
if (tool==='pen'||tool==='eraser') {
|
||||
const el={id:getId(),type:tool,color,size:SIZES[size],points:[{x,y}]};
|
||||
currentEl.current=el; pushElement(el);
|
||||
} else if (tool==='line') {
|
||||
const el={id:getId(),type:'line',color,size:SIZES[size],x1:x,y1:y,x2:x,y2:y};
|
||||
currentEl.current=el; pushElement(el);
|
||||
} else if (tool==='rect') {
|
||||
const el={id:getId(),type:'rect',color,size:SIZES[size],x,y,w:0,h:0};
|
||||
currentEl.current=el; pushElement(el);
|
||||
} else if (tool==='ellipse') {
|
||||
const el={id:getId(),type:'ellipse',color,size:SIZES[size],cx:x,cy:y,rx:0,ry:0};
|
||||
currentEl.current=el; pushElement(el);
|
||||
}
|
||||
};
|
||||
|
||||
const onPointerMove = (e) => {
|
||||
const { x, y, sx, sy } = toCanvas(e);
|
||||
wsRef.current?.readyState===1 && wsRef.current.send(JSON.stringify({type:'cursor',x,y}));
|
||||
|
||||
if (isPanning.current) {
|
||||
syncVp({ ...vpRef.current, x: sx-panStart.current.x, y: sy-panStart.current.y }); return;
|
||||
}
|
||||
|
||||
if (isDragging.current && selectedRef.current) {
|
||||
const dx=x-dragStart.current.x, dy=y-dragStart.current.y;
|
||||
dragStart.current={x,y};
|
||||
const next=elementsRef.current.map(el=>el.id===selectedRef.current?moveElement(el,dx,dy):el);
|
||||
elementsRef.current=next; setElements([...next]); return;
|
||||
}
|
||||
|
||||
if (isResizing.current && selectedRef.current) {
|
||||
const next=elementsRef.current.map(el=>el.id===selectedRef.current?resizeElement(el,x,y):el);
|
||||
elementsRef.current=next; setElements([...next]); return;
|
||||
}
|
||||
|
||||
if (!drawing.current||!currentEl.current) return;
|
||||
const el=currentEl.current;
|
||||
|
||||
if (tool==='pen'||tool==='eraser') {
|
||||
const updated={...el,points:[...el.points,{x,y}]};
|
||||
currentEl.current=updated; updateLastElement(updated);
|
||||
} else if (tool==='line') {
|
||||
const updated={...el,x2:x,y2:y}; currentEl.current=updated; updateLastElement(updated);
|
||||
} else if (tool==='rect') {
|
||||
const updated={...el,w:x-el.x,h:y-el.y}; currentEl.current=updated; updateLastElement(updated);
|
||||
} else if (tool==='ellipse') {
|
||||
const updated={...el,rx:x-el.cx,ry:y-el.cy}; currentEl.current=updated; updateLastElement(updated);
|
||||
}
|
||||
};
|
||||
|
||||
const onPointerUp = () => {
|
||||
isPanning.current=false;
|
||||
if (isDragging.current||isResizing.current) {
|
||||
broadcast(elementsRef.current);
|
||||
setUndoStack(prev=>[...prev, elementsRef.current]);
|
||||
}
|
||||
isDragging.current=false; isResizing.current=false;
|
||||
if (!drawing.current) return;
|
||||
drawing.current=false;
|
||||
if (currentEl.current) { finalizeLastElement(currentEl.current); currentEl.current=null; }
|
||||
};
|
||||
|
||||
const onWheel = (e) => {
|
||||
e.preventDefault();
|
||||
const delta=e.deltaY>0?0.9:1.1;
|
||||
const rect=canvasRef.current.getBoundingClientRect();
|
||||
const mx=e.clientX-rect.left, my=e.clientY-rect.top;
|
||||
const nz=Math.max(0.1,Math.min(8, vpRef.current.zoom*delta));
|
||||
syncVp({ x: mx-(mx-vpRef.current.x)*(nz/vpRef.current.zoom),
|
||||
y: my-(my-vpRef.current.y)*(nz/vpRef.current.zoom), zoom:nz });
|
||||
};
|
||||
|
||||
const zoom = (factor) => {
|
||||
const nz=Math.max(0.1,Math.min(8,vpRef.current.zoom*factor));
|
||||
const canvas=canvasRef.current; if(!canvas) return;
|
||||
const mx=canvas.width/2, my=canvas.height/2;
|
||||
syncVp({ x: mx-(mx-vpRef.current.x)*(nz/vpRef.current.zoom),
|
||||
y: my-(my-vpRef.current.y)*(nz/vpRef.current.zoom), zoom:nz });
|
||||
};
|
||||
|
||||
// Tastenkürzel
|
||||
useEffect(()=>{
|
||||
if(view!=='canvas') return;
|
||||
const onKey=(e)=>{
|
||||
if(e.target.tagName==='INPUT'||e.target.tagName==='TEXTAREA') return;
|
||||
if((e.ctrlKey||e.metaKey)&&e.key==='z'){ e.preventDefault(); undo(); }
|
||||
if((e.ctrlKey||e.metaKey)&&e.key==='s'){ e.preventDefault(); save(); }
|
||||
if((e.ctrlKey||e.metaKey)&&(e.key==='='||e.key==='+')){ e.preventDefault(); zoom(1.2); }
|
||||
if((e.ctrlKey||e.metaKey)&&e.key==='-'){ e.preventDefault(); zoom(0.8); }
|
||||
if((e.ctrlKey||e.metaKey)&&e.key==='0'){ e.preventDefault(); syncVp({x:0,y:0,zoom:1}); }
|
||||
if(e.key==='Escape'){ setSelectedId(null); selectedRef.current=null; }
|
||||
if((e.key==='Delete'||e.key==='Backspace')&&selectedRef.current&&e.target.tagName!=='INPUT'){
|
||||
e.preventDefault();
|
||||
const next=elementsRef.current.filter(el=>el.id!==selectedRef.current);
|
||||
setUndoStack(prev=>[...prev,elementsRef.current]);
|
||||
elementsRef.current=next; setElements([...next]); broadcast(next);
|
||||
setSelectedId(null); selectedRef.current=null;
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown',onKey);
|
||||
return ()=>window.removeEventListener('keydown',onKey);
|
||||
},[view, undoStack]);
|
||||
|
||||
// ── Cursor-Style ──────────────────────────────────────────────────────────
|
||||
const getCursor = () => {
|
||||
if (tool==='select') return isDragging.current?'grabbing':isResizing.current?'nwse-resize':'default';
|
||||
if (tool==='eraser') return 'cell';
|
||||
if (tool==='text') return 'text';
|
||||
return 'crosshair';
|
||||
};
|
||||
|
||||
// ── Render: Liste ─────────────────────────────────────────────────────────
|
||||
if (view==='list') return (
|
||||
<div style={{padding:mobile?'14px 14px 90px':'36px 44px'}}>
|
||||
<div style={{display:'flex',alignItems:'center',gap:10,marginBottom:24,flexWrap:'wrap'}}>
|
||||
<h2 style={{margin:0,fontSize:15,fontFamily:'monospace',color:'rgba(255,255,255,0.55)',letterSpacing:2,fontWeight:400}}>WHITEBOARD</h2>
|
||||
<div style={{flex:1}}/>
|
||||
{creating ? (
|
||||
<div style={{display:'flex',gap:6}}>
|
||||
<input autoFocus value={newTitle} onChange={e=>setNewTitle(e.target.value)}
|
||||
onKeyDown={async e=>{
|
||||
if(e.key==='Enter'&&newTitle.trim()){
|
||||
try{ const wb=await api('/tools/whiteboard',{body:{title:newTitle.trim()}}); setCreating(false);setNewTitle(''); await openBoard(wb); }
|
||||
catch(err){ toast(err.message,'error'); }
|
||||
}
|
||||
if(e.key==='Escape'){setCreating(false);setNewTitle('');}
|
||||
}}
|
||||
placeholder="Name des Whiteboards" style={{...S.inp,width:mobile?160:220,fontSize:12}}/>
|
||||
<button onClick={async()=>{
|
||||
if(!newTitle.trim()) return;
|
||||
try{ const wb=await api('/tools/whiteboard',{body:{title:newTitle.trim()}}); setCreating(false);setNewTitle(''); await openBoard(wb); }
|
||||
catch(err){ toast(err.message,'error'); }
|
||||
}} style={S.btn('#4ECDC4',true)}>Erstellen</button>
|
||||
<button onClick={()=>{setCreating(false);setNewTitle('');}} style={S.btn('#888888',true)}>✕</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={()=>setCreating(true)} style={S.btn('#4ECDC4')}>+ Neu</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading&&<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:12}}>Lädt…</div>}
|
||||
{!loading&&boards.length===0&&(
|
||||
<div style={{...S.card,textAlign:'center',padding:40,color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:12}}>
|
||||
Noch kein Whiteboard. Erstelle eines mit "+ Neu".
|
||||
</div>
|
||||
)}
|
||||
|
||||
{boards.map(b=>(
|
||||
<div key={b.id} style={{...S.card,display:'flex',alignItems:'center',gap:12,marginBottom:8,cursor:'pointer'}}
|
||||
onClick={()=>{ if(editingId!==b.id) openBoard(b); }}>
|
||||
<div style={{fontSize:24,position:'relative',flexShrink:0}}>
|
||||
🖊
|
||||
{unreadIds.has(b.id) && (
|
||||
<span style={{position:'absolute',top:-3,right:-3,width:8,height:8,
|
||||
borderRadius:'50%',background:'#4ecdc4',boxShadow:'0 0 5px #4ecdc4'}}/>
|
||||
)}
|
||||
</div>
|
||||
<div style={{flex:1,minWidth:0}}>
|
||||
{editingId===b.id?(
|
||||
<input autoFocus value={editTitle} onChange={e=>setEditTitle(e.target.value)}
|
||||
onKeyDown={e=>{if(e.key==='Enter') renameBoard(b.id,editTitle); if(e.key==='Escape') setEditingId(null);}}
|
||||
onBlur={()=>renameBoard(b.id,editTitle)}
|
||||
onClick={e=>e.stopPropagation()}
|
||||
style={{...S.inp,fontSize:12,fontWeight:700,padding:'3px 8px',width:'100%'}}/>
|
||||
):(
|
||||
<div style={{color:'#fff',fontFamily:'monospace',fontSize:13,fontWeight:700}}
|
||||
onDoubleClick={e=>{e.stopPropagation();if(b.role==='owner'||b.role==='edit'){setEditingId(b.id);setEditTitle(b.title);}}}>
|
||||
{b.title}
|
||||
</div>
|
||||
)}
|
||||
<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:10,marginTop:2}}>
|
||||
{b.role==='owner'?'👑 Eigenes':b.role==='edit'?'✏ Bearbeiten':'👁 Lesen'}
|
||||
{b.owner_name&&b.role!=='owner'&&` · von ${b.owner_name}`}
|
||||
{' · '+new Date(b.updated_at).toLocaleDateString('de-DE',{day:'2-digit',month:'2-digit',year:'numeric'})}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{display:'flex',gap:4,flexShrink:0}}>
|
||||
<button onClick={e=>{e.stopPropagation();
|
||||
api(`/tools/whiteboard/${b.id}/data`).then(d=>{
|
||||
const els=JSON.parse(typeof d.elements==='string'?d.elements:JSON.stringify(d.elements||[]));
|
||||
exportToPng(els,b.title);
|
||||
}).catch(()=>toast('Export fehlgeschlagen','error'));
|
||||
}} title="Als PNG exportieren"
|
||||
style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.25)',cursor:'pointer',fontSize:14,padding:'4px 6px'}}>⬇</button>
|
||||
{(b.role==='owner'||b.role==='edit')&&(
|
||||
<button onClick={e=>{e.stopPropagation();setEditingId(b.id);setEditTitle(b.title);}} title="Umbenennen"
|
||||
style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.2)',cursor:'pointer',fontSize:14,padding:'4px 6px'}}>✎</button>
|
||||
)}
|
||||
{b.role==='owner'&&(
|
||||
<button onClick={e=>{e.stopPropagation();if(confirm(`"${b.title}" löschen?`))
|
||||
api(`/tools/whiteboard/${b.id}`,{method:'DELETE'}).then(loadBoards).catch(err=>toast(err.message,'error'));
|
||||
}} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.2)',cursor:'pointer',fontSize:16,padding:'4px 6px'}}>✕</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── Render: Canvas ────────────────────────────────────────────────────────
|
||||
const canEdit = current?.role !== 'view';
|
||||
|
||||
return (
|
||||
<div style={{display:'flex',flexDirection:'column',height:'100%',background:BG,position:'relative'}}>
|
||||
|
||||
{shareModal&¤t?.role==='owner'&&(
|
||||
<ShareModal wbId={current.id} onClose={()=>setShareModal(false)} toast={toast}/>
|
||||
)}
|
||||
|
||||
{textInput&&(
|
||||
mobile ? (
|
||||
<div style={{position:'fixed',bottom:0,left:0,right:0,zIndex:500,
|
||||
background:'#1a1a1e',borderTop:'1px solid rgba(255,255,255,0.15)',
|
||||
padding:'12px 16px',paddingBottom:'calc(12px + env(safe-area-inset-bottom,0px))'}}>
|
||||
<div style={{fontSize:10,fontFamily:'monospace',color:'rgba(255,255,255,0.35)',marginBottom:6}}>
|
||||
TEXT EINGEBEN · Enter = bestätigen · Esc = abbrechen
|
||||
</div>
|
||||
<div style={{display:'flex',gap:8}}>
|
||||
<input autoFocus
|
||||
onChange={e=>{
|
||||
textPreviewRef.current={text:e.target.value,x:textInput.x,y:textInput.y,fontSize:FONT_SIZES[fontSize],color};
|
||||
render();
|
||||
}}
|
||||
onKeyDown={e=>{
|
||||
if(e.key==='Enter'){
|
||||
const v=e.target.value; textPreviewRef.current=null; setTextInput(null);
|
||||
if(v?.trim()) pushElement({id:getId(),type:'text',color,fontSize:FONT_SIZES[fontSize],text:v,x:textInput.x,y:textInput.y});
|
||||
}
|
||||
if(e.key==='Escape'){ textPreviewRef.current=null; setTextInput(null); render(); }
|
||||
}}
|
||||
placeholder="Text eingeben…"
|
||||
style={{...S.inp,flex:1,fontSize:14,color}}/>
|
||||
<button onMouseDown={e=>{
|
||||
e.preventDefault();
|
||||
const inp=e.currentTarget.previousSibling;
|
||||
const v=inp?.value||''; textPreviewRef.current=null; setTextInput(null);
|
||||
if(v?.trim()) pushElement({id:getId(),type:'text',color,fontSize:FONT_SIZES[fontSize],text:v,x:textInput.x,y:textInput.y});
|
||||
}} style={S.btn('#4ECDC4',true)}>✓</button>
|
||||
<button onMouseDown={e=>{e.preventDefault();textPreviewRef.current=null;setTextInput(null);render();}} style={S.btn('#888888',true)}>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{position:'absolute',left:textInput.sx,top:textInput.sy,zIndex:100,pointerEvents:'auto'}}>
|
||||
<input autoFocus
|
||||
onChange={e=>{
|
||||
textPreviewRef.current={text:e.target.value,x:textInput.x,y:textInput.y,fontSize:FONT_SIZES[fontSize],color};
|
||||
render();
|
||||
}}
|
||||
onKeyDown={e=>{
|
||||
if(e.key==='Enter'){
|
||||
const v=e.target.value; textPreviewRef.current=null; setTextInput(null);
|
||||
if(v?.trim()) pushElement({id:getId(),type:'text',color,fontSize:FONT_SIZES[fontSize],text:v,x:textInput.x,y:textInput.y});
|
||||
}
|
||||
if(e.key==='Escape'){ textPreviewRef.current=null; setTextInput(null); render(); }
|
||||
}}
|
||||
onBlur={e=>{
|
||||
const v=e.target.value; textPreviewRef.current=null; setTextInput(null);
|
||||
if(v?.trim()) pushElement({id:getId(),type:'text',color,fontSize:FONT_SIZES[fontSize],text:v,x:textInput.x,y:textInput.y});
|
||||
}}
|
||||
style={{background:'rgba(0,0,0,0.7)',border:'none',
|
||||
outline:`1px dashed ${color}`,borderRadius:4,padding:'2px 6px',
|
||||
color,fontSize:FONT_SIZES[fontSize],fontFamily:'monospace',minWidth:120}}/>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* ── Topbar ───────────────────────────────────────────────────────── */}
|
||||
<div style={{display:'flex',alignItems:'center',gap:8,padding:'8px 12px',
|
||||
background:'rgba(0,0,0,0.6)',borderBottom:'1px solid rgba(255,255,255,0.08)',flexShrink:0,flexWrap:'wrap'}}>
|
||||
<button onClick={closeBoard} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.5)',cursor:'pointer',fontSize:18,padding:'2px 6px',lineHeight:1}}>←</button>
|
||||
{editingId===current?.id?(
|
||||
<input autoFocus value={editTitle} onChange={e=>setEditTitle(e.target.value)}
|
||||
onKeyDown={e=>{if(e.key==='Enter') renameBoard(current.id,editTitle); if(e.key==='Escape') setEditingId(null);}}
|
||||
onBlur={()=>renameBoard(current.id,editTitle)}
|
||||
style={{...S.inp,fontSize:13,fontWeight:700,flex:1,padding:'3px 8px'}}/>
|
||||
):(
|
||||
<span onDoubleClick={()=>{if(current?.role!=='view'){setEditingId(current.id);setEditTitle(current.title);}}}
|
||||
title={current?.role!=='view'?'Doppelklick zum Umbenennen':''}
|
||||
style={{color:'#fff',fontFamily:'monospace',fontSize:13,fontWeight:700,flex:1,minWidth:0,
|
||||
overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap',cursor:current?.role!=='view'?'text':'default'}}>
|
||||
{current?.title}
|
||||
{current?.role==='view'&&<span style={{color:'rgba(255,255,255,0.3)',fontSize:10,marginLeft:8}}>👁 nur lesen</span>}
|
||||
</span>
|
||||
)}
|
||||
{collab.length>0&&(
|
||||
<div style={{display:'flex',gap:4,alignItems:'center'}}>
|
||||
{collab.map(c=>(
|
||||
<span key={c.userId} style={{background:c.color,color:'#000',fontFamily:'monospace',fontSize:9,borderRadius:10,padding:'2px 7px',fontWeight:700}}>
|
||||
{c.username[0].toUpperCase()}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button onClick={()=>exportToPng(elementsRef.current,current?.title)}
|
||||
style={{...S.btn('#60A5FA',true),fontSize:11}}>⬇ PNG</button>
|
||||
{canEdit&&<button onClick={save} disabled={saving} style={{...S.btn('#4ECDC4',true),fontSize:11}}>{saving?'…':'💾 Speichern'}</button>}
|
||||
{current?.role==='owner'&&(
|
||||
<button onClick={()=>setShareModal(true)} style={{...S.btn('#A78BFA',true),fontSize:11}}>👥 Teilen</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Toolbar ──────────────────────────────────────────────────────── */}
|
||||
{canEdit&&(
|
||||
<div style={{display:'flex',alignItems:'center',gap:5,padding:'5px 10px',
|
||||
background:'rgba(0,0,0,0.45)',borderBottom:'1px solid rgba(255,255,255,0.06)',
|
||||
flexShrink:0,flexWrap:'wrap'}}>
|
||||
|
||||
{/* Tools */}
|
||||
<div style={{display:'flex',gap:3}}>
|
||||
{TOOLS.map(t=>(
|
||||
<button key={t.id} onClick={()=>{
|
||||
setTool(t.id); setSelectedId(null); selectedRef.current=null;
|
||||
textPreviewRef.current=null; setTextInput(null); render();
|
||||
}} title={t.tip}
|
||||
style={{background:tool===t.id?'rgba(78,205,196,0.2)':'rgba(255,255,255,0.05)',
|
||||
border:`1px solid ${tool===t.id?'#4ECDC4':'rgba(255,255,255,0.1)'}`,
|
||||
borderRadius:7,padding:'5px 9px',color:tool===t.id?'#4ECDC4':'rgba(255,255,255,0.7)',
|
||||
cursor:'pointer',fontSize:14,lineHeight:1,fontFamily:'monospace'}}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{width:1,height:22,background:'rgba(255,255,255,0.1)'}}/>
|
||||
|
||||
{/* Farben */}
|
||||
<div style={{display:'flex',gap:3}}>
|
||||
{COLORS.map(c=>(
|
||||
<button key={c} onClick={()=>setColor(c)}
|
||||
style={{width:18,height:18,borderRadius:'50%',background:c,
|
||||
border:color===c?'2px solid #fff':'1px solid rgba(255,255,255,0.2)',cursor:'pointer',padding:0,flexShrink:0}}/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{width:1,height:22,background:'rgba(255,255,255,0.1)'}}/>
|
||||
|
||||
{/* Strichstärke */}
|
||||
<div style={{display:'flex',gap:3,alignItems:'center'}}>
|
||||
{SIZES.map((s,i)=>(
|
||||
<button key={i} onClick={()=>setSize(i)}
|
||||
style={{width:18,height:18,borderRadius:'50%',
|
||||
background:size===i?'rgba(78,205,196,0.2)':'rgba(255,255,255,0.05)',
|
||||
border:`1px solid ${size===i?'#4ECDC4':'rgba(255,255,255,0.15)'}`,
|
||||
cursor:'pointer',padding:0,display:'flex',alignItems:'center',justifyContent:'center'}}>
|
||||
<div style={{width:s+2,height:s+2,borderRadius:'50%',background:size===i?'#4ECDC4':'#888'}}/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Textgröße — nur bei Text-Tool */}
|
||||
{tool==='text'&&<>
|
||||
<div style={{width:1,height:22,background:'rgba(255,255,255,0.1)'}}/>
|
||||
<span style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:9}}>TEXT</span>
|
||||
<div style={{display:'flex',gap:3,alignItems:'center'}}>
|
||||
{FONT_SIZES.map((fs,i)=>(
|
||||
<button key={i} onClick={()=>setFontSize(i)}
|
||||
style={{padding:'2px 6px',borderRadius:6,fontFamily:'monospace',fontSize:Math.min(14,fs/2)+1,
|
||||
background:fontSize===i?'rgba(78,205,196,0.2)':'rgba(255,255,255,0.05)',
|
||||
border:`1px solid ${fontSize===i?'#4ECDC4':'rgba(255,255,255,0.15)'}`,
|
||||
color:fontSize===i?'#4ECDC4':'rgba(255,255,255,0.6)',cursor:'pointer'}}>
|
||||
{fs}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>}
|
||||
|
||||
<div style={{width:1,height:22,background:'rgba(255,255,255,0.1)'}}/>
|
||||
|
||||
{/* Undo + Löschen */}
|
||||
<button onClick={undo} disabled={!undoStack.length} title="Rückgängig (Ctrl+Z)"
|
||||
style={{background:'rgba(255,255,255,0.05)',border:'1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius:7,padding:'5px 9px',color:undoStack.length?'rgba(255,255,255,0.7)':'rgba(255,255,255,0.2)',
|
||||
cursor:undoStack.length?'pointer':'default',fontSize:12,fontFamily:'monospace'}}>↩</button>
|
||||
|
||||
{selectedId&&(
|
||||
<button onClick={()=>{
|
||||
const next=elementsRef.current.filter(el=>el.id!==selectedId);
|
||||
setUndoStack(prev=>[...prev,elementsRef.current]);
|
||||
elementsRef.current=next; setElements([...next]); broadcast(next);
|
||||
setSelectedId(null); selectedRef.current=null;
|
||||
}} style={{background:'rgba(255,107,157,0.08)',border:'1px solid rgba(255,107,157,0.25)',
|
||||
borderRadius:7,padding:'5px 9px',color:'#ff6b9d',cursor:'pointer',fontSize:12,fontFamily:'monospace'}}>
|
||||
✕ Löschen
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button onClick={()=>{if(confirm('Alles löschen?')){
|
||||
elementsRef.current=[];setElements([]);setUndoStack([]);broadcast([]);
|
||||
setSelectedId(null);selectedRef.current=null;
|
||||
}}} style={{background:'rgba(255,107,157,0.05)',border:'1px solid rgba(255,107,157,0.15)',
|
||||
borderRadius:7,padding:'5px 9px',color:'rgba(255,107,157,0.5)',cursor:'pointer',fontSize:12,fontFamily:'monospace'}}>
|
||||
🗑
|
||||
</button>
|
||||
|
||||
<div style={{width:1,height:22,background:'rgba(255,255,255,0.1)'}}/>
|
||||
|
||||
{/* Zoom */}
|
||||
<button onClick={()=>zoom(1.2)} style={{background:'rgba(255,255,255,0.05)',border:'1px solid rgba(255,255,255,0.1)',borderRadius:7,padding:'5px 9px',color:'rgba(255,255,255,0.7)',cursor:'pointer',fontSize:14,fontFamily:'monospace'}}>+</button>
|
||||
<span style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:10,minWidth:32,textAlign:'center'}}>
|
||||
{Math.round(viewport.zoom*100)}%
|
||||
</span>
|
||||
<button onClick={()=>zoom(0.8)} style={{background:'rgba(255,255,255,0.05)',border:'1px solid rgba(255,255,255,0.1)',borderRadius:7,padding:'5px 9px',color:'rgba(255,255,255,0.7)',cursor:'pointer',fontSize:14,fontFamily:'monospace'}}>-</button>
|
||||
<button onClick={()=>syncVp({x:0,y:0,zoom:1})}
|
||||
style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.25)',cursor:'pointer',fontFamily:'monospace',fontSize:9}}>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Canvas ───────────────────────────────────────────────────────── */}
|
||||
<canvas ref={canvasRef}
|
||||
onPointerDown={onPointerDown} onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp} onPointerLeave={onPointerUp} onWheel={onWheel}
|
||||
style={{flex:1,width:'100%',touchAction:'none',cursor:getCursor()}}
|
||||
/>
|
||||
|
||||
{/* Hinweis: Select-Tool Aktionen */}
|
||||
{tool==='select'&&selectedId&&!mobile&&(
|
||||
<div style={{position:'absolute',bottom:12,left:'50%',transform:'translateX(-50%)',
|
||||
background:'rgba(0,0,0,0.7)',border:'1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius:8,padding:'5px 12px',fontFamily:'monospace',fontSize:10,color:'rgba(255,255,255,0.4)',
|
||||
pointerEvents:'none',whiteSpace:'nowrap'}}>
|
||||
Ziehen: verschieben · Ecke (◉) ziehen: skalieren · Entf: löschen
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,21 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { readFileSync } from 'fs'
|
||||
|
||||
// version.txt wird im Dockerfile VOR dem Build generiert (date +%s)
|
||||
// Damit haben __BUILD_TIME__ im Bundle und /api/build-time exakt denselben Wert
|
||||
let buildTime;
|
||||
try {
|
||||
buildTime = readFileSync('./version.txt', 'utf8').trim();
|
||||
} catch {
|
||||
// Fallback für lokale Entwicklung ohne Dockerfile
|
||||
buildTime = String(Date.now());
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: { proxy: { '/api': 'http://localhost:4000' } },
|
||||
define: {
|
||||
__BUILD_TIME__: JSON.stringify(buildTime),
|
||||
},
|
||||
})
|
||||
|
||||