diff --git a/assets/css/app.css b/assets/css/app.css index b553c4fe..6df4f864 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -1,5 +1,6 @@ @layer tailwind-base, primereact, tailwind-utilities; +@import 'quill/dist/quill.snow.css'; @import 'primereact/resources/themes/arya-blue/theme.css' layer(primereact); /*@import 'primereact/resources/themes/bootstrap4-dark-blue/theme.css' layer(primereact);*/ @@ -1025,3 +1026,77 @@ body > div:first-of-type { transform: translateY(0); box-shadow: 0 2px 10px rgba(236, 72, 153, 0.3); } + +/* Quill editor dark theme overrides */ +.ql-toolbar.ql-snow { + border-color: #44403c; + background-color: #1c1917; +} + +.ql-container.ql-snow { + border-color: #44403c; + background-color: #0c0a09; + color: #e7e5e4; + min-height: 200px; +} + +.ql-editor.ql-blank::before { + color: #78716c; + font-style: italic; +} + +.ql-snow .ql-stroke { + stroke: #a8a29e; +} + +.ql-snow .ql-fill, +.ql-snow .ql-stroke.ql-fill { + fill: #a8a29e; +} + +.ql-snow .ql-picker { + color: #a8a29e; +} + +.ql-snow .ql-picker-options { + background-color: #1c1917; + border-color: #44403c; +} + +.ql-snow .ql-picker-label:hover, +.ql-snow .ql-picker-label.ql-active, +.ql-snow .ql-picker-item:hover, +.ql-snow .ql-picker-item.ql-selected { + color: #e7e5e4; +} + +.ql-snow .ql-picker-label:hover .ql-stroke, +.ql-snow .ql-picker-label.ql-active .ql-stroke, +.ql-snow button:hover .ql-stroke, +.ql-snow button.ql-active .ql-stroke { + stroke: #e7e5e4; +} + +.ql-snow .ql-picker-label:hover .ql-fill, +.ql-snow .ql-picker-label.ql-active .ql-fill, +.ql-snow button:hover .ql-fill, +.ql-snow button.ql-active .ql-fill { + fill: #e7e5e4; +} + +.ql-snow a { + color: #60a5fa; +} + +.ql-tooltip { + background-color: #1c1917 !important; + border-color: #44403c !important; + color: #e7e5e4 !important; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3) !important; +} + +.ql-tooltip input[type="text"] { + background-color: #0c0a09; + border-color: #44403c; + color: #e7e5e4; +} diff --git a/assets/js/hooks/index.ts b/assets/js/hooks/index.ts index e0781201..93e7863f 100755 --- a/assets/js/hooks/index.ts +++ b/assets/js/hooks/index.ts @@ -9,6 +9,7 @@ import DownloadJson from './downloadJson'; import NewVersionUpdate from './newVersionUpdate'; import MapAction from './maps/mapAction'; import ShowCharactersAddAlert from './showCharactersAddAlert'; +import WysiwygEditor from './wysiwygEditor'; export default { DownloadJson, @@ -22,4 +23,5 @@ export default { CopyToClipboard, NewVersionUpdate, ShowCharactersAddAlert, + WysiwygEditor, }; diff --git a/assets/js/hooks/wysiwygEditor.ts b/assets/js/hooks/wysiwygEditor.ts new file mode 100644 index 00000000..0ff189eb --- /dev/null +++ b/assets/js/hooks/wysiwygEditor.ts @@ -0,0 +1,46 @@ +import Quill from "quill"; +import TurndownService from "turndown"; + +const WysiwygEditor = { + mounted() { + const view = this as any; + const editorContainer = view.el.querySelector(".ql-editor-container"); + if (!editorContainer) return; + + const toolbarOptions = [ + ["bold", "italic", "underline", "strike"], + ["blockquote", "link"], + [{ list: "ordered" }, { list: "bullet" }], + [{ header: [1, 2, 3, false] }], + ["clean"], + ]; + + const quill = new Quill(editorContainer, { + theme: "snow", + modules: { toolbar: toolbarOptions }, + }); + + const initialContent = editorContainer.getAttribute("data-initial-content"); + if (initialContent) { + quill.clipboard.dangerouslyPasteHTML(initialContent); + } + + quill.on("text-change", () => { + view.pushEvent("content-text-change", { content: quill.getText() }); + }); + + view.handleEvent("request_editor_content", () => { + const html = quill.root.innerHTML; + + if (quill.getText().trim() === "") { + view.pushEvent("editor_content_markdown", { markdown: "" }); + } else { + const turndownService = new TurndownService(); + const markdown = turndownService.turndown(html); + view.pushEvent("editor_content_markdown", { markdown: markdown }); + } + }); + }, +}; + +export default WysiwygEditor; diff --git a/assets/package.json b/assets/package.json index 98386a5c..259c9a24 100644 --- a/assets/package.json +++ b/assets/package.json @@ -43,6 +43,8 @@ "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "tailwindcss": "^3.3.6", + "quill": "^2.0.3", + "turndown": "^7.2.0", "topbar": "^3.0.0", "use-local-storage-state": "^19.3.1" }, diff --git a/assets/static/images/news/2026/01-05-weekly-giveaway/cover.webp b/assets/static/images/news/2026/01-05-weekly-giveaway/cover.webp deleted file mode 100644 index 7bd653e5..00000000 Binary files a/assets/static/images/news/2026/01-05-weekly-giveaway/cover.webp and /dev/null differ diff --git a/assets/static/images/news/2026/02-12-eve-creator-awards/cover.jpg b/assets/static/images/news/2026/02-12-eve-creator-awards/cover.jpg deleted file mode 100644 index cffd0ca8..00000000 Binary files a/assets/static/images/news/2026/02-12-eve-creator-awards/cover.jpg and /dev/null differ diff --git a/assets/yarn.lock b/assets/yarn.lock index fd94574b..204205a1 100644 --- a/assets/yarn.lock +++ b/assets/yarn.lock @@ -922,6 +922,11 @@ resolved "https://registry.yarnpkg.com/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz#775374306116d51c0c500b8c4face0f9a04752d8" integrity sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g== +"@mixmark-io/domino@^2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@mixmark-io/domino/-/domino-2.2.0.tgz#4e8ec69bf1afeb7a14f0628b7e2c0f35bdb336c3" + integrity sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw== + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" @@ -3000,6 +3005,11 @@ esutils@^2.0.2: resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== +eventemitter3@^5.0.1: + version "5.0.4" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.4.tgz#a86d66170433712dde814707ac52b5271ceb1feb" + integrity sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw== + execa@^5.0.0: version "5.1.1" resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd" @@ -3041,7 +3051,7 @@ fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== -fast-diff@^1.1.2: +fast-diff@^1.1.2, fast-diff@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.3.0.tgz#ece407fa550a64d638536cd727e129c61616e0f0" integrity sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw== @@ -4321,11 +4331,21 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" +lodash-es@^4.17.21: + version "4.18.1" + resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.18.1.tgz#b962eeb80d9d983a900bf342961fb7418ca10b1d" + integrity sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A== + lodash.castarray@^4.4.0: version "4.4.0" resolved "https://registry.yarnpkg.com/lodash.castarray/-/lodash.castarray-4.4.0.tgz#c02513515e309daddd4c24c60cfddcf5976d9115" integrity sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q== +lodash.clonedeep@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" + integrity sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ== + lodash.debounce@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" @@ -5146,6 +5166,11 @@ package-json-from-dist@^1.0.0: resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== +parchment@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/parchment/-/parchment-3.0.0.tgz#2e3a4ada454e1206ae76ea7afcb50e9fb517e7d6" + integrity sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A== + parent-module@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" @@ -5451,6 +5476,25 @@ queue-microtask@^1.2.2: resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== +quill-delta@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/quill-delta/-/quill-delta-5.1.0.tgz#1c4bc08f7c8e5cc4bdc88a15a1a70c1cc72d2b48" + integrity sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA== + dependencies: + fast-diff "^1.3.0" + lodash.clonedeep "^4.5.0" + lodash.isequal "^4.5.0" + +quill@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/quill/-/quill-2.0.3.tgz#752765a31d5a535cdc5717dc49d4e50099365eb1" + integrity sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw== + dependencies: + eventemitter3 "^5.0.1" + lodash-es "^4.17.21" + parchment "^3.0.0" + quill-delta "^5.1.0" + react-dom@18.3.1, react-dom@^18.3.1: version "18.3.1" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.3.1.tgz#c2265d79511b57d479b3dd3fdfa51536494c5cb4" @@ -5987,16 +6031,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -6081,14 +6116,7 @@ stringify-entities@^4.0.0: character-entities-html4 "^2.0.0" character-entities-legacy "^3.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -6296,6 +6324,13 @@ ts-jest@^29.1.2: type-fest "^4.41.0" yargs-parser "^21.1.1" +turndown@^7.2.0: + version "7.2.4" + resolved "https://registry.yarnpkg.com/turndown/-/turndown-7.2.4.tgz#42d98202aefa8c188c997b586bc6da78bdf27ea2" + integrity sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ== + dependencies: + "@mixmark-io/domino" "^2.2.0" + type-check@^0.4.0, type-check@~0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" @@ -6618,16 +6653,7 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - -wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== diff --git a/lib/wanderer_app/api/character.ex b/lib/wanderer_app/api/character.ex index 239b753f..03b6064d 100644 --- a/lib/wanderer_app/api/character.ex +++ b/lib/wanderer_app/api/character.ex @@ -41,6 +41,7 @@ defmodule WandererApp.Api.Character do ) define(:admin_all, action: :admin_all) + define(:update_description, action: :update_description) end actions do @@ -141,6 +142,11 @@ defmodule WandererApp.Api.Character do accept([:eve_wallet_balance]) end + + update :update_description do + accept([:description]) + require_atomic? false + end end cloak do @@ -211,6 +217,11 @@ defmodule WandererApp.Api.Character do attribute :eve_wallet_balance, :float attribute :tracking_pool, :string + attribute :description, :string do + allow_nil? true + constraints max_length: 10_000 + end + create_timestamp(:inserted_at) update_timestamp(:updated_at) end diff --git a/lib/wanderer_app/api/map_transaction.ex b/lib/wanderer_app/api/map_transaction.ex index ca6928f1..1cb96884 100644 --- a/lib/wanderer_app/api/map_transaction.ex +++ b/lib/wanderer_app/api/map_transaction.ex @@ -22,6 +22,7 @@ defmodule WandererApp.Api.MapTransaction do define(:by_user, action: :by_user) define(:create, action: :create) define(:top_donators, action: :top_donators) + define(:server_top_donators, action: :server_top_donators) end actions do @@ -77,6 +78,31 @@ defmodule WandererApp.Api.MapTransaction do |> then(&{:ok, &1}) end end + + action :server_top_donators, {:array, :struct} do + argument(:after, :utc_datetime, allow_nil?: true) + + run fn input, _context -> + base = + from(t in __MODULE__, + where: t.type == :in and not is_nil(t.user_id), + group_by: [t.user_id], + select: %{user_id: t.user_id, total_amount: sum(t.amount)}, + order_by: [desc: sum(t.amount)], + limit: 10 + ) + + query = + case input.arguments[:after] do + nil -> base + after_date -> base |> where([t], t.inserted_at >= ^after_date) + end + + query + |> WandererApp.Repo.all() + |> then(&{:ok, &1}) + end + end end attributes do diff --git a/lib/wanderer_app/esi/api_client.ex b/lib/wanderer_app/esi/api_client.ex index 32ce7d64..58ab7c4c 100644 --- a/lib/wanderer_app/esi/api_client.ex +++ b/lib/wanderer_app/esi/api_client.ex @@ -804,7 +804,8 @@ defmodule WandererApp.Esi.ApiClient do }) if count >= 3 do - Logger.warning("TOKEN_REFRESH_FAILED: Invalid grant error (#{count}/3, invalidating tokens)", + Logger.warning( + "TOKEN_REFRESH_FAILED: Invalid grant error (#{count}/3, invalidating tokens)", character_id: character_id, error_message: error_message, time_since_expiry_seconds: time_since_expiry, @@ -861,7 +862,8 @@ defmodule WandererApp.Esi.ApiClient do expires_at, _scopes ) do - time_since_expiry = DateTime.diff(DateTime.utc_now(), DateTime.from_unix!(expires_at), :second) + time_since_expiry = + DateTime.diff(DateTime.utc_now(), DateTime.from_unix!(expires_at), :second) Logger.warning("TOKEN_REFRESH_FAILED: Transient OAuth2 error during token refresh", character_id: character_id, @@ -879,7 +881,8 @@ defmodule WandererApp.Esi.ApiClient do end defp handle_refresh_token_result(error, _character, character_id, expires_at, _scopes) do - time_since_expiry = DateTime.diff(DateTime.utc_now(), DateTime.from_unix!(expires_at), :second) + time_since_expiry = + DateTime.diff(DateTime.utc_now(), DateTime.from_unix!(expires_at), :second) Logger.warning("TOKEN_REFRESH_FAILED: Unexpected error during token refresh", character_id: character_id, @@ -910,7 +913,12 @@ defmodule WandererApp.Esi.ApiClient do {:ok, current_character} -> # Only invalidate if tokens haven't been refreshed since we started if current_character.access_token == character.access_token do - attrs = %{access_token: nil, refresh_token: nil, expires_at: expires_at, scopes: scopes} + attrs = %{ + access_token: nil, + refresh_token: nil, + expires_at: expires_at, + scopes: scopes + } with {:ok, _} <- WandererApp.Api.Character.update(current_character, attrs) do WandererApp.Character.update_character(character_id, attrs) diff --git a/lib/wanderer_app/map/server/map_server_characters_impl.ex b/lib/wanderer_app/map/server/map_server_characters_impl.ex index 3e8fd757..2413f581 100644 --- a/lib/wanderer_app/map/server/map_server_characters_impl.ex +++ b/lib/wanderer_app/map/server/map_server_characters_impl.ex @@ -336,7 +336,11 @@ defmodule WandererApp.Map.Server.CharactersImpl do "[CharacterCleanup] Map #{map_id} - untracking settings and removing character #{s.character_id}" end) - WandererApp.MapCharacterSettingsRepo.untrack!(%{map_id: s.map_id, character_id: s.character_id}) + WandererApp.MapCharacterSettingsRepo.untrack!(%{ + map_id: s.map_id, + character_id: s.character_id + }) + remove_character(map_id, s.character_id) end) diff --git a/lib/wanderer_app/map/server/map_server_connections_impl.ex b/lib/wanderer_app/map/server/map_server_connections_impl.ex index 324179ab..80ce2540 100644 --- a/lib/wanderer_app/map/server/map_server_connections_impl.ex +++ b/lib/wanderer_app/map/server/map_server_connections_impl.ex @@ -278,8 +278,7 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do ), do: update_connection(map_id, :update_mass_status, [:mass_status], connection_update, fn - %{mass_status: old_mass_status}, - %{mass_status: mass_status} = updated_connection -> + %{mass_status: old_mass_status}, %{mass_status: mass_status} = updated_connection -> if mass_status != old_mass_status do maybe_update_linked_signature_mass_status(map_id, updated_connection) end diff --git a/lib/wanderer_app_web.ex b/lib/wanderer_app_web.ex index 084b5be6..858b4ff4 100644 --- a/lib/wanderer_app_web.ex +++ b/lib/wanderer_app_web.ex @@ -68,6 +68,13 @@ defmodule WandererAppWeb do end end + def blog_live_view do + live_view( + layout: {WandererAppWeb.Layouts, :blog}, + container: {:div, class: ""} + ) + end + def live_component do quote do use Phoenix.LiveComponent diff --git a/lib/wanderer_app_web/components/layouts.ex b/lib/wanderer_app_web/components/layouts.ex index c8dd2199..7de56f13 100644 --- a/lib/wanderer_app_web/components/layouts.ex +++ b/lib/wanderer_app_web/components/layouts.ex @@ -206,6 +206,7 @@ defmodule WandererAppWeb.Layouts do >
  • Changelog
  • News
  • +
  • Sponsors
  • License
  • Contact Us
  • @@ -236,6 +237,13 @@ defmodule WandererAppWeb.Layouts do icon="hero-signal-solid" tip="Characters Tracking" /> + <.nav_link + :if={@map_subscriptions_enabled} + href="/sponsors" + active={@active_tab == :sponsors} + icon="hero-heart-solid" + tip="Our Sponsors" + />
    diff --git a/lib/wanderer_app_web/controllers/route_builder_controller.ex b/lib/wanderer_app_web/controllers/route_builder_controller.ex index 9ffc3c10..607ee0b0 100644 --- a/lib/wanderer_app_web/controllers/route_builder_controller.ex +++ b/lib/wanderer_app_web/controllers/route_builder_controller.ex @@ -19,6 +19,7 @@ defmodule WandererAppWeb.RouteBuilderController do {:error, reason} -> Logger.warning("[RouteBuilderController] find_closest failed: #{inspect(reason)}") + conn |> put_status(:bad_gateway) |> json(%{error: "route_builder_failed"}) diff --git a/lib/wanderer_app_web/live/character_profile/character_profile_live.ex b/lib/wanderer_app_web/live/character_profile/character_profile_live.ex new file mode 100644 index 00000000..837ca470 --- /dev/null +++ b/lib/wanderer_app_web/live/character_profile/character_profile_live.ex @@ -0,0 +1,139 @@ +defmodule WandererAppWeb.CharacterProfileLive do + use WandererAppWeb, :live_view + + require Logger + + @impl true + def mount(%{"eve_id" => eve_id_str}, _session, socket) do + case Integer.parse(eve_id_str) do + {eve_id, ""} -> + case load_character(eve_id) do + {:ok, character} -> + is_owner = owner?(socket.assigns.current_user, eve_id_str) + description_html = render_description(character.description) + + {:ok, + assign(socket, + page_title: character.name, + profile: build_profile(character), + is_owner: is_owner, + editing: false, + description_html: description_html, + description_raw: character.description || "" + )} + + {:error, _} -> + {:ok, + socket + |> put_flash(:error, "Character not found") + |> redirect(to: "/")} + end + + _ -> + {:ok, + socket + |> put_flash(:error, "Invalid character ID") + |> redirect(to: "/")} + end + end + + @impl true + def handle_event("toggle_edit", _params, socket) do + if socket.assigns.is_owner do + {:noreply, assign(socket, editing: !socket.assigns.editing)} + else + {:noreply, socket} + end + end + + def handle_event("save_description", _params, socket) do + if socket.assigns.is_owner do + {:noreply, push_event(socket, "request_editor_content", %{})} + else + {:noreply, socket} + end + end + + def handle_event("cancel_edit", _params, socket) do + {:noreply, assign(socket, editing: false)} + end + + def handle_event("content-text-change", _params, socket) do + {:noreply, socket} + end + + def handle_event("editor_content_markdown", %{"markdown" => markdown}, socket) do + if socket.assigns.is_owner do + markdown = String.slice(markdown, 0, 10_000) + eve_id_str = socket.assigns.profile.eve_id + + case WandererApp.Api.Character.by_eve_id(eve_id_str) do + {:ok, character} -> + case WandererApp.Api.Character.update_description(character, %{description: markdown}) do + {:ok, _updated} -> + Cachex.del(:api_cache, "character_profile_#{eve_id_str}") + description_html = render_description(markdown) + + {:noreply, + socket + |> assign( + editing: false, + description_html: description_html, + description_raw: markdown + ) + |> put_flash(:info, "Description updated")} + + {:error, reason} -> + Logger.error("Failed to update description: #{inspect(reason)}") + {:noreply, put_flash(socket, :error, "Failed to save description")} + end + + {:error, _} -> + {:noreply, put_flash(socket, :error, "Character not found")} + end + else + {:noreply, socket} + end + end + + defp owner?(current_user, eve_id_str) do + case current_user do + %{characters: characters} when is_list(characters) -> + Enum.any?(characters, fn c -> to_string(c.eve_id) == eve_id_str end) + + _ -> + false + end + end + + defp load_character(eve_id) do + WandererApp.Api.Character.by_eve_id(eve_id) + end + + defp build_profile(character) do + %{ + eve_id: character.eve_id, + name: character.name, + corporation_id: character.corporation_id, + corporation_name: character.corporation_name, + corporation_ticker: character.corporation_ticker, + alliance_id: character.alliance_id, + alliance_name: character.alliance_name, + alliance_ticker: character.alliance_ticker, + online: character.online + } + end + + defp render_description(nil), do: "" + defp render_description(""), do: "" + + defp render_description(markdown) do + case Earmark.as_html(markdown) do + {:ok, html, _} -> + HtmlSanitizeEx.markdown_html(html) + + {:error, _, _} -> + "" + end + end +end diff --git a/lib/wanderer_app_web/live/character_profile/character_profile_live.html.heex b/lib/wanderer_app_web/live/character_profile/character_profile_live.html.heex new file mode 100644 index 00000000..fe954bb6 --- /dev/null +++ b/lib/wanderer_app_web/live/character_profile/character_profile_live.html.heex @@ -0,0 +1,133 @@ +
    +
    +
    +
    + {@profile.name} +
    +
    +

    {@profile.name}

    + + +
    +
    + {@profile.corporation_name} + + + [{@profile.corporation_ticker}] + + {@profile.corporation_name} + +
    +
    + {@profile.alliance_name} + + + [{@profile.alliance_ticker}] + + {@profile.alliance_name} + +
    +
    + +
    + + <%!-- About / Description section --%> +
    +
    +

    About

    + +
    + + <%!-- View mode --%> +
    + <%= if @description_html != "" do %> +
    + {raw(@description_html)} +
    + <% else %> +

    No description yet.

    + <% end %> +
    + + <%!-- Edit mode --%> +
    +
    +
    +
    +
    + <.button + type="button" + phx-click="save_description" + class="p-button p-component p-button-primary w-full" + style="min-width: 0;" + > + Save + + <.button + type="button" + phx-click="cancel_edit" + class="p-button p-component p-button-primary w-full" + style="min-width: 0;" + > + Cancel + +
    +
    +
    +
    +
    +
    diff --git a/lib/wanderer_app_web/live/characters/characters_live.html.heex b/lib/wanderer_app_web/live/characters/characters_live.html.heex index 288d8757..2e6efec7 100644 --- a/lib/wanderer_app_web/live/characters/characters_live.html.heex +++ b/lib/wanderer_app_web/live/characters/characters_live.html.heex @@ -197,6 +197,13 @@
    + <.link + navigate={~p"/characters/#{character.eve_id}"} + class="tooltip tooltip-bottom" + data-tip="View Profile" + > + <.icon name="hero-user-solid" class="w-4 h-4 hover:text-white" /> + <.link patch={~p"/characters/authorize"} class="tooltip tooltip-bottom" @@ -236,7 +243,9 @@ <:col :let={character} label="Name"> - {character.name} + <.link navigate={~p"/characters/#{character.eve_id}"} class="hover:text-white underline"> + {character.name} + <:col :let={character} label="Corporation"> {character diff --git a/lib/wanderer_app_web/live/map/event_handlers/map_core_event_handler.ex b/lib/wanderer_app_web/live/map/event_handlers/map_core_event_handler.ex index 0efdf281..8b25a660 100644 --- a/lib/wanderer_app_web/live/map/event_handlers/map_core_event_handler.ex +++ b/lib/wanderer_app_web/live/map/event_handlers/map_core_event_handler.ex @@ -614,7 +614,8 @@ defmodule WandererAppWeb.MapCoreEventHandler do nil end - expired_characters = tracked_characters |> Enum.filter(&(&1.access_token == nil)) |> Enum.map(& &1.eve_id) + expired_characters = + tracked_characters |> Enum.filter(&(&1.access_token == nil)) |> Enum.map(& &1.eve_id) initial_data = %{ diff --git a/lib/wanderer_app_web/live/map/event_handlers/map_routes_event_handler.ex b/lib/wanderer_app_web/live/map/event_handlers/map_routes_event_handler.ex index 6976c0ac..6cc24ea7 100644 --- a/lib/wanderer_app_web/live/map/event_handlers/map_routes_event_handler.ex +++ b/lib/wanderer_app_web/live/map/event_handlers/map_routes_event_handler.ex @@ -176,12 +176,14 @@ defmodule WandererAppWeb.MapRoutesEventHandler do routes_type = Map.get(event, "type", "blueLoot") security_type = Map.get(event, "securityType", "both") is_subscription_active? = Map.get(socket.assigns, :is_subscription_active?, false) + routes_limit = if is_subscription_active? == true do @paid_routes_limit else Map.get(@alpha_routes_limit_by_type, routes_type, @default_alpha_routes_limit) end + routes_settings = routes_settings |> get_routes_settings() diff --git a/lib/wanderer_app_web/live/nav.ex b/lib/wanderer_app_web/live/nav.ex index a1a28ed5..aec602dc 100755 --- a/lib/wanderer_app_web/live/nav.ex +++ b/lib/wanderer_app_web/live/nav.ex @@ -6,6 +6,7 @@ defmodule WandererAppWeb.Nav do alias WandererAppWeb.{ AccessListsLive, + CharacterProfileLive, MapLive, MapsLive, CharactersLive, @@ -64,6 +65,9 @@ defmodule WandererAppWeb.Nav do {CharactersLive, _} -> :characters + {CharacterProfileLive, _} -> + :characters + {CharactersTrackingLive, _} -> :characters_tracking diff --git a/lib/wanderer_app_web/live/sponsors/sponsors_live.ex b/lib/wanderer_app_web/live/sponsors/sponsors_live.ex new file mode 100644 index 00000000..ebd454d4 --- /dev/null +++ b/lib/wanderer_app_web/live/sponsors/sponsors_live.ex @@ -0,0 +1,93 @@ +defmodule WandererAppWeb.SponsorsLive do + use WandererAppWeb, :live_view + + alias BetterNumber, as: Number + + require Logger + + @cache_key "server_top_donators" + @cache_ttl :timer.minutes(15) + + @impl true + def mount(_params, _session, socket) do + if not WandererApp.Env.map_subscriptions_enabled?() do + {:ok, socket |> redirect(to: "/")} + else + top_donators = load_top_donators() + {corporation_id, corporation_info} = load_corporation_info() + + {:ok, + assign(socket, + page_title: "Sponsors", + top_donators: top_donators, + corporation_id: corporation_id, + corporation_info: corporation_info + )} + end + end + + def format_isk(amount) do + Number.to_human(amount, units: ["", "K", "M", "B", "T", "P"]) + end + + defp load_top_donators do + case Cachex.get(:api_cache, @cache_key) do + {:ok, nil} -> + donators = fetch_and_enrich() + Cachex.put(:api_cache, @cache_key, donators, ttl: @cache_ttl) + donators + + {:ok, cached} -> + cached + + _ -> + fetch_and_enrich() + end + end + + defp fetch_and_enrich do + after_date = DateTime.utc_now() |> DateTime.add(-30, :day) + + case WandererApp.Api.MapTransaction.server_top_donators(%{after: after_date}) do + {:ok, donators} -> + enrich_with_characters(donators) + + {:error, reason} -> + Logger.warning("Failed to load server top donators: #{inspect(reason)}") + [] + end + end + + defp enrich_with_characters(donators) do + donators + |> Enum.map(fn %{user_id: user_id, total_amount: total_amount} -> + case WandererApp.Api.Character.active_by_user(%{user_id: user_id}) do + {:ok, [character | _]} -> + %{ + character_name: character.name, + eve_id: character.eve_id, + corporation_name: character.corporation_name, + corporation_ticker: character.corporation_ticker, + total_amount: total_amount + } + + _ -> + nil + end + end) + |> Enum.reject(&is_nil/1) + end + + defp load_corporation_info do + corp_eve_id = WandererApp.Env.corp_eve_id() + + if corp_eve_id == -1 do + {nil, nil} + else + case WandererApp.Esi.get_corporation_info(corp_eve_id) do + {:ok, info} -> {corp_eve_id, info} + _ -> {nil, nil} + end + end + end +end diff --git a/lib/wanderer_app_web/live/sponsors/sponsors_live.html.heex b/lib/wanderer_app_web/live/sponsors/sponsors_live.html.heex new file mode 100644 index 00000000..74ae3fbe --- /dev/null +++ b/lib/wanderer_app_web/live/sponsors/sponsors_live.html.heex @@ -0,0 +1,119 @@ +
    +
    +

    + Our Sponsors +

    +

    + Top 10 ISK donators across all maps in the last 30 days. +

    + +
    +
    + No donations found for this period. +
    + +
    +
    + + {index + 1} + + {donator.character_name} +
    + <.link + navigate={~p"/characters/#{donator.eve_id}"} + class="text-sm font-bold text-white truncate hover:text-blue-400 transition-colors" + > + {donator.character_name} + +
    + + [{donator.corporation_ticker}] + {donator.corporation_name} + +
    +
    +
    + ISK {WandererAppWeb.SponsorsLive.format_isk(donator.total_amount)} +
    +
    +
    + +
    + <.button + type="button" + phx-click={show_modal("donate-modal")} + class="p-button p-component p-button-primary w-full" + style="min-width: 0;" + > + Become a Sponsor + +
    +
    +
    + + <.modal id="donate-modal" title="How to become a sponsor?" class="!w-[700px]"> +
    + It's not available yet :( +
    +
    +
    +
    + Wanderer EVE Corporation: +
    +
    +
    + {@corporation_info["name"]} +
    +
    +   {@corporation_info["name"]} +
    +
    +

    + How to Donate ISK to Wanderer in Eve Online +

    +
      +
    1. + Open corporations overview: + Click on the 'Social' and then on 'Corporation' in the Neocom menu to access corporations search. +
    2. +
    3. + Search for a Corporation: + Type in the search bar the name: {@corporation_info["name"]}. +
    4. +
    5. + Choose 'Give Money': + Select the 'Give Money' in the context menu to initiate the transfer. +
    6. +
    7. + Specify the Amount: + Input the amount of ISK you wish to transfer to the corporate account. +
    8. +
    9. + Add a Reason (Optional): + Include a short note or reason for the transfer if desired. +
    10. +
    11. + Confirm the Transfer: + Double-check the recipient's name and the amount, then click 'OK' to complete the transaction. +
    12. +
    +

    + The ISK will be transferred instantly to the Wanderer's account. Ensure you enter the correct recipient name to avoid any errors. Fly safe and enjoy your time in Eve Online! +

    +
    +
    + +
    diff --git a/lib/wanderer_app_web/router.ex b/lib/wanderer_app_web/router.ex index 919c0d84..4eab48b9 100644 --- a/lib/wanderer_app_web/router.ex +++ b/lib/wanderer_app_web/router.ex @@ -7,7 +7,7 @@ defmodule WandererAppWeb.Router do import WandererAppWeb.UserAuth, warn: false, - only: [redirect_if_user_is_authenticated: 2] + only: [redirect_if_user_is_authenticated: 2, require_authenticated_user: 2] import WandererAppWeb.BasicAuth, warn: false, @@ -164,6 +164,10 @@ defmodule WandererAppWeb.Router do plug :put_layout, html: {WandererAppWeb.Layouts, :blog} end + pipeline :require_auth do + plug :require_authenticated_user + end + pipeline :api do plug WandererAppWeb.Plugs.ContentNegotiation, accepts: ["json"] plug :accepts, ["json"] @@ -417,6 +421,8 @@ defmodule WandererAppWeb.Router do get "/", BlogController, :license end + + scope "/swaggerui" do pipe_through [:browser, :api_spec] @@ -549,6 +555,8 @@ defmodule WandererAppWeb.Router do live "/tracking", CharactersTrackingLive, :index live "/characters", CharactersLive, :index live "/characters/authorize", CharactersLive, :authorize + live "/characters/:eve_id", CharacterProfileLive, :show + live "/sponsors", SponsorsLive, :index live "/maps/new", MapsLive, :create live "/maps/:slug/edit", MapsLive, :edit live "/maps/:slug/settings", MapsLive, :settings diff --git a/mix.exs b/mix.exs index fca14304..2e731e96 100644 --- a/mix.exs +++ b/mix.exs @@ -134,6 +134,8 @@ defmodule WandererApp.MixProject do {:live_view_events, "~> 0.1.0"}, {:ash_pagify, "~> 1.4.1"}, {:timex, "~> 3.0"}, + {:earmark, "~> 1.4"}, + {:html_sanitize_ex, "~> 1.4"}, # Test coverage and quality {:excoveralls, "~> 0.18", only: :test} ] diff --git a/mix.lock b/mix.lock index 59319aad..87e4410e 100644 --- a/mix.lock +++ b/mix.lock @@ -57,6 +57,7 @@ "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"}, "heroicons": {:hex, :heroicons, "0.5.5", "c2bcb05a90f010df246a5a2a2b54cac15483b5de137b2ef0bead77fcdf06e21a", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:phoenix_live_view, ">= 0.18.2", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "2f4bf929440fecd5191ba9f40e5009b0f75dc993d765c0e4d068fcb7026d6da1"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, + "html_sanitize_ex": {:hex, :html_sanitize_ex, "1.5.0", "ea13a4a92ba0fa17bc6199f1bb7b755a8595ec3b5f763330ea8570d8b5f648e4", [:mix], [{:mochiweb, "~> 2.15 or ~> 3.1", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm", "4eaa2205ae56fab95d0f25065d709b05f0cba730f3fcec184dfde594acdd4578"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "igniter": {:hex, :igniter, "0.7.0", "6848714fa5afa14258c82924a57af9364745316241a409435cf39cbe11e3ae80", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "1e7254780dbf4b44c9eccd6d86d47aa961efc298d7f520c24acb0258c8e90ba9"}, "inflex": {:hex, :inflex, "2.1.0", "a365cf0821a9dacb65067abd95008ca1b0bb7dcdd85ae59965deef2aa062924c", [:mix], [], "hexpm", "14c17d05db4ee9b6d319b0bff1bdf22aa389a25398d1952c7a0b5f3d93162dd8"}, @@ -78,6 +79,7 @@ "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, "mint_web_socket": {:hex, :mint_web_socket, "1.0.4", "0b539116dbb3d3f861cdf5e15e269a933cb501c113a14db7001a3157d96ffafd", [:mix], [{:mint, ">= 1.4.1 and < 2.0.0-0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "027d4c5529c45a4ba0ce27a01c0f35f284a5468519c045ca15f43decb360a991"}, "mix_audit": {:hex, :mix_audit, "2.1.3", "c70983d5cab5dca923f9a6efe559abfb4ec3f8e87762f02bab00fa4106d17eda", [:make, :mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.9", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "8c3987100b23099aea2f2df0af4d296701efd031affb08d0746b2be9e35988ec"}, + "mochiweb": {:hex, :mochiweb, "3.3.0", "2898ad0bfeee234e4cbae623c7052abc3ff0d73d499ba6e6ffef445b13ffd07a", [:rebar3], [], "hexpm", "aa85b777fb23e9972ebc424e40b5d35106f19bc998873e026dedd876df8ee50c"}, "mox": {:hex, :mox, "1.1.0", "0f5e399649ce9ab7602f72e718305c0f9cdc351190f72844599545e4996af73c", [:mix], [], "hexpm", "d44474c50be02d5b72131070281a5d3895c0e7a95c780e90bc0cfe712f633a13"}, "nebulex": {:hex, :nebulex, "2.6.2", "0874989db4e382362884662d2ee9f31b4c4862595f4ec300bd279068729dd2d0", [:mix], [{:decorator, "~> 1.4", [hex: :decorator, repo: "hexpm", optional: true]}, {:shards, "~> 1.1", [hex: :shards, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "002a1774d5a187eb631ae4006db13df4bb6b325fe2a3c14cb14a1f3e989042b4"}, "nimble_csv": {:hex, :nimble_csv, "1.2.0", "4e26385d260c61eba9d4412c71cea34421f296d5353f914afe3f2e71cce97722", [:mix], [], "hexpm", "d0628117fcc2148178b034044c55359b26966c6eaa8e2ce15777be3bbc91b12a"}, diff --git a/priv/posts/2026/01-01-roadmap-2026.md b/priv/posts/2026/01-01-roadmap-2026.md index 602ba019..e4956e1d 100644 --- a/priv/posts/2026/01-01-roadmap-2026.md +++ b/priv/posts/2026/01-01-roadmap-2026.md @@ -3,7 +3,7 @@ title: "Event: Wanderer 2026 Roadmap Reveal", author: "Wanderer Team", cover_image_uri: "/images/news/2026/01-01-roadmap/cover.webp", tags: ~w(event roadmap 2026 announcement community), -description: "JWanderer's 2026 roadmap are ready to reveal! Discover what exciting features and improvements are coming in 2026." +description: "Wanderer's 2026 roadmap are ready to reveal! Discover what exciting features and improvements are coming in 2026." } --- diff --git a/priv/posts/2026/01-05-weekly-giveaway-challenge.md b/priv/posts/2026/01-05-weekly-giveaway-challenge.md deleted file mode 100644 index 8ca30415..00000000 --- a/priv/posts/2026/01-05-weekly-giveaway-challenge.md +++ /dev/null @@ -1,36 +0,0 @@ -%{ -title: "Event: Weekly Giveaway Challenge", -author: "Wanderer Team", -cover_image_uri: "/images/news/2026/01-05-weekly-giveaway/cover.webp", -tags: ~w(event giveaway challenge), -description: "Join our Weekly Giveaway Challenge! Be the fastest to claim your reward!" -} - ---- - - -![Weekly Giveaway Challenge](/images/news/2026/01-05-weekly-giveaway/cover.webp "Weekly Giveaway Challenge") - -### Event Details - -In 2026, we're going to giveaway partnership SKIN codes for our community, every week! - -- **Event Name:** Weekly Giveaway Challenge -- **Event Link:** [Join Weekly Giveaway Challenge](https://eventcortex.com/events/invite/Cjo87svZFq6J8cc1cubH4B7AR_VfPmQ4) - ---- - -### Tips for Participants - -- **Be Ready:** Know the reveal time and be online a few minutes early. - ---- - -Good luck, and may the fastest capsuleer win! - ---- - -Fly safe, -**Wanderer Team** - ---- diff --git a/priv/posts/2026/02-12-eve-creator-awards.md b/priv/posts/2026/02-12-eve-creator-awards.md deleted file mode 100644 index 10691c51..00000000 --- a/priv/posts/2026/02-12-eve-creator-awards.md +++ /dev/null @@ -1,53 +0,0 @@ -%{ -title: "EVE Creator Awards - Nominate Wanderer!", -author: "Wanderer Team", -cover_image_uri: "/images/news/2026/02-12-eve-creator-awards/cover.jpg", -tags: ~w(event community awards nomination), -description: "CCP has opened nominations for the EVE Creator Awards! Support Wanderer by voting for Third-Party App of the Year and Developer of the Year." -} - ---- - -![EVE Creator Awards](/images/news/2026/02-12-eve-creator-awards/cover.jpg "EVE Creator Awards") - -### EVE Creator Awards - We Need Your Vote! - -CCP has opened nominations for the **EVE Creator Awards**, including **Best Third-Party App** and **Developer of the Year**, and you can support us by voting. - ---- - -### How to Nominate Us - -You can nominate us for **Third-Party App of the Year**, and choose one of the team as **Developer of the Year**: Dan Sylvest, vvrong, or Gustav Oswaldo. - -**App field** may be filled with: `Wanderer` / `https://wanderer.ltd/` - ---- - -### A Bit of Stats - -Over the past months, Wanderer has grown to more than **7,000 monthly users**, with pilots joining from all around the world. - ---- - -### Meet the Team - -- **Dan Sylvest** — leads frontend, design, and frontend architecture, along with several supporting services. -- **vvrong** (you know him as Demiro) — responsible for backend development, core architecture, and APIs, with additional frontend contributions. -- **Gustav Oswaldo** — contributes across backend and frontend, including zKillboard-related services, APIs, and bots. - ---- - -### Vote Now - -- **[Vote for us here](https://eve-creator-awards.paperform.co/)** -- **[Read the announcement](https://www.eveonline.com/news/view/eve-creator-awards)** - ---- - -Thank you for your support! - -Fly safe, -**Wanderer Team** - ---- diff --git a/priv/repo/migrations/20260406213852_add_character_description.exs b/priv/repo/migrations/20260406213852_add_character_description.exs new file mode 100644 index 00000000..6454b8ff --- /dev/null +++ b/priv/repo/migrations/20260406213852_add_character_description.exs @@ -0,0 +1,29 @@ +defmodule WandererApp.Repo.Migrations.AddCharacterDescription do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + alter table(:maps_v1) do + modify :scopes, {:array, :text}, default: '{wormholes}' + end + + alter table(:character_v1) do + add :description, :text + end + end + + def down do + alter table(:character_v1) do + remove :description + end + + alter table(:maps_v1) do + modify :scopes, {:array, :text}, default: nil + end + end +end diff --git a/priv/repo/seeds_sponsors.exs b/priv/repo/seeds_sponsors.exs new file mode 100644 index 00000000..fbb1b55e --- /dev/null +++ b/priv/repo/seeds_sponsors.exs @@ -0,0 +1,135 @@ +# Seed script for example sponsor donations. +# +# Creates sample users, characters, a map, and donation transactions +# so the /sponsors page and /characters/:eve_id profile pages have data. +# +# Run with: +# mix run priv/repo/seeds_sponsors.exs +# +require Logger +Logger.info("Seeding sponsor donation data...") + +alias WandererApp.Repo + +# Well-known EVE character data (real public info, no secrets) +characters_data = [ + %{ + eve_id: "96734492", + name: "Oz Hasaki", + corporation_id: 98_553_333, + corporation_name: "Wormhole Wanderers", + corporation_ticker: "W.W", + alliance_id: nil, + alliance_name: nil, + alliance_ticker: nil + }, + %{ + eve_id: "2119543215", + name: "Katya Itzimansen", + corporation_id: 98_681_432, + corporation_name: "Anoikis Explorers", + corporation_ticker: "A.EXP", + alliance_id: 99_011_258, + alliance_name: "Anoikis Coalition", + alliance_ticker: "ANOK" + }, + %{ + eve_id: "93568202", + name: "Dmitriy Lancel", + corporation_id: 98_712_045, + corporation_name: "Signal Cartel", + corporation_ticker: "1420.", + alliance_id: 99_005_338, + alliance_name: "EvE-Scout Enclave", + alliance_ticker: "SCOUT" + }, + %{ + eve_id: "94801715", + name: "Heron Explorer", + corporation_id: 98_553_333, + corporation_name: "Wormhole Wanderers", + corporation_ticker: "W.W", + alliance_id: nil, + alliance_name: nil, + alliance_ticker: nil + } +] + +# Donation amounts (ISK) for each character — descending order +donation_amounts = [ + 2_500_000_000.0, + 1_200_000_000.0, + 800_000_000.0, + 350_000_000.0 +] + +# ---------- Create users, characters, a map, and transactions ---------- + +Repo.transaction(fn -> + # 1. Create a dummy map for the transactions (needs name + slug) + {:ok, map} = + WandererApp.Api.Map.new(%{ + name: "Seed Sponsors Map", + slug: "seed-sponsors-map" + }) + + Logger.info(" Created map: #{map.id}") + + Enum.zip(characters_data, donation_amounts) + |> Enum.each(fn {char_data, amount} -> + # 2. Create a user + {:ok, user} = + WandererApp.Api.User + |> Ash.Changeset.for_create(:create, %{ + name: char_data.name, + hash: "seed_sponsor_#{char_data.eve_id}" + }) + |> Ash.create() + + Logger.info(" Created user: #{user.id} (#{user.name})") + + # 3. Create a character linked to that user + {:ok, character} = + WandererApp.Api.Character + |> Ash.Changeset.for_create(:create, %{ + eve_id: char_data.eve_id, + name: char_data.name + }) + |> Ash.create() + + # Assign user + {:ok, character} = WandererApp.Api.Character.assign_user(character, %{user_id: user.id}) + + # Update corporation info + {:ok, character} = + WandererApp.Api.Character.update_corporation(character, %{ + corporation_id: char_data.corporation_id, + corporation_name: char_data.corporation_name, + corporation_ticker: char_data.corporation_ticker + }) + + # Update alliance info (if present) + if char_data.alliance_id do + WandererApp.Api.Character.update_alliance(character, %{ + alliance_id: char_data.alliance_id, + alliance_name: char_data.alliance_name, + alliance_ticker: char_data.alliance_ticker + }) + end + + Logger.info(" Created character: #{character.eve_id} (#{character.name})") + + # 4. Create a donation transaction (type: :in) + {:ok, _txn} = + WandererApp.Api.MapTransaction.create(%{ + map_id: map.id, + user_id: user.id, + type: :in, + amount: amount + }) + + Logger.info(" Created donation: #{amount} ISK from #{char_data.name}") + end) +end) + +Logger.info("Sponsor seed data complete!") diff --git a/priv/resource_snapshots/repo/character_v1/20260406213852.json b/priv/resource_snapshots/repo/character_v1/20260406213852.json new file mode 100644 index 00000000..79f95921 --- /dev/null +++ b/priv/resource_snapshots/repo/character_v1/20260406213852.json @@ -0,0 +1,413 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "eve_id", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "name", + "type": "text" + }, + { + "allow_nil?": true, + "default": "false", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "online", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "false", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "deleted", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "scopes", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "character_owner_hash", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "token_type", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "expires_at", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "ship_name", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "ship_item_id", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "corporation_id", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "corporation_name", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "corporation_ticker", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "alliance_id", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "alliance_name", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "alliance_ticker", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "tracking_pool", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "description", + "type": "text" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "character_v1_user_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": null, + "table": "user_v1" + }, + "scale": null, + "size": null, + "source": "user_id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "encrypted_eve_wallet_balance", + "type": "binary" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "encrypted_location", + "type": "binary" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "encrypted_ship", + "type": "binary" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "encrypted_solar_system_id", + "type": "binary" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "encrypted_structure_id", + "type": "binary" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "encrypted_station_id", + "type": "binary" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "encrypted_access_token", + "type": "binary" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "encrypted_refresh_token", + "type": "binary" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "B75AEDD6CAB8418E22082401EEB78FDA9E2B9B70883EC7EA0E05EE26695D138E", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "character_v1_unique_eve_id_index", + "keys": [ + { + "type": "atom", + "value": "eve_id" + } + ], + "name": "unique_eve_id", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.WandererApp.Repo", + "schema": null, + "table": "character_v1" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/maps_v1/20260406213852.json b/priv/resource_snapshots/repo/maps_v1/20260406213852.json new file mode 100644 index 00000000..d436d79d --- /dev/null +++ b/priv/resource_snapshots/repo/maps_v1/20260406213852.json @@ -0,0 +1,277 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "name", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "slug", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "description", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "personal_note", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "public_api_key", + "type": "text" + }, + { + "allow_nil?": true, + "default": "[]", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "hubs", + "type": [ + "array", + "text" + ] + }, + { + "allow_nil?": false, + "default": "\"wormholes\"", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "scope", + "type": "text" + }, + { + "allow_nil?": true, + "default": "false", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "deleted", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "false", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "only_tracked_characters", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "options", + "type": "text" + }, + { + "allow_nil?": false, + "default": "false", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "webhooks_enabled", + "type": "boolean" + }, + { + "allow_nil?": false, + "default": "false", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "sse_enabled", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "'{wormholes}'", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "scopes", + "type": [ + "array", + "text" + ] + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "maps_v1_owner_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": null, + "table": "character_v1" + }, + "scale": null, + "size": null, + "source": "owner_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "17E507A2F8B57193D92DF2E707C6623C68A07B5058227A12DEF1522777BE7B83", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "maps_v1_unique_public_api_key_index", + "keys": [ + { + "type": "atom", + "value": "public_api_key" + } + ], + "name": "unique_public_api_key", + "nils_distinct?": true, + "where": null + }, + { + "all_tenants?": false, + "base_filter": null, + "index_name": "maps_v1_unique_slug_index", + "keys": [ + { + "type": "atom", + "value": "slug" + } + ], + "name": "unique_slug", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.WandererApp.Repo", + "schema": null, + "table": "maps_v1" +} \ No newline at end of file diff --git a/test/support/openapi_spec_analyzer.ex b/test/support/openapi_spec_analyzer.ex index 8de47ce5..dbdb81dd 100644 --- a/test/support/openapi_spec_analyzer.ex +++ b/test/support/openapi_spec_analyzer.ex @@ -155,7 +155,7 @@ defmodule WandererAppWeb.OpenAPISpecAnalyzer do # Categorize schemas based on naming patterns request_schemas = Enum.filter(schema_names, &String.contains?(&1, "Request")) response_schemas = Enum.filter(schema_names, &String.contains?(&1, "Response")) - shared_schemas = schema_names -- request_schemas -- response_schemas + shared_schemas = schema_names -- (request_schemas -- response_schemas) %{ total: length(schema_names),