From 6051b17401c00d4148994723940e031fbfd5134f Mon Sep 17 00:00:00 2001 From: "homarr-renovate[bot]" <158783068+homarr-renovate[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 16:37:40 +0000 Subject: [PATCH 01/11] chore(deps): update dependency nanoid@>=4.0.0 <5.0.9 to >=5.1.15 (#6079) Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com> --- pnpm-lock.yaml | 2 +- pnpm-workspace.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 79924fd38..fefdda401 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -572,7 +572,7 @@ overrides: form-data@>=4.0.0 <4.0.4: '>=4.0.6' hono@<4.6.5: '>=4.12.26' linkifyjs@<4.3.2: '>=4.3.3' - nanoid@>=4.0.0 <5.0.9: '>=5.1.14' + nanoid@>=4.0.0 <5.0.9: '>=5.1.15' prismjs@<1.30.0: '>=1.30.0' proxmox-api>undici: 7.27.1 react-is: ^19.2.7 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index caa906958..55080439f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -213,7 +213,7 @@ overrides: "form-data@>=4.0.0 <4.0.4": ">=4.0.6" "hono@<4.6.5": ">=4.12.26" "linkifyjs@<4.3.2": ">=4.3.3" - "nanoid@>=4.0.0 <5.0.9": ">=5.1.14" + "nanoid@>=4.0.0 <5.0.9": ">=5.1.15" "prismjs@<1.30.0": ">=1.30.0" "proxmox-api>undici": "7.27.1" "react-is": "^19.2.7" From f56a05bd8ed2b0e0d0f25a1d689f37b3482f548d Mon Sep 17 00:00:00 2001 From: "homarr-renovate[bot]" <158783068+homarr-renovate[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 11:04:52 +0000 Subject: [PATCH 02/11] chore(deps): update dependency video.js to ^8.23.9 (#6084) Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com> --- pnpm-lock.yaml | 53 ++++++++++++++++++++++++++------------------- pnpm-workspace.yaml | 2 +- 2 files changed, 32 insertions(+), 23 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fefdda401..a3e6c5a9d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -529,8 +529,8 @@ catalogs: specifier: ^1.8.1 version: 1.8.1 video.js: - specifier: ^8.23.8 - version: 8.23.8 + specifier: ^8.23.9 + version: 8.23.9 vite: specifier: ^8.0.16 version: 8.0.16 @@ -2691,7 +2691,7 @@ importers: version: 3.8.1(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1) video.js: specifier: 'catalog:' - version: 8.23.8 + version: 8.23.9 zod: specifier: 'catalog:' version: 4.4.3 @@ -7867,8 +7867,8 @@ packages: resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==} engines: {node: '>= 20'} - '@videojs/http-streaming@3.17.4': - resolution: {integrity: sha512-XAvdG2dolBuV2Fx8bu1kjmQ2D4TonGzZH68Pgv/O9xMSFWdZtITSMFismeQLEAtMmGwze8qNJp3RgV+jStrJqg==} + '@videojs/http-streaming@3.17.5': + resolution: {integrity: sha512-PRRQVu0hYW5h0XvRWpIyIpEatpMZzZtf7L1avV89Hnu+69f+0c7oLsSL9jwpg2nbvzGkdX5rQxQkz5Vc7tFR9w==} engines: {node: '>=8', npm: '>=5'} peerDependencies: video.js: ^8.19.0 @@ -7877,6 +7877,10 @@ packages: resolution: {integrity: sha512-5iLX6sR2ownbv4Mtejw6Ax+naosGvoT9kY+gcuHzANyUZZ+4NpeNdKMUhb6ag0acYej1Y7cmr/F2+4PrggMiVA==} engines: {node: '>=8', npm: '>=5'} + '@videojs/vhs-utils@4.1.2': + resolution: {integrity: sha512-1Dsv5dtWOq0LP1G37O46FQFA2tKAZON5UMe6dFPxT7JvPhEx8QIXcFQxan8+hHI3fX6aomFRFrUBJNgRYQDSXA==} + engines: {node: '>=8', npm: '>=5'} + '@videojs/xhr@2.7.0': resolution: {integrity: sha512-giab+EVRanChIupZK7gXjHy90y3nncA2phIOyG3Ne5fvpiMJzvqYwiTOnEVW2S4CoYcuKJkomat7bMXA/UoUZQ==} @@ -11787,8 +11791,8 @@ packages: engines: {node: '>=10'} hasBin: true - mpd-parser@1.3.1: - resolution: {integrity: sha512-1FuyEWI5k2HcmhS1HkKnUAQV7yFPfXPht2DnRRGtoiiAAW+ESTbtEXIDpRkwdU+XyrQuwrIym7UkoPKsZ0SyFw==} + mpd-parser@1.4.0: + resolution: {integrity: sha512-blbA7XpAaOdC/PR0Tu8GiUxlx6CpBuRowQPYV7lhi9Yr9G9+dkPXIUjqjzc/NCn/uZZPopluaJlIYntIEI/VLw==} hasBin: true mrmime@2.0.0: @@ -14706,8 +14710,8 @@ packages: victory-vendor@37.3.6: resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} - video.js@8.23.8: - resolution: {integrity: sha512-plZuWxN4clhDESSpi3QLYVs6tgnnf9qT9SA4iahHwZEmdVSuUh4+O6vQPcA5XqiEDvjeBDJvLafH1bhkAXaXKQ==} + video.js@8.23.9: + resolution: {integrity: sha512-3UCqyFW4wIusQu4TuhgRUxqtbzbj0WPT9hps0XIEXST43QBD11zb+bG7tBPrinrZztwVYdXCiOBo2pcGj9L05w==} videojs-contrib-quality-levels@4.1.0: resolution: {integrity: sha512-TfrXJJg1Bv4t6TOCMEVMwF/CoS8iENYsWNKip8zfhB5kTcegiFYezEA0eHAJPU64ZC8NQbxQgOwAsYU8VXbOWA==} @@ -21450,22 +21454,27 @@ snapshots: '@vercel/oidc@3.1.0': {} - '@videojs/http-streaming@3.17.4(video.js@8.23.8)': + '@videojs/http-streaming@3.17.5(video.js@8.23.9)': dependencies: '@babel/runtime': 7.29.7 - '@videojs/vhs-utils': 4.1.1 + '@videojs/vhs-utils': 4.1.2 aes-decrypter: 4.0.2 global: 4.4.0 m3u8-parser: 7.2.0 - mpd-parser: 1.3.1 + mpd-parser: 1.4.0 mux.js: 7.1.0 - video.js: 8.23.8 + video.js: 8.23.9 '@videojs/vhs-utils@4.1.1': dependencies: '@babel/runtime': 7.29.7 global: 4.4.0 + '@videojs/vhs-utils@4.1.2': + dependencies: + '@babel/runtime': 7.29.7 + global: 4.4.0 + '@videojs/xhr@2.7.0': dependencies: '@babel/runtime': 7.29.7 @@ -25962,10 +25971,10 @@ snapshots: mkdirp@3.0.1: {} - mpd-parser@1.3.1: + mpd-parser@1.4.0: dependencies: '@babel/runtime': 7.29.7 - '@videojs/vhs-utils': 4.1.1 + '@videojs/vhs-utils': 4.1.2 '@xmldom/xmldom': 0.8.10 global: 4.4.0 @@ -29224,25 +29233,25 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - video.js@8.23.8: + video.js@8.23.9: dependencies: '@babel/runtime': 7.29.7 - '@videojs/http-streaming': 3.17.4(video.js@8.23.8) - '@videojs/vhs-utils': 4.1.1 + '@videojs/http-streaming': 3.17.5(video.js@8.23.9) + '@videojs/vhs-utils': 4.1.2 '@videojs/xhr': 2.7.0 aes-decrypter: 4.0.2 global: 4.4.0 m3u8-parser: 7.2.0 - mpd-parser: 1.3.1 + mpd-parser: 1.4.0 mux.js: 7.1.0 - videojs-contrib-quality-levels: 4.1.0(video.js@8.23.8) + videojs-contrib-quality-levels: 4.1.0(video.js@8.23.9) videojs-font: 4.2.0 videojs-vtt.js: 0.15.5 - videojs-contrib-quality-levels@4.1.0(video.js@8.23.8): + videojs-contrib-quality-levels@4.1.0(video.js@8.23.9): dependencies: global: 4.4.0 - video.js: 8.23.8 + video.js: 8.23.9 videojs-font@4.2.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 55080439f..ea0305138 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -180,7 +180,7 @@ catalog: vite: ^8.0.16 undici: 7.27.2 use-deep-compare-effect: ^1.8.1 - video.js: ^8.23.8 + video.js: ^8.23.9 vite-tsconfig-paths: ^6.1.1 vitest: ^4.1.9 winston: 3.19.0 From a2bb53a8b8fd17da3d2e23851b75d4e179f66225 Mon Sep 17 00:00:00 2001 From: Lokesh <110459168+Lok3sh-kumar@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:59:23 +0530 Subject: [PATCH 03/11] feat: add VPN status widget (#5812) Co-authored-by: Lokesh-Kumar-Dudi Co-authored-by: Thomas Camlong --- CODEOWNERS | 5 + apps/docs/docs/integrations/gluetun/index.mdx | 52 +++ apps/docs/docs/integrations/gluetun/index.ts | 8 + apps/docs/docs/widgets/vpn/img/single.png | Bin 0 -> 30744 bytes apps/docs/docs/widgets/vpn/index.mdx | 40 ++ apps/docs/docs/widgets/vpn/index.ts | 9 + .../src/app/api/backup/test/backup.spec.ts | 12 +- .../components/backup/use-backup-analysis.ts | 3 +- .../integration/integration-grid-shared.ts | 1 + packages/api/src/router/widgets/index.ts | 1 + packages/api/src/router/widgets/vpn.ts | 59 +++ packages/cron-jobs/src/index.ts | 2 + .../cron-jobs/src/jobs/integrations/vpn.ts | 14 + .../src/docs/homarr-docs-sitemap.ts | 2 + .../src/docs/integration-doc-slugs.ts | 1 + .../definitions/src/docs/widget-doc-slugs.ts | 1 + packages/definitions/src/integration.ts | 8 + .../definitions/src/widget-integration-map.ts | 1 + packages/definitions/src/widget.ts | 1 + packages/integrations/src/base/creator.ts | 2 + .../src/gluetun/gluetun-integration.ts | 115 ++++++ .../integrations/src/gluetun/gluetun-types.ts | 116 ++++++ .../gluetun/test/gluetun-integration.spec.ts | 371 ++++++++++++++++++ .../src/gotify/gotify-integration.ts | 20 +- .../vpn-summary/vpn-summary-integration.ts | 5 + .../vpn-summary/vpn-summary-types.ts | 13 + packages/integrations/src/types.ts | 2 + packages/integrations/test/technitium.spec.ts | 369 ++++++++--------- packages/request-handler/src/vpn.ts | 35 ++ packages/translation/src/lang/en.json | 10 + packages/widgets/src/docker/component.tsx | 4 +- packages/widgets/src/index.tsx | 2 + packages/widgets/src/vpn/component.tsx | 72 ++++ packages/widgets/src/vpn/helpers.ts | 22 ++ packages/widgets/src/vpn/index.ts | 14 + packages/widgets/src/vpn/vpn-card.tsx | 164 ++++++++ 36 files changed, 1332 insertions(+), 224 deletions(-) create mode 100644 apps/docs/docs/integrations/gluetun/index.mdx create mode 100644 apps/docs/docs/integrations/gluetun/index.ts create mode 100644 apps/docs/docs/widgets/vpn/img/single.png create mode 100644 apps/docs/docs/widgets/vpn/index.mdx create mode 100644 apps/docs/docs/widgets/vpn/index.ts create mode 100644 packages/api/src/router/widgets/vpn.ts create mode 100644 packages/cron-jobs/src/jobs/integrations/vpn.ts create mode 100644 packages/integrations/src/gluetun/gluetun-integration.ts create mode 100644 packages/integrations/src/gluetun/gluetun-types.ts create mode 100644 packages/integrations/src/gluetun/test/gluetun-integration.spec.ts create mode 100644 packages/integrations/src/interfaces/vpn-summary/vpn-summary-integration.ts create mode 100644 packages/integrations/src/interfaces/vpn-summary/vpn-summary-types.ts create mode 100644 packages/request-handler/src/vpn.ts create mode 100644 packages/widgets/src/vpn/component.tsx create mode 100644 packages/widgets/src/vpn/helpers.ts create mode 100644 packages/widgets/src/vpn/index.ts create mode 100644 packages/widgets/src/vpn/vpn-card.tsx diff --git a/CODEOWNERS b/CODEOWNERS index b798cfada..b3e2214ac 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,6 +1,11 @@ # ── CODEOWNERS ─────────────────────────────────────── * @homarr-labs/maintainers +# Specific features / integrations +# Glueten +apps/docs/docs/integrations/glueten @Lok3sh-kumar +packages/integrations/src/gluetun @Lok3sh-kumar + # Exempt Renovate‑managed files (no owners) package.json **/package.json diff --git a/apps/docs/docs/integrations/gluetun/index.mdx b/apps/docs/docs/integrations/gluetun/index.mdx new file mode 100644 index 000000000..7c936fc1f --- /dev/null +++ b/apps/docs/docs/integrations/gluetun/index.mdx @@ -0,0 +1,52 @@ +--- +title: 'Gluetun' +description: 'Connect Gluetun to Homarr as a VPN integration.' +hide_title: true +--- + +import { IntegrationHeader } from '@site/src/components/integrations/header'; +import { IntegrationCapabilites } from '@site/src/components/integrations/widgets'; +import { AddingIntegration } from '@site/src/components/integrations/adding'; +import { IntegrationSecrets } from '@site/src/components/integrations/secrets'; +import { gluetunIntegration } from '.'; +import { vpnWidget } from '@site/docs/widgets/vpn'; + + + +[Gluetun](https://github.com/qdm12/gluetun) exposes a control server that Homarr can query to report the VPN tunnel +status, DNS status, public IP address, and active provider details in the VPN widget. + +### Widgets & Capabilities + + + +### Setting up the Gluetun control server + +Refer to the [Gluetun control server documentation](https://github.com/qdm12/gluetun-wiki/blob/main/setup/advanced/control-server.md#authentication) to enable and configure authentication. + +### Adding the integration + + + +### Secrets + +Gluetun's control server can be left unprotected or secured with either an API key or HTTP Basic authentication. +Choose the option that matches how your Gluetun instance integration is set up. + + diff --git a/apps/docs/docs/integrations/gluetun/index.ts b/apps/docs/docs/integrations/gluetun/index.ts new file mode 100644 index 000000000..6d2489a62 --- /dev/null +++ b/apps/docs/docs/integrations/gluetun/index.ts @@ -0,0 +1,8 @@ +import { IntegrationDefinition } from "@site/src/types"; + +export const gluetunIntegration: IntegrationDefinition = { + name: "Gluetun", + description: "VPN control server integration for Homarr's VPN widget.", + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/gluetun.svg", + path: "../../integrations/gluetun", +}; diff --git a/apps/docs/docs/widgets/vpn/img/single.png b/apps/docs/docs/widgets/vpn/img/single.png new file mode 100644 index 0000000000000000000000000000000000000000..ea4b423e70e21580000822f773dca05929df8fcd GIT binary patch literal 30744 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

5JJ);@KRAtTa4XRy*y&h>_K6N0 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

^U!O@n~~hT){zP>!YMTx z$eDT`H$q1A7b1-DwzP6x1DlJ92IJ~7ve;;WA5K{h^PRLb40@shrIyLD5}8I$0c#Xo=Ujz%zfT_P{}-Nci23VD_7{)^Xydget3zmai+X`VxBBMCK<6d_wf5|B?73 z6u*JMO--{`WzDTJ$387{yT2di>hGY!!|WC>MGv+M{6uyXQ~q)T0Nf8C)OU_;xK$FV z7JBTwBB$_(w3IBqY@3OmsXp9XIJKt&GOK6klUJ(rH|djvAD+o3u!VyEXp3xAous+H z#4${pQJIuXW2p(<%?fu@?lLt2*4kg5hP+l#iPQpCm4)%zg1A(G?e&6cznc5(Sz_qk zufC~GYyzPjB@Mep+r);-A_ZxHl*!U~RsfEGQ#Rk75`KGtw zhHMWGFp)D5!=^#s;HheJzSQ*r=aR3%QoFnoe|ph`0d6IvL&1@M^_3+K0QYGKS9_`^ zz2E1^M}~A4$ux*tO!_ON=V^?FVt&6~dbTKBd7JhhPNlm!f&K3xWx*K<13pXlT*>Ee zAIQz5Q$T=A6+S051=j!nrvJBw;DI2<4ybA6p_?h{320J%yjS`)4Tb)%DL^R3+=Ayn ze0x7fI|#rI=g|?lHzpq$A?)A3YTmj`)V`yaaLc-}iV#}D@BAGDe+4B-^)XOC(2G&V z7f23+z8z5{aZCf}pYAP;yf>E^%U30mux`hZaQRu@0{*G=FDDC3JO(mUZYna?8XR~G zAo9(CtG$c5x3LoV96%b30cz_T(ni9zD?ST6db4W&ZE{KZF`&Hn1+?Sd0CVXL7+MC5 z_Qv{FC}d%G>^fk5RyX51ki99;U+Aw3eTv5>G3X@5mm@DZnWG ztVjBXPKI#N8vQ!X|K93L?g(^Ce8>UNEvjPci+X;c^#9OFa}WUUS!E^vZ@C~84RCs9 zm6=e?p%)(t@t5stSg6p#jilBf(`p8cJpg;XNsV}i&AkB8a}1#9`r z(}j!w1|Q-UzKJL~@~QSR83#GoP`2>8o6Y2Y6J7j&Z%~Bb^mC*97*kf-emufUgTYI( zI0_!#M=4tr{qI}CoXm%6^E|nYv^WJEEqZ>p8&}@CM;(&Q_0P=Zosm~jT|1igOdFKe z-6;g#mDL}Ji~q713n7ZWO8z%z18L$j=<|D=im-g_im>Ve2JW~9kHrd$=BC&D=1o9F zoMym3jCu&p=KXI)7~aKloviz|kI*krOPg-0MiLE(Bh=YIVEp+1dv5QEvk2hN52p1r zSc!Y0g`a#`{Dd9j)$C9ENAI5%ps36CSjzlX_%XdP&t!;d3{hJ3J+=!tesYeO3r#b} z|HdN$@>x40fSLS?e_4%O6xMBSf>?BD7AW<{&T+`8+Tv9af%#B*2f6;YxzhrrY; zS-~g$BAii!eYH;Cc_{j_%k(D%{~Prmj9C3!@6A4Xoz$|J827}QBW3v`oh?=|ojhSR zq-g!FuPjhe!-3|gq`H3xZY1Fr?2ameoTi++P?1|AAynvng~Wn#5eq*;BcogB+);Fe z4PPf1Dz?r2zm=W>3G=;^A7R?_itMCZ*?8B%nyFIn-J=B?l7jidTU`RUES$G)2SBd- zHh08YzjGfHvT_()L&?iCjQfL3{ z6;?ji{>5sddhTs(!z;Yz0UE4-dtb^3C_|igtUvy@+^B(#&_Ff& zUGcw*zr+_%n3R5s9+m%ho{bQ|_DStiocVA1$(sO_D({Y%jL-iKb3<}KsABXZ!#Vcf zMdz%ph$)%~G})s3r|}1>fSSNi-=UQI|1%G>8!G+I3^lRQVtEVr(^S0=t5AL%@qYkR C$8mK4 literal 0 HcmV?d00001 diff --git a/apps/docs/docs/widgets/vpn/index.mdx b/apps/docs/docs/widgets/vpn/index.mdx new file mode 100644 index 000000000..0aa6a1a16 --- /dev/null +++ b/apps/docs/docs/widgets/vpn/index.mdx @@ -0,0 +1,40 @@ +--- +title: 'VPN' +description: 'Monitor the status of your VPN integrations.' +hide_title: true +--- + +import { WidgetHeader } from '@site/src/components/widgets/header'; +import { AddingWidget } from '@site/src/components/widgets/adding'; +import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; +import { vpnWidget } from '.'; +import { gluetunIntegration } from '@site/docs/integrations/gluetun'; + + + +The VPN widget provides a compact overview of each configured VPN integration. It shows whether the tunnel and DNS +services are up, the currently exposed public IP address, and the VPN provider and protocol being used. + +When multiple VPN integrations are attached, the widget renders one card per integration so you can compare their +current status at a glance. + +### Screenshots + +import singleIntegrationImage from './img/single.png'; + +
+ VPN widget showing a single integration +
+ +### Supported Integrations + + + +### Adding the widget + + diff --git a/apps/docs/docs/widgets/vpn/index.ts b/apps/docs/docs/widgets/vpn/index.ts new file mode 100644 index 000000000..0f1590e5b --- /dev/null +++ b/apps/docs/docs/widgets/vpn/index.ts @@ -0,0 +1,9 @@ +import { WidgetDefinition } from "@site/src/types"; +import { IconShieldLock } from "@tabler/icons-react"; + +export const vpnWidget: WidgetDefinition = { + icon: IconShieldLock, + name: "VPN", + description: "Monitor the connection status, public IP, and provider details of your VPN integrations.", + path: "../../widgets/vpn", +}; diff --git a/apps/nextjs/src/app/api/backup/test/backup.spec.ts b/apps/nextjs/src/app/api/backup/test/backup.spec.ts index 0a394abf7..15706ecb8 100644 --- a/apps/nextjs/src/app/api/backup/test/backup.spec.ts +++ b/apps/nextjs/src/app/api/backup/test/backup.spec.ts @@ -211,7 +211,17 @@ describe("SQLite backup", () => { const decryptedAfter = decryptWithKey(updatedRow.value as `${string}.${string}`, newKey); expect(decryptedAfter).toBe(plaintext); - expect(() => decryptWithKey(updatedRow.value as `${string}.${string}`, oldKey)).toThrow(); + // The old key must no longer recover the plaintext. With AES-256-CBC this + // usually fails PKCS7 padding validation (throws), but ~1/256 of the time the + // wrong key yields valid padding and returns garbage instead of throwing, so + // assert on the actual property (plaintext is unrecoverable) rather than throwing. + let decryptedWithOldKey: string | null = null; + try { + decryptedWithOldKey = decryptWithKey(updatedRow.value as `${string}.${string}`, oldKey); + } catch { + // Expected for most wrong keys: padding validation fails. + } + expect(decryptedWithOldKey).not.toBe(plaintext); sqlite.close(); }); diff --git a/apps/nextjs/src/components/backup/use-backup-analysis.ts b/apps/nextjs/src/components/backup/use-backup-analysis.ts index a59e4335b..ee92a4464 100644 --- a/apps/nextjs/src/components/backup/use-backup-analysis.ts +++ b/apps/nextjs/src/components/backup/use-backup-analysis.ts @@ -142,7 +142,8 @@ export const useBackupAnalysis = () => { phase: "error", }); throw new Error( - `Migration ${migration.tag} failed: ${migrationErr instanceof Error ? migrationErr.message : "Unknown error"}`, { cause: migrationErr }, + `Migration ${migration.tag} failed: ${migrationErr instanceof Error ? migrationErr.message : "Unknown error"}`, + { cause: migrationErr }, ); } diff --git a/apps/nextjs/src/components/integration/integration-grid-shared.ts b/apps/nextjs/src/components/integration/integration-grid-shared.ts index 4b43a4299..2fc7228c8 100644 --- a/apps/nextjs/src/components/integration/integration-grid-shared.ts +++ b/apps/nextjs/src/components/integration/integration-grid-shared.ts @@ -57,6 +57,7 @@ export const categoryTranslationKeys: Record = { mediaMonitoring: "integration.category.mediaMonitoring", speedtest: "integration.category.speedtest", analytics: "integration.category.analytics", + vpn: "integration.category.vpn", documents: "integration.category.documents", mediaLibrary: "integration.category.mediaLibrary", uptimeMonitoring: "integration.category.uptimeMonitoring", diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts index 70c1aa997..80776db45 100644 --- a/packages/api/src/router/widgets/index.ts +++ b/packages/api/src/router/widgets/index.ts @@ -36,6 +36,7 @@ export const widgetRouter = createTRPCRouter({ uptimeKuma: lazy(() => import("./uptime-kuma").then((mod) => mod.uptimeKumaRouter)), audioStats: lazy(() => import("./audio-stats").then((mod) => mod.audioStatsRouter)), umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)), + vpn: lazy(() => import("./vpn").then((mod) => mod.vpnRouter)), ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)), customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)), }); diff --git a/packages/api/src/router/widgets/vpn.ts b/packages/api/src/router/widgets/vpn.ts new file mode 100644 index 000000000..f50d65f33 --- /dev/null +++ b/packages/api/src/router/widgets/vpn.ts @@ -0,0 +1,59 @@ +import { observable } from "@trpc/server/observable"; + +import type { Modify } from "@homarr/common/types"; +import type { Integration } from "@homarr/db/schema"; +import type { IntegrationKindByCategory } from "@homarr/definitions"; +import { getIntegrationKindsByCategory } from "@homarr/definitions"; +import type { VpnSummary } from "@homarr/integrations/types"; +import { vpnSummaryHandler } from "@homarr/request-handler/vpn"; + +import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { createTRPCRouter, publicProcedure } from "../../trpc"; + +const createVpnIntegrationMiddleware = () => + createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("vpn")); + +export const vpnRouter = createTRPCRouter({ + getSummaries: publicProcedure.unstable_concat(createVpnIntegrationMiddleware()).query(async ({ ctx }) => { + return await Promise.all( + ctx.integrations.map(async (integration) => { + const innerHandler = vpnSummaryHandler.handler(integration, {}); + const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + + return { + integration: { + id: integration.id, + name: integration.name, + kind: integration.kind, + updatedAt: timestamp, + }, + summary: data, + }; + }), + ); + }), + subscribeSummaries: publicProcedure.unstable_concat(createVpnIntegrationMiddleware()).subscription(({ ctx }) => { + return observable<{ + integration: Modify }>; + summary: VpnSummary | null; + }>((emit) => { + const unsubscribes: (() => void)[] = []; + for (const integrationWithSecrets of ctx.integrations) { + const { decryptedSecrets: _, ...integration } = integrationWithSecrets; + const innerHandler = vpnSummaryHandler.handler(integrationWithSecrets, {}); + const unsubscribe = innerHandler.subscribe((summary) => { + emit.next({ + integration, + summary, + }); + }); + unsubscribes.push(unsubscribe); + } + return () => { + unsubscribes.forEach((unsubscribe) => { + unsubscribe(); + }); + }; + }); + }), +}); diff --git a/packages/cron-jobs/src/index.ts b/packages/cron-jobs/src/index.ts index 576e001c9..1f0cb8577 100644 --- a/packages/cron-jobs/src/index.ts +++ b/packages/cron-jobs/src/index.ts @@ -21,6 +21,7 @@ import { refreshNotificationsJob } from "./jobs/integrations/notifications"; import { timetableJob } from "./jobs/integrations/timetable"; import { tracearrJob } from "./jobs/integrations/tracearr"; import { umamiActiveVisitorsJob } from "./jobs/integrations/umami"; +import { vpnJob } from "./jobs/integrations/vpn"; import { minecraftServerStatusJob } from "./jobs/minecraft-server-status"; import { pingJob } from "./jobs/ping"; import { rssFeedsJob } from "./jobs/rss-feeds"; @@ -57,6 +58,7 @@ const getJobGroup = () => { timetable: timetableJob, tracearr: tracearrJob, umamiActiveVisitors: umamiActiveVisitorsJob, + vpn: vpnJob, }); }; diff --git a/packages/cron-jobs/src/jobs/integrations/vpn.ts b/packages/cron-jobs/src/jobs/integrations/vpn.ts new file mode 100644 index 000000000..b6495b359 --- /dev/null +++ b/packages/cron-jobs/src/jobs/integrations/vpn.ts @@ -0,0 +1,14 @@ +import { EVERY_30_SECONDS } from "@homarr/cron-jobs-core/expressions"; +import { vpnSummaryHandler } from "@homarr/request-handler/vpn"; +import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler"; + +import { createCronJob } from "../../lib"; + +export const vpnJob = createCronJob("vpn", EVERY_30_SECONDS).withCallback( + createRequestIntegrationJobHandler(vpnSummaryHandler.handler, { + widgetKinds: ["vpn"], + getInput: { + vpn: () => ({}), + }, + }), +); diff --git a/packages/definitions/src/docs/homarr-docs-sitemap.ts b/packages/definitions/src/docs/homarr-docs-sitemap.ts index 1af579b5f..c49be1f53 100644 --- a/packages/definitions/src/docs/homarr-docs-sitemap.ts +++ b/packages/definitions/src/docs/homarr-docs-sitemap.ts @@ -337,6 +337,7 @@ export type HomarrDocumentationPath = | "/docs/integrations/audiobookshelf" | "/docs/integrations/navidrome" | "/docs/integrations/umami" + | "/docs/integrations/gluetun" | "/docs/integrations/peanut" | "/docs/integrations/beszel" | "/docs/widgets/clock" @@ -380,6 +381,7 @@ export type HomarrDocumentationPath = | "/docs/widgets/uptime-kuma" | "/docs/widgets/audio-stats" | "/docs/widgets/umami" + | "/docs/widgets/vpn" | "/docs/widgets/ups" | "/docs/widgets/beszel-system-table" | "/docs/widgets/beszel-system-grid" diff --git a/packages/definitions/src/docs/integration-doc-slugs.ts b/packages/definitions/src/docs/integration-doc-slugs.ts index 99ec6ebb0..90c3cae8b 100644 --- a/packages/definitions/src/docs/integration-doc-slugs.ts +++ b/packages/definitions/src/docs/integration-doc-slugs.ts @@ -55,6 +55,7 @@ export const integrationDocSlugs: Record = { audiobookshelf: "audiobookshelf", navidrome: "navidrome", umami: "umami", + gluetun: "gluetun", peaNut: "peanut", beszel: "beszel", archiveTeamWarrior: null, diff --git a/packages/definitions/src/docs/widget-doc-slugs.ts b/packages/definitions/src/docs/widget-doc-slugs.ts index 3ffae6408..2e34a4843 100644 --- a/packages/definitions/src/docs/widget-doc-slugs.ts +++ b/packages/definitions/src/docs/widget-doc-slugs.ts @@ -43,6 +43,7 @@ export const widgetDocSlugs: Record = { uptimeKuma: "uptime-kuma", audioStats: "audio-stats", umami: "umami", + vpn: "vpn", ups: "ups", archiveTeamWarrior: null, beszelSystemTable: "beszel-system-table", diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index 8efa552e4..abb4ffdfd 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -489,6 +489,13 @@ export const integrationDefs = { documentationUrl: createDocumentationLink("/docs/integrations/beszel"), defaultPort: 8090, }, + gluetun: { + name: "Gluetun", + secretKinds: [["username", "password"], ["apiKey"], []], + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/gluetun.svg", + category: ["vpn"], + documentationUrl: createDocumentationLink("/docs/integrations/gluetun"), + }, archiveTeamWarrior: { name: "ArchiveTeam Warrior", secretKinds: [[], ["username", "password"]], @@ -604,6 +611,7 @@ export const integrationCategories = [ "mediaMonitoring", "speedtest", "analytics", + "vpn", "archiving", "ups", "documents", diff --git a/packages/definitions/src/widget-integration-map.ts b/packages/definitions/src/widget-integration-map.ts index 87898a49e..a560f2979 100644 --- a/packages/definitions/src/widget-integration-map.ts +++ b/packages/definitions/src/widget-integration-map.ts @@ -38,6 +38,7 @@ export const widgetIntegrationSupport: Partial { + const authHeaders = this.getAuthHeaders(); + const url = this.url("/v1/vpn/status"); + const response = await input.fetchAsync(url, { + headers: authHeaders, + }); + + if (!response.ok) { + return TestConnectionError.StatusResult(response); + } + + return { success: true }; + } + + public async getSummaryAsync(): Promise { + const authHeaders = this.getAuthHeaders(); + const [vpnStatus, dnsStatus, publicIp, vpnSettings] = await Promise.all([ + this.getVpnStatusAsync(authHeaders), + this.getDnsStatusAsync(authHeaders), + this.getPublicIpAsync(authHeaders), + this.getVpnSettingAsync(authHeaders), + ]); + + return { + vpnStatus: vpnStatus.status, + dnsStatus: dnsStatus.status, + publicIp: publicIp.public_ip, + country: publicIp.country, + city: publicIp.city, + vpnProvider: { + protocol: vpnSettings.type, + provider: vpnSettings.provider.name, + }, + }; + } + + private async getVpnStatusAsync(authHeaders: Record): Promise { + const url = this.url("/v1/vpn/status"); + + const response = await fetchWithTrustedCertificatesAsync(url, { + headers: authHeaders, + }); + + if (!response.ok) throw new ResponseError(response); + return gluetunVpnStatusSchema.parse(await response.json()); + } + + private async getDnsStatusAsync(authHeaders: Record): Promise { + const url = this.url("/v1/dns/status"); + + const response = await fetchWithTrustedCertificatesAsync(url, { + headers: authHeaders, + }); + + if (!response.ok) throw new ResponseError(response); + return gluetunDnsStatusSchema.parse(await response.json()); + } + + private async getPublicIpAsync(authHeaders: Record): Promise { + const url = this.url("/v1/publicip/ip"); + + const response = await fetchWithTrustedCertificatesAsync(url, { + headers: authHeaders, + }); + + if (!response.ok) throw new ResponseError(response); + return gluetunPublicIpSchema.parse(await response.json()); + } + + private async getVpnSettingAsync(authHeaders: Record): Promise { + const url = this.url("/v1/vpn/settings"); + + const response = await fetchWithTrustedCertificatesAsync(url, { + headers: authHeaders, + }); + + if (!response.ok) throw new ResponseError(response); + return gluetunVpnSettingsSchema.parse(await response.json()); + } + + private getAuthHeaders(): Record { + if (this.hasSecretValue("apiKey")) { + return { "X-API-Key": this.getSecretValue("apiKey") }; + } + + if (this.hasSecretValue("username") && this.hasSecretValue("password")) { + const username = this.getSecretValue("username"); + const password = this.getSecretValue("password"); + + const encodedCredentials = btoa(`${username}:${password}`); + + return { Authorization: `Basic ${encodedCredentials}` }; + } + + return {}; + } +} diff --git a/packages/integrations/src/gluetun/gluetun-types.ts b/packages/integrations/src/gluetun/gluetun-types.ts new file mode 100644 index 000000000..6886d72a7 --- /dev/null +++ b/packages/integrations/src/gluetun/gluetun-types.ts @@ -0,0 +1,116 @@ +import { z } from "zod/v4"; + +export const gluetunVpnStatusSchema = z.object({ + status: z.string(), +}); + +export type GluetunVpnStatus = z.infer; + +export const gluetunDnsStatusSchema = gluetunVpnStatusSchema; + +export type GluetunDnsStatus = z.infer; + +export const gluetunPublicIpSchema = z.object({ + public_ip: z.string(), + region: z.string(), + country: z.string(), + city: z.string(), + location: z.string(), + organization: z.string(), + postal_code: z.string(), + timezone: z.string(), +}); + +export type GluetunPublicIp = z.infer; + +const gluetunServerSelectionOpenvpnSchema = z.object({ + config_file_path: z.string(), + protocol: z.string(), + endpoint_ip: z.string(), + custom_port: z.number(), + pia_encryption_preset: z.string(), +}); + +const gluetunServerSelectionWireguardSchema = z.object({ + endpoint_ip: z.string(), + endpoint_port: z.number(), + public_key: z.string(), +}); + +const gluetunServerSelectionSchema = z.object({ + vpn: z.string(), + countries: z.array(z.string()).nullable(), + categories: z.array(z.string()).nullable(), + regions: z.array(z.string()).nullable(), + cities: z.array(z.string()).nullable(), + isps: z.array(z.string()).nullable(), + names: z.array(z.string()).nullable(), + numbers: z.array(z.number()).nullable(), + hostnames: z.array(z.string()), + owned_only: z.boolean(), + free_only: z.boolean(), + premium_only: z.boolean(), + stream_only: z.boolean(), + multi_hop_only: z.boolean(), + port_forward_only: z.boolean(), + secure_core_only: z.boolean(), + tor_only: z.boolean(), + openvpn: gluetunServerSelectionOpenvpnSchema, + wireguard: gluetunServerSelectionWireguardSchema, +}); + +const gluetunPortForwardingSchema = z.object({ + enabled: z.boolean(), + provider: z.string(), + status_file_path: z.string(), + up_command: z.string(), + down_command: z.string(), + listening_port: z.number(), + username: z.string(), + password: z.string(), +}); + +const gluetunProviderSchema = z.object({ + name: z.string(), + server_selection: gluetunServerSelectionSchema, + port_forwarding: gluetunPortForwardingSchema, +}); + +const gluetunOpenvpnSettingsSchema = z.object({ + version: z.string(), + user: z.string(), + password: z.string(), + config_file_path: z.string(), + ciphers: z.array(z.string()).nullable(), + auth: z.string(), + cert: z.string(), + key: z.string(), + encrypted_key: z.string(), + key_passphrase: z.string(), + pia_encryption_preset: z.string(), + mssfix: z.number(), + interface: z.string(), + process_user: z.string(), + verbosity: z.number(), + flags: z.array(z.string()).nullable(), +}); + +const gluetunWireguardSettingsSchema = z.object({ + private_key: z.string(), + pre_shared_key: z.string(), + addresses: z.array(z.string()), + allowed_ips: z.array(z.string()), + interface: z.string(), + persistent_keep_alive_interval: z.number(), + mtu: z.number(), + implementation: z.string(), +}); + +export const gluetunVpnSettingsSchema = z.object({ + type: z.string(), + provider: gluetunProviderSchema, + openvpn: gluetunOpenvpnSettingsSchema, + wireguard: gluetunWireguardSettingsSchema, +}); + +export type GluetunVpnSettings = z.infer; diff --git a/packages/integrations/src/gluetun/test/gluetun-integration.spec.ts b/packages/integrations/src/gluetun/test/gluetun-integration.spec.ts new file mode 100644 index 000000000..1f84005f3 --- /dev/null +++ b/packages/integrations/src/gluetun/test/gluetun-integration.spec.ts @@ -0,0 +1,371 @@ +import { Response } from "undici"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { createDb } from "@homarr/db/test"; + +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +import type { IntegrationTestingInput } from "../../base/integration"; +import { GluetunIntegration } from "../gluetun-integration"; +import { + gluetunDnsStatusSchema, + gluetunPublicIpSchema, + gluetunVpnSettingsSchema, + gluetunVpnStatusSchema, +} from "../gluetun-types"; + +vi.mock("@homarr/db", async (importActual) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const actual = await importActual(); + return { + ...actual, + db: createDb(), + }; +}); + +vi.mock("@homarr/core/infrastructure/http", () => ({ + fetchWithTrustedCertificatesAsync: vi.fn(), +})); + +const TEST_URL = "https://gluetun.example.com"; +const TEST_API_KEY = "test-api-key"; +const TEST_USERNAME = "user"; +const TEST_PASSWORD = "p@ss"; + +const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync); + +type FetchResponse = Awaited>; + +const VPN_STATUS_PAYLOAD = { status: "running" }; +const DNS_STATUS_PAYLOAD = { status: "running" }; +const PUBLIC_IP_PAYLOAD = { + public_ip: "203.0.113.42", + region: "Europe", + country: "Germany", + city: "Berlin", + location: "52.5200,13.4050", + organization: "Example Org", + postal_code: "10115", + timezone: "Europe/Berlin", +}; +const VPN_SETTINGS_PAYLOAD = { + type: "wireguard", + provider: { + name: "mullvad", + server_selection: { + vpn: "wireguard", + countries: ["Germany"], + categories: null, + regions: null, + cities: ["Berlin"], + isps: null, + names: null, + numbers: null, + hostnames: ["de-ber-wg-001"], + owned_only: false, + free_only: false, + premium_only: false, + stream_only: false, + multi_hop_only: false, + port_forward_only: false, + secure_core_only: false, + tor_only: false, + openvpn: { + config_file_path: "", + protocol: "udp", + endpoint_ip: "", + custom_port: 0, + pia_encryption_preset: "", + }, + wireguard: { + endpoint_ip: "203.0.113.1", + endpoint_port: 51820, + public_key: "pubkey", + }, + }, + port_forwarding: { + enabled: false, + provider: "mullvad", + status_file_path: "", + up_command: "", + down_command: "", + listening_port: 0, + username: "", + password: "", + }, + }, + openvpn: { + version: "", + user: "", + password: "", + config_file_path: "", + ciphers: null, + auth: "", + cert: "", + key: "", + encrypted_key: "", + key_passphrase: "", + pia_encryption_preset: "", + mssfix: 0, + interface: "tun0", + process_user: "", + verbosity: 0, + flags: null, + }, + wireguard: { + private_key: "", + pre_shared_key: "", + addresses: ["10.0.0.2/32"], + allowed_ips: ["0.0.0.0/0"], + interface: "wg0", + persistent_keep_alive_interval: 25, + mtu: 1420, + implementation: "auto", + }, +}; + +const jsonResponse = (body: unknown, init: ResponseInit = { status: 200 }): FetchResponse => + new Response(JSON.stringify(body), { + ...init, + headers: { "content-type": "application/json" }, + }) as unknown as FetchResponse; + +const setupHappyPath = () => { + mockFetch.mockImplementation((url) => { + const urlString = typeof url === "string" ? url : url.toString(); + const { pathname } = new URL(urlString); + + switch (pathname) { + case "/v1/vpn/status": + return Promise.resolve(jsonResponse(VPN_STATUS_PAYLOAD)); + case "/v1/dns/status": + return Promise.resolve(jsonResponse(DNS_STATUS_PAYLOAD)); + case "/v1/publicip/ip": + return Promise.resolve(jsonResponse(PUBLIC_IP_PAYLOAD)); + case "/v1/vpn/settings": + return Promise.resolve(jsonResponse(VPN_SETTINGS_PAYLOAD)); + default: + return Promise.resolve(jsonResponse({ error: "not found" }, { status: 404 })); + } + }); +}; + +const createIntegrationWithApiKey = () => + new GluetunIntegration({ + id: "gluetun-1", + name: "Gluetun", + url: TEST_URL, + externalUrl: null, + decryptedSecrets: [{ kind: "apiKey", value: TEST_API_KEY }], + }); + +const createIntegrationWithBasicAuth = () => + new GluetunIntegration({ + id: "gluetun-2", + name: "Gluetun", + url: TEST_URL, + externalUrl: null, + decryptedSecrets: [ + { kind: "username", value: TEST_USERNAME }, + { kind: "password", value: TEST_PASSWORD }, + ], + }); + +describe("GluetunIntegration.getSummaryAsync", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("maps the four upstream responses into a flat VPN summary", async () => { + setupHappyPath(); + + const result = await createIntegrationWithApiKey().getSummaryAsync(); + + expect(result).toEqual({ + vpnStatus: "running", + dnsStatus: "running", + publicIp: "203.0.113.42", + country: "Germany", + city: "Berlin", + vpnProvider: { + protocol: "wireguard", + provider: "mullvad", + }, + }); + }); + + test("hits each of the four control endpoints exactly once", async () => { + setupHappyPath(); + + await createIntegrationWithApiKey().getSummaryAsync(); + + const calledPaths = mockFetch.mock.calls.map(([url]) => { + const urlString = typeof url === "string" ? url : url.toString(); + return new URL(urlString).pathname; + }); + + expect(calledPaths).toHaveLength(4); + expect(new Set(calledPaths)).toEqual( + new Set(["/v1/vpn/status", "/v1/dns/status", "/v1/publicip/ip", "/v1/vpn/settings"]), + ); + }); + + test("sends X-API-Key header when an apiKey secret is configured", async () => { + setupHappyPath(); + + await createIntegrationWithApiKey().getSummaryAsync(); + + for (const [, init] of mockFetch.mock.calls) { + const headers = (init?.headers ?? {}) as Record; + expect(headers["X-API-Key"]).toBe(TEST_API_KEY); + expect(headers.Authorization).toBeUndefined(); + } + }); + + test("sends Basic auth header when username/password are configured", async () => { + setupHappyPath(); + + await createIntegrationWithBasicAuth().getSummaryAsync(); + + const expected = `Basic ${btoa(`${TEST_USERNAME}:${TEST_PASSWORD}`)}`; + for (const [, init] of mockFetch.mock.calls) { + const headers = (init?.headers ?? {}) as Record; + expect(headers.Authorization).toBe(expected); + expect(headers["X-API-Key"]).toBeUndefined(); + } + }); + + test("throws when any upstream endpoint returns a non-OK status", async () => { + mockFetch.mockImplementation((url) => { + const urlString = typeof url === "string" ? url : url.toString(); + const { pathname } = new URL(urlString); + + if (pathname === "/v1/publicip/ip") { + return Promise.resolve(jsonResponse({ error: "unauthorized" }, { status: 401 })); + } + return Promise.resolve(jsonResponse(VPN_STATUS_PAYLOAD)); + }); + + await expect(createIntegrationWithApiKey().getSummaryAsync()).rejects.toThrow(); + }); + + test("throws when an upstream payload fails schema validation", async () => { + mockFetch.mockImplementation((url) => { + const urlString = typeof url === "string" ? url : url.toString(); + const { pathname } = new URL(urlString); + + if (pathname === "/v1/publicip/ip") { + return Promise.resolve(jsonResponse({ public_ip: 123 })); + } + return Promise.resolve(jsonResponse(VPN_STATUS_PAYLOAD)); + }); + + await expect(createIntegrationWithApiKey().getSummaryAsync()).rejects.toThrow(); + }); +}); + +describe("GluetunIntegration testing endpoint", () => { + type TestingFn = (input: IntegrationTestingInput) => Promise<{ success: boolean }>; + + const invokeTesting = (integration: GluetunIntegration, input: IntegrationTestingInput) => { + const testing = (integration as unknown as { testingAsync: TestingFn }).testingAsync.bind(integration); + return testing(input); + }; + + const makeInput = (response: Response): IntegrationTestingInput => + ({ + fetchAsync: vi.fn().mockResolvedValue(response), + }) as unknown as IntegrationTestingInput; + + test("returns success when /v1/vpn/status responds 200", async () => { + const input = makeInput(new Response(JSON.stringify(VPN_STATUS_PAYLOAD), { status: 200 })); + + const result = await invokeTesting(createIntegrationWithApiKey(), input); + + expect(result.success).toBe(true); + }); + + test("returns a status-code failure when /v1/vpn/status responds non-OK", async () => { + const input = makeInput(new Response("unauthorized", { status: 401 })); + + const result = await invokeTesting(createIntegrationWithApiKey(), input); + + expect(result.success).toBe(false); + }); + + test("passes the api-key header through to fetchAsync", async () => { + const response = new Response(JSON.stringify(VPN_STATUS_PAYLOAD), { status: 200 }); + const input = makeInput(response); + + await invokeTesting(createIntegrationWithApiKey(), input); + + const fetchAsyncMock = vi.mocked(input.fetchAsync); + const init = fetchAsyncMock.mock.calls[0]?.[1]; + const headers = (init?.headers ?? {}) as Record; + expect(headers["X-API-Key"]).toBe(TEST_API_KEY); + }); + + test("passes the Basic auth header through to fetchAsync when username/password are configured", async () => { + const response = new Response(JSON.stringify(VPN_STATUS_PAYLOAD), { status: 200 }); + const input = makeInput(response); + + await invokeTesting(createIntegrationWithBasicAuth(), input); + + const fetchAsyncMock = vi.mocked(input.fetchAsync); + const init = fetchAsyncMock.mock.calls[0]?.[1]; + const headers = (init?.headers ?? {}) as Record; + const expected = `Basic ${btoa(`${TEST_USERNAME}:${TEST_PASSWORD}`)}`; + expect(headers.Authorization).toBe(expected); + expect(headers["X-API-Key"]).toBeUndefined(); + }); +}); + +describe("gluetun schemas", () => { + test("vpn status schema accepts a string status", () => { + expect(gluetunVpnStatusSchema.parse({ status: "running" })).toEqual({ status: "running" }); + }); + + test("vpn status schema rejects a missing status", () => { + expect(() => gluetunVpnStatusSchema.parse({})).toThrow(); + }); + + test("dns status schema mirrors vpn status schema", () => { + expect(gluetunDnsStatusSchema.parse({ status: "stopped" })).toEqual({ status: "stopped" }); + expect(() => gluetunDnsStatusSchema.parse({ status: 42 })).toThrow(); + }); + + test("public ip schema parses the full payload", () => { + expect(gluetunPublicIpSchema.parse(PUBLIC_IP_PAYLOAD)).toEqual(PUBLIC_IP_PAYLOAD); + }); + + test("public ip schema rejects a missing required field", () => { + const { city: _city, ...partial } = PUBLIC_IP_PAYLOAD; + expect(() => gluetunPublicIpSchema.parse(partial)).toThrow(); + }); + + test("vpn settings schema parses a wireguard payload", () => { + const parsed = gluetunVpnSettingsSchema.parse(VPN_SETTINGS_PAYLOAD); + expect(parsed.type).toBe("wireguard"); + expect(parsed.provider.name).toBe("mullvad"); + }); + + test("vpn settings schema accepts nullable server-selection arrays", () => { + const payloadWithNulls = { + ...VPN_SETTINGS_PAYLOAD, + provider: { + ...VPN_SETTINGS_PAYLOAD.provider, + server_selection: { + ...VPN_SETTINGS_PAYLOAD.provider.server_selection, + countries: null, + cities: null, + }, + }, + }; + + expect(() => gluetunVpnSettingsSchema.parse(payloadWithNulls)).not.toThrow(); + }); + + test("vpn settings schema rejects a non-string type", () => { + expect(() => gluetunVpnSettingsSchema.parse({ ...VPN_SETTINGS_PAYLOAD, type: 1 })).toThrow(); + }); +}); diff --git a/packages/integrations/src/gotify/gotify-integration.ts b/packages/integrations/src/gotify/gotify-integration.ts index b7fd9361e..1d0f5eb8c 100644 --- a/packages/integrations/src/gotify/gotify-integration.ts +++ b/packages/integrations/src/gotify/gotify-integration.ts @@ -15,9 +15,9 @@ export class GotifyIntegration extends Integration implements INotificationsInte } private getHeaders() { - const credentials = Buffer.from( - `${super.getSecretValue("username")}:${super.getSecretValue("password")}`, - ).toString("base64"); + const credentials = Buffer.from(`${super.getSecretValue("username")}:${super.getSecretValue("password")}`).toString( + "base64", + ); return { Authorization: `Basic ${credentials}` }; } @@ -31,11 +31,13 @@ export class GotifyIntegration extends Integration implements INotificationsInte if (!result.success) throw new Error(`Failed to parse Gotify response: ${result.error.message}`); const parsed = result.data; - return parsed.messages.map((message): Notification => ({ - id: String(message.id), - time: new Date(message.date), - title: message.title, - body: message.message, - })); + return parsed.messages.map( + (message): Notification => ({ + id: String(message.id), + time: new Date(message.date), + title: message.title, + body: message.message, + }), + ); } } diff --git a/packages/integrations/src/interfaces/vpn-summary/vpn-summary-integration.ts b/packages/integrations/src/interfaces/vpn-summary/vpn-summary-integration.ts new file mode 100644 index 000000000..39e500d30 --- /dev/null +++ b/packages/integrations/src/interfaces/vpn-summary/vpn-summary-integration.ts @@ -0,0 +1,5 @@ +import type { VpnSummary } from "./vpn-summary-types"; + +export interface VpnSummaryIntegration { + getSummaryAsync(): Promise; +} diff --git a/packages/integrations/src/interfaces/vpn-summary/vpn-summary-types.ts b/packages/integrations/src/interfaces/vpn-summary/vpn-summary-types.ts new file mode 100644 index 000000000..91e70454c --- /dev/null +++ b/packages/integrations/src/interfaces/vpn-summary/vpn-summary-types.ts @@ -0,0 +1,13 @@ +export interface VpnProviderDetails { + protocol: string; + provider: string; +} + +export interface VpnSummary { + vpnStatus: string; + dnsStatus: string; + publicIp: string; + country: string; + city: string; + vpnProvider: VpnProviderDetails; +} diff --git a/packages/integrations/src/types.ts b/packages/integrations/src/types.ts index 1ee395dfa..afeaf1db2 100644 --- a/packages/integrations/src/types.ts +++ b/packages/integrations/src/types.ts @@ -4,6 +4,7 @@ export * from "./interfaces/network-controller-summary/network-controller-summar export * from "./interfaces/firewall-summary/firewall-summary-types"; export * from "./interfaces/health-monitoring/health-monitoring-types"; export * from "./interfaces/ups-summary/ups-summary-types"; +export * from "./interfaces/vpn-summary/vpn-summary-types"; export * from "./interfaces/indexer-manager/indexer-manager-types"; export * from "./interfaces/media-requests/media-request-types"; export * from "./base/searchable-integration"; @@ -22,4 +23,5 @@ export * from "./audiobookshelf/audiobookshelf-types"; export * from "./navidrome/navidrome-types"; export * from "./paperless-ngx/paperless-ngx-types"; export * from "./umami/umami-types"; +export * from "./gluetun/gluetun-types"; export * from "./beszel/beszel-types"; diff --git a/packages/integrations/test/technitium.spec.ts b/packages/integrations/test/technitium.spec.ts index 99c9d2610..77189b556 100644 --- a/packages/integrations/test/technitium.spec.ts +++ b/packages/integrations/test/technitium.spec.ts @@ -2,15 +2,7 @@ import type { StartedTestContainer } from "testcontainers"; import { GenericContainer, Wait } from "testcontainers"; -import { - beforeAll, - beforeEach, - afterAll, - describe, - expect, - test, - vi, -} from "vitest"; +import { beforeAll, beforeEach, afterAll, describe, expect, test, vi } from "vitest"; import { createDb } from "@homarr/db/test"; @@ -31,15 +23,10 @@ vi.mock("@homarr/db", async (importActual) => { vi.mock("@homarr/core/infrastructure/certificates", async (importActual) => { // eslint-disable-next-line @typescript-eslint/consistent-type-imports - const actual = - await importActual< - typeof import("@homarr/core/infrastructure/certificates") - >(); + const actual = await importActual(); return { ...actual, - getTrustedCertificateHostnamesAsync: vi - .fn() - .mockImplementation(() => Promise.resolve([])), + getTrustedCertificateHostnamesAsync: vi.fn().mockImplementation(() => Promise.resolve([])), }; }); @@ -90,9 +77,7 @@ const INTEGRATION_ID = "test-technitium"; const makeInput = ( baseUrl: string, - auth: - | { kind: "credentials"; password?: string } - | { kind: "apiKey"; value: string }, + auth: { kind: "credentials"; password?: string } | { kind: "apiKey"; value: string }, ): IntegrationInput => ({ id: INTEGRATION_ID, name: "Technitium DNS", @@ -109,10 +94,7 @@ const makeInput = ( // Fetch an API token appropriate for the server version. // v15 has a dedicated createToken endpoint; legacy reuses the session token from login. -async function fetchApiToken( - baseUrl: string, - apiVersion: TechnitiumVersion, -): Promise { +async function fetchApiToken(baseUrl: string, apiVersion: TechnitiumVersion): Promise { if (apiVersion === "v15") { // v15 exposes a dedicated endpoint for non-expiring API tokens. const res = await fetch( @@ -122,9 +104,7 @@ async function fetchApiToken( } // Legacy: /api/user/login exists on all tested versions (v11–v14) and returns a session // token that can be used as a long-lived API key via the ?token= query parameter. - const res = await fetch( - `${baseUrl}/api/user/login?user=${DEFAULT_USERNAME}&pass=${DEFAULT_PASSWORD}`, - ); + const res = await fetch(`${baseUrl}/api/user/login?user=${DEFAULT_USERNAME}&pass=${DEFAULT_PASSWORD}`); return ((await res.json()) as { token: string }).token; } @@ -144,16 +124,12 @@ const pool: Record = {}; beforeAll(async () => { await Promise.all( VERSIONS.map(async ({ tag, apiVersion }) => { - const container = await new GenericContainer( - `technitium/dns-server:${tag}`, - ) + const container = await new GenericContainer(`technitium/dns-server:${tag}`) .withExposedPorts(5380) // Explicitly set the admin password so all versions use the same credentials. // Without this, older versions may generate a random password on first run. .withEnvironment({ DNS_SERVER_ADMIN_PASSWORD: DEFAULT_PASSWORD }) - .withWaitStrategy( - Wait.forLogMessage("Technitium DNS Server was started successfully."), - ) + .withWaitStrategy(Wait.forLogMessage("Technitium DNS Server was started successfully.")) .start(); const baseUrl = `http://${container.getHost()}:${container.getMappedPort(5380)}`; const apiToken = await fetchApiToken(baseUrl, apiVersion); @@ -163,238 +139,209 @@ beforeAll(async () => { }, 120_000); afterAll(async () => { - await Promise.all( - Object.values(pool).map(({ container }) => container.stop()), - ); + await Promise.all(Object.values(pool).map(({ container }) => container.stop())); }); // ─── version-parameterised suites ──────────────────────────────────────────── -describe.each(VERSIONS)( - "Technitium DNS $tag ($apiVersion)", - ({ tag, apiVersion }) => { - let baseUrl: string; - let apiToken: string; +describe.each(VERSIONS)("Technitium DNS $tag ($apiVersion)", ({ tag, apiVersion }) => { + let baseUrl: string; + let apiToken: string; - beforeAll(() => { - const entry = pool[tag]; - if (!entry) - throw new Error(`Container pool entry for ${tag} was not initialized`); - ({ baseUrl, apiToken } = entry); - }); + beforeAll(() => { + const entry = pool[tag]; + if (!entry) throw new Error(`Container pool entry for ${tag} was not initialized`); + ({ baseUrl, apiToken } = entry); + }); - beforeEach(() => { - sessionState[INTEGRATION_ID] = null; - }); + beforeEach(() => { + sessionState[INTEGRATION_ID] = null; + }); - // ── factory ──────────────────────────────────────────────────────────────── + // ── factory ──────────────────────────────────────────────────────────────── - describe("createTechnitiumDnsIntegrationAsync (factory)", () => { - test("factory creates a working integration (auto-detects version)", async () => { - const integration = await createTechnitiumDnsIntegrationAsync( - makeInput(baseUrl, { kind: "credentials" }), - ); + describe("createTechnitiumDnsIntegrationAsync (factory)", () => { + test("factory creates a working integration (auto-detects version)", async () => { + const integration = await createTechnitiumDnsIntegrationAsync(makeInput(baseUrl, { kind: "credentials" })); - const result = await integration.getSummaryAsync(); + const result = await integration.getSummaryAsync(); - expect(result.status).toBe("enabled"); - expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0); - }, 20_000); - }); + expect(result.status).toBe("enabled"); + expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0); + }, 20_000); + }); - // ── getSummaryAsync ──────────────────────────────────────────────────────── + // ── getSummaryAsync ──────────────────────────────────────────────────────── - describe("getSummaryAsync", () => { - test("returns valid summary with credentials", async () => { - const integration = new TechnitiumDnsIntegration( - makeInput(baseUrl, { kind: "credentials" }), - ); + describe("getSummaryAsync", () => { + test("returns valid summary with credentials", async () => { + const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" })); - const result = await integration.getSummaryAsync(); + const result = await integration.getSummaryAsync(); - expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0); - expect(result.adsBlockedToday).toBeGreaterThanOrEqual(0); - expect(result.adsBlockedTodayPercentage).toBeGreaterThanOrEqual(0); - expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0); - expect(result.status).toBe("enabled"); - }, 20_000); + expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0); + expect(result.adsBlockedToday).toBeGreaterThanOrEqual(0); + expect(result.adsBlockedTodayPercentage).toBeGreaterThanOrEqual(0); + expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0); + expect(result.status).toBe("enabled"); + }, 20_000); - test("returns valid summary with API key", async () => { - const integration = new TechnitiumDnsIntegration( - makeInput(baseUrl, { kind: "apiKey", value: apiToken }), - apiVersion, - ); + test("returns valid summary with API key", async () => { + const integration = new TechnitiumDnsIntegration( + makeInput(baseUrl, { kind: "apiKey", value: apiToken }), + apiVersion, + ); - const result = await integration.getSummaryAsync(); + const result = await integration.getSummaryAsync(); - expect(result.status).toBe("enabled"); - expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0); - }, 20_000); + expect(result.status).toBe("enabled"); + expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0); + }, 20_000); - test("domainsBeingBlocked is a non-negative number (blockedZones + blockListZones)", async () => { - const integration = new TechnitiumDnsIntegration( - makeInput(baseUrl, { kind: "credentials" }), - ); + test("domainsBeingBlocked is a non-negative number (blockedZones + blockListZones)", async () => { + const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" })); - const result = await integration.getSummaryAsync(); + const result = await integration.getSummaryAsync(); - expect(typeof result.domainsBeingBlocked).toBe("number"); - expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0); - }, 20_000); - }); + expect(typeof result.domainsBeingBlocked).toBe("number"); + expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0); + }, 20_000); + }); - // ── session caching ──────────────────────────────────────────────────────── + // ── session caching ──────────────────────────────────────────────────────── - describe("session caching", () => { - test("login is performed once and token is cached for subsequent calls", async () => { - const integration = new TechnitiumDnsIntegration( - makeInput(baseUrl, { kind: "credentials" }), - ); + describe("session caching", () => { + test("login is performed once and token is cached for subsequent calls", async () => { + const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" })); - await integration.getSummaryAsync(); - const tokenAfterFirst = sessionState[INTEGRATION_ID]?.token; - expect(tokenAfterFirst).toBeDefined(); + await integration.getSummaryAsync(); + const tokenAfterFirst = sessionState[INTEGRATION_ID]?.token; + expect(tokenAfterFirst).toBeDefined(); - await integration.getSummaryAsync(); - const tokenAfterSecond = sessionState[INTEGRATION_ID]?.token; + await integration.getSummaryAsync(); + const tokenAfterSecond = sessionState[INTEGRATION_ID]?.token; - expect(tokenAfterSecond).toBe(tokenAfterFirst); - }, 20_000); + expect(tokenAfterSecond).toBe(tokenAfterFirst); + }, 20_000); - test("recovers from an expired/invalid stored token by re-logging in", async () => { - // Plant a bad token with the correct version so it hits the right API path before failing - sessionState[INTEGRATION_ID] = { - token: "deliberately-invalid-token", - version: apiVersion, - }; + test("recovers from an expired/invalid stored token by re-logging in", async () => { + // Plant a bad token with the correct version so it hits the right API path before failing + sessionState[INTEGRATION_ID] = { + token: "deliberately-invalid-token", + version: apiVersion, + }; - const integration = new TechnitiumDnsIntegration( - makeInput(baseUrl, { kind: "credentials" }), - apiVersion, - ); + const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }), apiVersion); - const result = await integration.getSummaryAsync(); + const result = await integration.getSummaryAsync(); - expect(result.status).toBe("enabled"); - expect(sessionState[INTEGRATION_ID]).not.toBeNull(); - expect(sessionState[INTEGRATION_ID]?.token).not.toBe( - "deliberately-invalid-token", - ); - }, 20_000); - }); + expect(result.status).toBe("enabled"); + expect(sessionState[INTEGRATION_ID]).not.toBeNull(); + expect(sessionState[INTEGRATION_ID]?.token).not.toBe("deliberately-invalid-token"); + }, 20_000); + }); - // ── blocking state changes ───────────────────────────────────────────────── + // ── blocking state changes ───────────────────────────────────────────────── - describe("blocking state changes", () => { - test("disableAsync permanently disables blocking", async () => { - const integration = new TechnitiumDnsIntegration( - makeInput(baseUrl, { kind: "credentials" }), - ); + describe("blocking state changes", () => { + test("disableAsync permanently disables blocking", async () => { + const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" })); - await integration.disableAsync(); - const result = await integration.getSummaryAsync(); + await integration.disableAsync(); + const result = await integration.getSummaryAsync(); - expect(result.status).toBe("disabled"); + expect(result.status).toBe("disabled"); - await integration.enableAsync(); // restore - }, 20_000); + await integration.enableAsync(); // restore + }, 20_000); - test("enableAsync re-enables blocking after permanent disable", async () => { - const integration = new TechnitiumDnsIntegration( - makeInput(baseUrl, { kind: "credentials" }), - ); + test("enableAsync re-enables blocking after permanent disable", async () => { + const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" })); - await integration.disableAsync(); - await integration.enableAsync(); - const result = await integration.getSummaryAsync(); + await integration.disableAsync(); + await integration.enableAsync(); + const result = await integration.getSummaryAsync(); - expect(result.status).toBe("enabled"); - }, 20_000); + expect(result.status).toBe("enabled"); + }, 20_000); - test("disableAsync with duration temporarily disables blocking", async () => { - const integration = new TechnitiumDnsIntegration( - makeInput(baseUrl, { kind: "credentials" }), - ); + test("disableAsync with duration temporarily disables blocking", async () => { + const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" })); - await integration.disableAsync(120); // 120s → 2 min - const result = await integration.getSummaryAsync(); + await integration.disableAsync(120); // 120s → 2 min + const result = await integration.getSummaryAsync(); - expect(result.status).toBe("disabled"); + expect(result.status).toBe("disabled"); - await integration.enableAsync(); // restore - }, 20_000); + await integration.enableAsync(); // restore + }, 20_000); - test("disableAsync with small duration (1s) rounds up to 1 minute and disables", async () => { - const integration = new TechnitiumDnsIntegration( - makeInput(baseUrl, { kind: "credentials" }), - ); + test("disableAsync with small duration (1s) rounds up to 1 minute and disables", async () => { + const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" })); - await integration.disableAsync(1); - const result = await integration.getSummaryAsync(); + await integration.disableAsync(1); + const result = await integration.getSummaryAsync(); - expect(result.status).toBe("disabled"); + expect(result.status).toBe("disabled"); - await integration.enableAsync(); // restore - }, 20_000); + await integration.enableAsync(); // restore + }, 20_000); - test("enableAsync cancels an active timed disable immediately", async () => { - const integration = new TechnitiumDnsIntegration( - makeInput(baseUrl, { kind: "credentials" }), - ); + test("enableAsync cancels an active timed disable immediately", async () => { + const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" })); - await integration.disableAsync(600); // 10 minutes - await integration.enableAsync(); - const result = await integration.getSummaryAsync(); + await integration.disableAsync(600); // 10 minutes + await integration.enableAsync(); + const result = await integration.getSummaryAsync(); - expect(result.status).toBe("enabled"); - }, 20_000); - }); + expect(result.status).toBe("enabled"); + }, 20_000); + }); - // ── testConnectionAsync ──────────────────────────────────────────────────── + // ── testConnectionAsync ──────────────────────────────────────────────────── - describe("testConnectionAsync", () => { - test("succeeds with valid credentials", async () => { - const result = await new TechnitiumDnsIntegration( - makeInput(baseUrl, { kind: "credentials" }), - ).testConnectionAsync(); - expect(result.success).toBe(true); - }, 20_000); + describe("testConnectionAsync", () => { + test("succeeds with valid credentials", async () => { + const result = await new TechnitiumDnsIntegration( + makeInput(baseUrl, { kind: "credentials" }), + ).testConnectionAsync(); + expect(result.success).toBe(true); + }, 20_000); - test("succeeds with valid API key", async () => { - const result = await new TechnitiumDnsIntegration( - makeInput(baseUrl, { kind: "apiKey", value: apiToken }), - apiVersion, - ).testConnectionAsync(); - expect(result.success).toBe(true); - }, 20_000); + test("succeeds with valid API key", async () => { + const result = await new TechnitiumDnsIntegration( + makeInput(baseUrl, { kind: "apiKey", value: apiToken }), + apiVersion, + ).testConnectionAsync(); + expect(result.success).toBe(true); + }, 20_000); - test("fails with wrong password — returns authorization error", async () => { - const integration = new TechnitiumDnsIntegration( - makeInput(baseUrl, { - kind: "credentials", - password: "wrong-password", - }), - ); + test("fails with wrong password — returns authorization error", async () => { + const integration = new TechnitiumDnsIntegration( + makeInput(baseUrl, { + kind: "credentials", + password: "wrong-password", + }), + ); - const result = await integration.testConnectionAsync(); + const result = await integration.testConnectionAsync(); - expect(result.success).toBe(false); - if (result.success) return; - expect(result.error).toBeInstanceOf(TestConnectionError); - expect(result.error.type).toBe("authorization"); - }, 20_000); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toBeInstanceOf(TestConnectionError); + expect(result.error.type).toBe("authorization"); + }, 20_000); - test("fails with invalid API key — returns authorization error", async () => { - const result = await new TechnitiumDnsIntegration( - makeInput(baseUrl, { kind: "apiKey", value: "invalid-api-key-xyz" }), - apiVersion, - ).testConnectionAsync(); + test("fails with invalid API key — returns authorization error", async () => { + const result = await new TechnitiumDnsIntegration( + makeInput(baseUrl, { kind: "apiKey", value: "invalid-api-key-xyz" }), + apiVersion, + ).testConnectionAsync(); - expect(result.success).toBe(false); - if (result.success) return; - expect(result.error).toBeInstanceOf(TestConnectionError); - expect(result.error.type).toBe("authorization"); - }, 20_000); - }); - }, -); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toBeInstanceOf(TestConnectionError); + expect(result.error.type).toBe("authorization"); + }, 20_000); + }); +}); diff --git a/packages/request-handler/src/vpn.ts b/packages/request-handler/src/vpn.ts new file mode 100644 index 000000000..de068938c --- /dev/null +++ b/packages/request-handler/src/vpn.ts @@ -0,0 +1,35 @@ +import dayjs from "dayjs"; + +import { createLogger } from "@homarr/core/infrastructure/logs"; +import type { IntegrationKindByCategory } from "@homarr/definitions"; +import { createIntegrationAsync } from "@homarr/integrations"; +import type { VpnSummary } from "@homarr/integrations/types"; + +import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; + +const logger = createLogger({ module: "vpnRequestHandler" }); + +export const vpnSummaryHandler = createCachedIntegrationRequestHandler< + VpnSummary | null, + IntegrationKindByCategory<"vpn">, + Record +>({ + async requestAsync(integration, _input) { + try { + const integrationInstance = await createIntegrationAsync(integration); + return await integrationInstance.getSummaryAsync(); + } catch (error) { + // The VPN control server is unreachable (e.g. the container is down). + // Return null so the widget can render an "unavailable" status instead of + // surfacing a hard error, and so it recovers automatically once it is back up. + logger.warn("Failed to fetch VPN summary, marking as unavailable", { + integrationId: integration.id, + integrationKind: integration.kind, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } + }, + cacheDuration: dayjs.duration(5, "minutes"), + queryKey: "vpnSummary", +}); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index c831c6223..50628e908 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1021,6 +1021,7 @@ "mediaMonitoring": "Monitoring", "speedtest": "Speed Test", "analytics": "Analytics", + "vpn": "VPN", "mediaLibrary": "Media Library", "uptimeMonitoring": "Uptime Monitoring" }, @@ -2408,6 +2409,12 @@ "title": "Instances" } }, + "vpn": { + "name": "VPN", + "description": "Overview of your VPN connections", + "serviceUnavailable": "VPN unavailable", + "option": {} + }, "ups": { "name": "UPS", "description": "Monitor the status of your UPS devices through a NUT server", @@ -4588,6 +4595,9 @@ }, "umamiActiveVisitors": { "label": "Umami Active Visitors" + }, + "vpn": { + "label": "VPN Status" } }, "interval": { diff --git a/packages/widgets/src/docker/component.tsx b/packages/widgets/src/docker/component.tsx index b50139361..d6c1d004d 100644 --- a/packages/widgets/src/docker/component.tsx +++ b/packages/widgets/src/docker/component.tsx @@ -207,7 +207,7 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo acc.memory += safeValue(container.memoryUsage); return acc; }, - { cpu: 0, memory: 0 } + { cpu: 0, memory: 0 }, ); }, [containers]); @@ -283,7 +283,7 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo {t("table.totalCpu", { cpu: totals.cpu.toFixed(2) })} - + {t("table.totalMemory", { memory: humanFileSize(totals.memory) })} diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index 628c67e32..17e9b8eed 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -52,6 +52,7 @@ import * as timetable from "./timetable"; import * as tracearr from "./tracearr"; import * as umami from "./umami"; import * as ups from "./ups"; +import * as vpn from "./vpn"; import * as beszelSystemTable from "./beszel-system-table"; import * as beszelSystemGrid from "./beszel-system-grid"; import * as beszelAlerts from "./beszel-alerts"; @@ -108,6 +109,7 @@ export const widgetImports = { uptimeKuma, audioStats, umami, + vpn, ups, beszelSystemTable, beszelSystemGrid, diff --git a/packages/widgets/src/vpn/component.tsx b/packages/widgets/src/vpn/component.tsx new file mode 100644 index 000000000..85ee4e3cf --- /dev/null +++ b/packages/widgets/src/vpn/component.tsx @@ -0,0 +1,72 @@ +"use client"; + +import React from "react"; +import { Flex, ScrollArea, Stack } from "@mantine/core"; + +import { clientApi } from "@homarr/api/client"; + +import type { WidgetComponentProps } from "../definition"; +import { updateVpnInfoFromSubscription } from "./helpers"; +import { VpnIntegrationCard } from "./vpn-card"; + +export default function VpnWidget({ options, integrationIds }: WidgetComponentProps<"vpn">) { + const [integrations] = clientApi.widget.vpn.getSummaries.useSuspenseQuery( + { + ...options, + integrationIds, + }, + { + // Initial load only. Live updates arrive via the subscription below, which is + // fed by the VPN cron job publishing to the integration channel. + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + retry: false, + }, + ); + const utils = clientApi.useUtils(); + + clientApi.widget.vpn.subscribeSummaries.useSubscription( + { + integrationIds, + }, + { + onData: (data) => { + utils.widget.vpn.getSummaries.setData( + { + ...options, + integrationIds, + }, + (prevData) => updateVpnInfoFromSubscription(prevData, data), + ); + }, + }, + ); + + if (integrations.length === 1) { + // It will always have at least one integration as otherwise the NoIntegrationSelectedError would be thrown in item-content.tsx + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const vpn = integrations[0]!; + + return ( + + + + ); + } + + return ( + + + {integrations.map((vpn) => ( + + ))} + + + ); +} diff --git a/packages/widgets/src/vpn/helpers.ts b/packages/widgets/src/vpn/helpers.ts new file mode 100644 index 000000000..24a81720b --- /dev/null +++ b/packages/widgets/src/vpn/helpers.ts @@ -0,0 +1,22 @@ +import type { RouterOutputs } from "@homarr/api"; + +export const RUNNING_STATUS = "running"; + +type VpnInfoItem = RouterOutputs["widget"]["vpn"]["getSummaries"][number]; + +export function getStatusColor(status: string) { + return status === RUNNING_STATUS ? "green" : "red"; +} + +export function updateVpnInfoFromSubscription( + prevData: VpnInfoItem[] | undefined, + data: { integration: { id: string }; summary: VpnInfoItem["summary"] }, +): VpnInfoItem[] | undefined { + if (!prevData) { + return undefined; + } + + return prevData.map((item) => + item.integration.id === data.integration.id ? { ...item, summary: data.summary } : item, + ); +} diff --git a/packages/widgets/src/vpn/index.ts b/packages/widgets/src/vpn/index.ts new file mode 100644 index 000000000..426366a66 --- /dev/null +++ b/packages/widgets/src/vpn/index.ts @@ -0,0 +1,14 @@ +import { IconShieldLock } from "@tabler/icons-react"; + +import { getIntegrationKindsByCategory } from "@homarr/definitions"; + +import { createWidgetDefinition } from "../definition"; +import { optionsBuilder } from "../options"; + +export const { componentLoader, definition } = createWidgetDefinition("vpn", { + icon: IconShieldLock, + createOptions() { + return optionsBuilder.from(() => ({})); + }, + supportedIntegrations: getIntegrationKindsByCategory("vpn"), +}).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/vpn/vpn-card.tsx b/packages/widgets/src/vpn/vpn-card.tsx new file mode 100644 index 000000000..3cb26bdd5 --- /dev/null +++ b/packages/widgets/src/vpn/vpn-card.tsx @@ -0,0 +1,164 @@ +"use client"; + +import React from "react"; +import { Badge, Box, Card, Flex, Group, Text } from "@mantine/core"; +import { IconArrowsExchange, IconMapPin, IconShieldCheck, IconShieldX } from "@tabler/icons-react"; + +import type { RouterOutputs } from "@homarr/api"; +import { useRequiredBoard } from "@homarr/boards/context"; +import { useScopedI18n } from "@homarr/translation/client"; + +import { getStatusColor, RUNNING_STATUS } from "./helpers"; + +export type VpnInfo = RouterOutputs["widget"]["vpn"]["getSummaries"][number]["summary"]; + +export function VpnIntegrationCard({ + vpn, + integrationName, + variant = "single", +}: { + vpn: VpnInfo; + integrationName?: string; + variant?: "single" | "list"; +}) { + const board = useRequiredBoard(); + const compact = variant === "list"; + + const content = vpn ? ( + + + + + ) : ( + + ); + + if (compact) { + return ( + + {content} + + ); + } + + return {content}; +} + +function VpnUnavailableContent({ compact, integrationName }: { compact: boolean; integrationName?: string }) { + const t = useScopedI18n("widget.vpn"); + + return ( + + {/* Empty statuses render the shield and DNS badge in red (see getStatusColor). */} + + + {integrationName ? ( + + {integrationName} + + ) : null} + + {t("serviceUnavailable")} + + + + ); +} + +function VpnStatusColumn({ + vpnStatus, + dnsStatus, + compact, +}: { + vpnStatus: string; + dnsStatus: string; + compact: boolean; +}) { + // Connected shows a shield with a check; anything else (stopped or unavailable) shows a shield with a cross. + const ShieldIcon = vpnStatus === RUNNING_STATUS ? IconShieldCheck : IconShieldX; + + return ( + + + + + ); +} + +function VpnInfoColumn({ + integrationName, + publicIp, + city, + country, + provider, + protocol, + compact, +}: { + integrationName?: string; + publicIp: string; + city: string; + country: string; + provider: string; + protocol: string; + compact: boolean; +}) { + return ( + + {integrationName ? ( + + {integrationName} + + ) : null} + + {publicIp} + + + + + {city}, {country} + + + + + ); +} + +function VpnProviderDetails({ provider, protocol }: { provider: string; protocol: string }) { + return ( + + + {provider} + + + + {protocol} + + + ); +} + +function StatusDot({ status, size = 10 }: { status: string; size?: number }) { + return ; +} + +function DnsStatusBadge({ status }: { status: string }) { + return ( + } + > + DNS + + ); +} From 915c8a3501551fdccf786d0c34dc01107d873ce0 Mon Sep 17 00:00:00 2001 From: Thomas Date: Fri, 26 Jun 2026 14:42:13 +0200 Subject: [PATCH 04/11] fix: show app first in add item modal (#6087) --- .../src/components/board/items/item-select-modal.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/nextjs/src/components/board/items/item-select-modal.tsx b/apps/nextjs/src/components/board/items/item-select-modal.tsx index 726f135fa..2ac349c6d 100644 --- a/apps/nextjs/src/components/board/items/item-select-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-select-modal.tsx @@ -41,7 +41,12 @@ export const ItemSelectModal = createModal(({ actions }) => { name: t(`widget.${kind}.name`), description: t(`widget.${kind}.description`), })) - .sort((itemA, itemB) => itemA.name.localeCompare(itemB.name)), + .sort((itemA, itemB) => { + if (itemA.kind === "app") return -1; + if (itemB.kind === "app") return 1; + + return itemA.name.localeCompare(itemB.name); + }), [t], ); From 2443b89b46f88fddbf41f0382a9f3bc5469c6fa8 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Fri, 26 Jun 2026 05:42:36 -0700 Subject: [PATCH 05/11] fix(plex): use series title for media releases instead of season label (#6083) --- .../integrations/src/plex/plex-integration.ts | 13 +- .../src/plex/test/plex-integration.spec.ts | 140 ++++++++++++++++++ 2 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 packages/integrations/src/plex/test/plex-integration.spec.ts diff --git a/packages/integrations/src/plex/plex-integration.ts b/packages/integrations/src/plex/plex-integration.ts index 9c2cb8a4f..a031343c0 100644 --- a/packages/integrations/src/plex/plex-integration.ts +++ b/packages/integrations/src/plex/plex-integration.ts @@ -127,11 +127,18 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat const media = data.MediaContainer.Metadata?.filter((item) => item.Image).map((item) => { + const title = + item.type === "episode" + ? (item.grandparentTitle ?? item.title) + : item.type === "season" + ? (item.parentTitle ?? item.title) + : item.title; + return { id: item.Media?.at(0)?.id.toString() ?? item.key, type: mapType(item.type), - title: item.title, - subtitle: item.tagline, + title, + subtitle: title === item.title ? item.tagline : item.title, description: item.summary, releaseDate: item.originallyAvailableAt ? new Date(item.originallyAvailableAt) @@ -222,6 +229,8 @@ const recentlyAddedSchema = z.object({ studio: z.string().optional(), type: z.string(), // For example "movie", "album" title: z.string(), + parentTitle: z.string().optional(), + grandparentTitle: z.string().optional(), summary: z.string().optional(), duration: z.number().optional(), addedAt: z.number(), diff --git a/packages/integrations/src/plex/test/plex-integration.spec.ts b/packages/integrations/src/plex/test/plex-integration.spec.ts new file mode 100644 index 000000000..64e046256 --- /dev/null +++ b/packages/integrations/src/plex/test/plex-integration.spec.ts @@ -0,0 +1,140 @@ +// @vitest-environment node +import { Response } from "undici"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +import type { IntegrationSecret } from "../../base/types"; +import { PlexIntegration } from "../plex-integration"; + +const imageProxyMocks = vi.hoisted(() => ({ + createImageAsync: vi.fn((url: string) => Promise.resolve(`proxied:${url}`)), +})); + +vi.mock("@homarr/core/infrastructure/http", () => ({ + fetchWithTrustedCertificatesAsync: vi.fn(), +})); + +vi.mock("@homarr/image-proxy", () => ({ + ImageProxy: class { + createImageAsync = imageProxyMocks.createImageAsync; + }, +})); + +const TEST_URL = "https://plex.example.com"; +const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync); + +const secrets: IntegrationSecret[] = [{ kind: "apiKey", value: "plex-token" }]; + +const createIntegration = () => + new PlexIntegration({ + id: "test-plex", + name: "Test Plex", + url: TEST_URL, + externalUrl: null, + decryptedSecrets: secrets, + }); + +const createMetadataItem = (key: string, overrides: Record) => ({ + key: `/library/metadata/${key}`, + type: "movie", + title: "Movie", + addedAt: 1_735_689_600, + Image: [ + { + type: "coverPoster", + url: `/library/metadata/${key}/thumb`, + }, + ], + ...overrides, +}); + +beforeEach(() => { + mockFetch.mockReset(); + imageProxyMocks.createImageAsync.mockClear(); +}); + +describe("PlexIntegration.getMediaReleasesAsync", () => { + test("uses the series title for TV seasons and episodes", async () => { + mockFetch.mockImplementation((url) => { + const urlString = String(url); + + if (urlString.endsWith("/identity")) { + return Promise.resolve( + new Response(JSON.stringify({ MediaContainer: { machineIdentifier: "server-id" } }), { status: 200 }), + ) as unknown as ReturnType; + } + + if (urlString.endsWith("/library/recentlyAdded")) { + return Promise.resolve( + new Response( + JSON.stringify({ + MediaContainer: { + Metadata: [ + createMetadataItem("season", { + type: "season", + title: "Season 1", + parentTitle: "Breaking Bad", + }), + createMetadataItem("episode", { + type: "episode", + title: "Review", + parentTitle: "Season 1", + grandparentTitle: "The Bear", + }), + createMetadataItem("movie", { + type: "movie", + title: "Inception", + tagline: "Dreams feel real", + }), + createMetadataItem("show", { + type: "show", + title: "The Last of Us", + tagline: "A series tagline", + }), + createMetadataItem("missing-parent", { + type: "season", + title: "Season 3", + }), + ], + }, + }), + { status: 200 }, + ), + ) as unknown as ReturnType; + } + + throw new Error(`Unexpected Plex request: ${urlString}`); + }); + + const releases = await createIntegration().getMediaReleasesAsync(); + + expect(releases).toMatchObject([ + { + title: "Breaking Bad", + subtitle: "Season 1", + type: "tv", + }, + { + title: "The Bear", + subtitle: "Review", + type: "tv", + }, + { + title: "Inception", + subtitle: "Dreams feel real", + type: "movie", + }, + { + title: "The Last of Us", + subtitle: "A series tagline", + type: "tv", + }, + { + title: "Season 3", + subtitle: undefined, + type: "tv", + }, + ]); + }); +}); \ No newline at end of file From f0b2486c8ad2c1630b62de3d141cca2d836798a2 Mon Sep 17 00:00:00 2001 From: "homarr-renovate[bot]" <158783068+homarr-renovate[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:18:40 +0000 Subject: [PATCH 06/11] chore(deps): update dependency timezones-ical-library to ^2.2.1 (#6077) * chore(deps): update dependency timezones-ical-library to ^2.2.1 * style: auto-fix lint and format --------- Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com> --- .../definitions/src/docs/homarr-docs-sitemap.ts | 2 ++ pnpm-lock.yaml | 13 ++++++------- pnpm-workspace.yaml | 2 +- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/definitions/src/docs/homarr-docs-sitemap.ts b/packages/definitions/src/docs/homarr-docs-sitemap.ts index c49be1f53..4dbb8757a 100644 --- a/packages/definitions/src/docs/homarr-docs-sitemap.ts +++ b/packages/definitions/src/docs/homarr-docs-sitemap.ts @@ -179,6 +179,7 @@ export type HomarrDocumentationPath = | "/docs/integrations/github" | "/docs/integrations/gitlab" | "/docs/integrations/glances" + | "/docs/integrations/gluetun" | "/docs/integrations/gotify" | "/docs/integrations/home-assistant" | "/docs/integrations/ical" @@ -280,6 +281,7 @@ export type HomarrDocumentationPath = | "/docs/widgets/ups" | "/docs/widgets/uptime-kuma" | "/docs/widgets/video" + | "/docs/widgets/vpn" | "/docs/widgets/weather" | "" | "/sitemap.xml" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a3e6c5a9d..682dce7d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -502,8 +502,8 @@ catalogs: specifier: ^12.0.3 version: 12.0.3 timezones-ical-library: - specifier: ^2.2.0 - version: 2.2.0 + specifier: ^2.2.1 + version: 2.2.1 trpc-to-mcp: specifier: ^1.3.2 version: 1.3.2 @@ -1959,7 +1959,7 @@ importers: version: 1.1.1 timezones-ical-library: specifier: 'catalog:' - version: 2.2.0 + version: 2.2.1 tsdav: specifier: 'catalog:' version: 2.2.2 @@ -14248,9 +14248,8 @@ packages: resolution: {integrity: sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==} engines: {node: '>=12'} - timezones-ical-library@2.2.0: - resolution: {integrity: sha512-D8pKEBzYnCAGZSEyinMZqh2yTc7ZJqonXtNkpTzhPuJlkJjPEUJOXBs7ExeHcHGBaGpaJRTye5t0mLR20BeklA==} - engines: {node: '>=22.18.0', npm: '>=10.9.3'} + timezones-ical-library@2.2.1: + resolution: {integrity: sha512-VfDloy50AHsckqwuqc2ea+uyqxY7RjKXOqFMBd4T1zLkq31aVQNKhASvIF4+hSE2tuqaUGzGi4UQk5VzVNn94A==} tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -28784,7 +28783,7 @@ snapshots: dependencies: convert-hrtime: 5.0.0 - timezones-ical-library@2.2.0: {} + timezones-ical-library@2.2.1: {} tiny-invariant@1.3.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ea0305138..24ff4891f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -170,7 +170,7 @@ catalog: superjson: 2.2.6 svgson: ^5.3.1 testcontainers: ^12.0.3 - timezones-ical-library: ^2.2.0 + timezones-ical-library: ^2.2.1 trpc-to-mcp: ^1.3.2 trpc-to-openapi: ^3.3.0 tsdav: ^2.2.2 From 19f9769d9e669d56d793e9f6a0d265316113d743 Mon Sep 17 00:00:00 2001 From: Thomas Date: Fri, 26 Jun 2026 20:12:39 +0200 Subject: [PATCH 07/11] fix(manage/about): bypass self-fetch + add manage error boundary (LLM exploration) (#6009) * fix(manage/about): bypass self-fetch to prevent ECONNREFUSED crash The /manage/about page fetched its own API routes over HTTP (http://$HOSTNAME:3000/api/about/...). When the server could not reach itself (misconfigured HOSTNAME, restricted container networking, reverse-proxy setups), fetch threw ECONNREFUSED and the whole page crashed with a full-page Next.js error (issue #6008). All four routes were force-static serving static JSON / a direct fs read, so the HTTP round-trip was unnecessary. Replace the fetches with direct JSON imports and a direct getDependenciesAsync() call, eliminating the self-network dependency entirely. Also add a manage-level error.tsx boundary (Next.js 16 convention) so that any future runtime error in a manage page renders a friendly, localised fallback inside the existing layout (sidebar stays usable) instead of the ugly default full-page error. Exploration draft by an LLM. Refs #6008 * style: auto-fix lint and format * refactor: use @static-data path alias, simplify error ternary - Add @static-data/* tsconfig path alias to replace 7-level relative imports (../../../../../../../static-data/...) with clean alias imports across the about page and all 3 API route handlers. - Simplify error.tsx: replace ternary with && short-circuit. - Preserve CI lint bot's .sort() -> .toSorted() fix. Refs #6008 --- .../src/app/[locale]/manage/about/page.tsx | 36 +++------------- apps/nextjs/src/app/[locale]/manage/error.tsx | 42 +++++++++++++++++++ .../api/about/contributors/crowdin/route.ts | 2 +- .../api/about/contributors/github/route.ts | 2 +- .../contributors/opencollective/route.ts | 2 +- apps/nextjs/tsconfig.json | 1 + packages/translation/src/lang/en.json | 5 +++ 7 files changed, 57 insertions(+), 33 deletions(-) create mode 100644 apps/nextjs/src/app/[locale]/manage/error.tsx diff --git a/apps/nextjs/src/app/[locale]/manage/about/page.tsx b/apps/nextjs/src/app/[locale]/manage/about/page.tsx index 3f49db49c..9577d39a0 100644 --- a/apps/nextjs/src/app/[locale]/manage/about/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/about/page.tsx @@ -1,4 +1,3 @@ -import { headers } from "next/headers"; import Image from "next/image"; import { Accordion, @@ -41,11 +40,10 @@ import { getScopedI18n } from "@homarr/translation/server"; import { homarrLogoPath } from "~/components/layout/logo/homarr-logo"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { createMetaTitle } from "~/metadata"; -import type { PackageJsonDependencies } from "~/versions/package-reader"; -import { getPackageVersion } from "~/versions/package-reader"; -import type githubContributorsJson from "../../../../../../../static-data/contributors.json"; -import type openCollectiveContributorsJson from "../../../../../../../static-data/opencollective-contributors.json"; -import type crowdinContributorsJson from "../../../../../../../static-data/translators.json"; +import { getDependenciesAsync, getPackageVersion } from "~/versions/package-reader"; +import githubContributors from "@static-data/contributors.json"; +import openCollectiveContributors from "@static-data/opencollective-contributors.json"; +import crowdinContributors from "@static-data/translators.json"; import classes from "./about.module.css"; export async function generateMetadata() { @@ -56,32 +54,10 @@ export async function generateMetadata() { }; } -const getHostAsync = async () => { - if (process.env.HOSTNAME) { - return `${process.env.HOSTNAME}:3000`; - } - - return (await headers()).get("host"); -}; - export default async function AboutPage() { - const baseServerUrl = `http://${await getHostAsync()}`; const t = await getScopedI18n("management.page.about"); const version = getPackageVersion(); - const dependencies = (await fetch(`${baseServerUrl}/api/about/dependencies`).then((res) => - res.json(), - )) as PackageJsonDependencies; - const githubContributors = (await fetch(`${baseServerUrl}/api/about/contributors/github`).then((res) => - res.json(), - )) as typeof githubContributorsJson; - - const crowdinContributors = (await fetch(`${baseServerUrl}/api/about/contributors/crowdin`).then((res) => - res.json(), - )) as typeof crowdinContributorsJson; - - const openCollectiveContributors = (await fetch(`${baseServerUrl}/api/about/contributors/opencollective`).then( - (res) => res.json(), - )) as typeof openCollectiveContributorsJson; + const dependencies = await getDependenciesAsync(); return (
@@ -185,7 +161,7 @@ export default async function AboutPage() { {Object.entries(dependencies) .filter(([, value]) => !value.includes("workspace:")) - .sort(([key1], [key2]) => key1.localeCompare(key2)) + .toSorted(([key1], [key2]) => key1.localeCompare(key2)) .map(([name, version]) => ( void; +}) { + const tError = useScopedI18n("management.error"); + const t = useI18n(); + + useEffect(() => { + console.error(error); + }, [error]); + + return ( +
+ + + {tError("title")} + + {tError("text")} + + {error.digest && ( + + {tError("digest", { digest: error.digest })} + + )} + + +
+ ); +} diff --git a/apps/nextjs/src/app/api/about/contributors/crowdin/route.ts b/apps/nextjs/src/app/api/about/contributors/crowdin/route.ts index a149c082d..d55165906 100644 --- a/apps/nextjs/src/app/api/about/contributors/crowdin/route.ts +++ b/apps/nextjs/src/app/api/about/contributors/crowdin/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; -import crowdinContributors from "../../../../../../../../static-data/translators.json"; +import crowdinContributors from "@static-data/translators.json"; export const GET = () => { return NextResponse.json(crowdinContributors); diff --git a/apps/nextjs/src/app/api/about/contributors/github/route.ts b/apps/nextjs/src/app/api/about/contributors/github/route.ts index a6fc81173..fa93d2881 100644 --- a/apps/nextjs/src/app/api/about/contributors/github/route.ts +++ b/apps/nextjs/src/app/api/about/contributors/github/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; -import githubContributors from "../../../../../../../../static-data/contributors.json"; +import githubContributors from "@static-data/contributors.json"; export const GET = () => { return NextResponse.json(githubContributors); diff --git a/apps/nextjs/src/app/api/about/contributors/opencollective/route.ts b/apps/nextjs/src/app/api/about/contributors/opencollective/route.ts index 369f14a8c..eac1b58c4 100644 --- a/apps/nextjs/src/app/api/about/contributors/opencollective/route.ts +++ b/apps/nextjs/src/app/api/about/contributors/opencollective/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; -import openCollectiveContributors from "../../../../../../../../static-data/opencollective-contributors.json"; +import openCollectiveContributors from "@static-data/opencollective-contributors.json"; export const GET = () => { return NextResponse.json(openCollectiveContributors); diff --git a/apps/nextjs/tsconfig.json b/apps/nextjs/tsconfig.json index 470a11e34..59c34075e 100644 --- a/apps/nextjs/tsconfig.json +++ b/apps/nextjs/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "paths": { "~/*": ["./src/*"], + "@static-data/*": ["../../static-data/*"], "@homarr/node-unifi": ["../../node_modules/@types/node-unifi"] }, "plugins": [ diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 50628e908..8b73d45b3 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -4149,6 +4149,11 @@ "title": "Not Found", "text": "Could not find requested resource" }, + "error": { + "title": "This page couldn't load", + "text": "An unexpected error occurred while loading this page. Your navigation is still available — try reloading, or use another section from the sidebar.", + "digest": "Error reference: {digest}" + }, "navbar": { "items": { "home": "Home", From 6ac352c520d831153483f201a1592c8b88438b8b Mon Sep 17 00:00:00 2001 From: "homarr-update-contributors[bot]" <210161987+homarr-update-contributors[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 18:14:48 +0000 Subject: [PATCH 08/11] chore: update contributors (#6088) * chore: update contributors * style: auto-fix lint and format --------- Co-authored-by: Homarr Update Contributors <210161987+homarr-update-contributors[bot]@users.noreply.github.com> --- static-data/contributors.json | 2 +- static-data/translators.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/static-data/contributors.json b/static-data/contributors.json index 2137df790..07d22132c 100644 --- a/static-data/contributors.json +++ b/static-data/contributors.json @@ -1 +1 @@ -[{"login":"ajnart","avatar_url":"https://avatars.githubusercontent.com/u/49837342?v=4"},{"login":"manuel-rw","avatar_url":"https://avatars.githubusercontent.com/u/30572287?v=4"},{"login":"Meierschlumpf","avatar_url":"https://avatars.githubusercontent.com/u/63781622?v=4"},{"login":"SeDemal","avatar_url":"https://avatars.githubusercontent.com/u/26098587?v=4"},{"login":"lammersbjorn","avatar_url":"https://avatars.githubusercontent.com/u/71191962?v=4"},{"login":"LarveyOfficial","avatar_url":"https://avatars.githubusercontent.com/u/39219859?v=4"},{"login":"c00ldude1oo","avatar_url":"https://avatars.githubusercontent.com/u/15677803?v=4"},{"login":"InterN0te","avatar_url":"https://avatars.githubusercontent.com/u/10882916?v=4"},{"login":"Aimsucks","avatar_url":"https://avatars.githubusercontent.com/u/1828486?v=4"},{"login":"JannesV","avatar_url":"https://avatars.githubusercontent.com/u/5984244?v=4"},{"login":"MauriceNino","avatar_url":"https://avatars.githubusercontent.com/u/18658092?v=4"},{"login":"hillaliy","avatar_url":"https://avatars.githubusercontent.com/u/950010?v=4"},{"login":"Skeeww","avatar_url":"https://avatars.githubusercontent.com/u/9639992?v=4"},{"login":"RichyHBM","avatar_url":"https://avatars.githubusercontent.com/u/5472275?v=4"},{"login":"Bonfire","avatar_url":"https://avatars.githubusercontent.com/u/5704760?v=4"},{"login":"wieli99","avatar_url":"https://avatars.githubusercontent.com/u/46794825?v=4"},{"login":"ishaanparlikar","avatar_url":"https://avatars.githubusercontent.com/u/43013132?v=4"},{"login":"Zareix","avatar_url":"https://avatars.githubusercontent.com/u/29818713?v=4"},{"login":"tuggan","avatar_url":"https://avatars.githubusercontent.com/u/4325608?v=4"},{"login":"Spillebulle","avatar_url":"https://avatars.githubusercontent.com/u/46653946?v=4"},{"login":"marektancak0106","avatar_url":"https://avatars.githubusercontent.com/u/25422667?v=4"},{"login":"spkesDE","avatar_url":"https://avatars.githubusercontent.com/u/1682314?v=4"},{"login":"j3lte","avatar_url":"https://avatars.githubusercontent.com/u/2557568?v=4"},{"login":"dslatt","avatar_url":"https://avatars.githubusercontent.com/u/5211001?v=4"},{"login":"Malong11-007","avatar_url":"https://avatars.githubusercontent.com/u/40298510?v=4"},{"login":"StefanB7","avatar_url":"https://avatars.githubusercontent.com/u/22056509?v=4"},{"login":"souravghosh01","avatar_url":"https://avatars.githubusercontent.com/u/37548139?v=4"},{"login":"SR3u","avatar_url":"https://avatars.githubusercontent.com/u/2380522?v=4"},{"login":"Rikpat","avatar_url":"https://avatars.githubusercontent.com/u/33869814?v=4"},{"login":"NormanJS","avatar_url":"https://avatars.githubusercontent.com/u/83409549?v=4"},{"login":"No-Maines-Land","avatar_url":"https://avatars.githubusercontent.com/u/20687725?v=4"},{"login":"stark1tty","avatar_url":"https://avatars.githubusercontent.com/u/69326376?v=4"},{"login":"Momcilo42","avatar_url":"https://avatars.githubusercontent.com/u/10118137?v=4"},{"login":"marius-arch","avatar_url":"https://avatars.githubusercontent.com/u/72494359?v=4"},{"login":"Lumilias","avatar_url":"https://avatars.githubusercontent.com/u/10852161?v=4"},{"login":"Havock94","avatar_url":"https://avatars.githubusercontent.com/u/7635248?v=4"},{"login":"Lollyde","avatar_url":"https://avatars.githubusercontent.com/u/11347524?v=4"},{"login":"krishnamuppaneni","avatar_url":"https://avatars.githubusercontent.com/u/4703366?v=4"},{"login":"devtobi","avatar_url":"https://avatars.githubusercontent.com/u/28538704?v=4"},{"login":"TyxTang","avatar_url":"https://avatars.githubusercontent.com/u/90897955?v=4"},{"login":"beanbag420","avatar_url":"https://avatars.githubusercontent.com/u/19630511?v=4"},{"login":"WillyJL","avatar_url":"https://avatars.githubusercontent.com/u/49810075?v=4"},{"login":"anonysoul","avatar_url":"https://avatars.githubusercontent.com/u/105715150?v=4"},{"login":"cospeedster","avatar_url":"https://avatars.githubusercontent.com/u/52749645?v=4"},{"login":"diederbert","avatar_url":"https://avatars.githubusercontent.com/u/162878798?v=4"},{"login":"digdilem","avatar_url":"https://avatars.githubusercontent.com/u/4052275?v=4"},{"login":"dinghino","avatar_url":"https://avatars.githubusercontent.com/u/14729459?v=4"},{"login":"gfomichev","avatar_url":"https://avatars.githubusercontent.com/u/1596146?v=4"},{"login":"gnattu","avatar_url":"https://avatars.githubusercontent.com/u/12995396?v=4"},{"login":"tuxpizza","avatar_url":"https://avatars.githubusercontent.com/u/84710786?v=4"},{"login":"xmarkurion","avatar_url":"https://avatars.githubusercontent.com/u/46654687?v=4"},{"login":"huanga","avatar_url":"https://avatars.githubusercontent.com/u/732970?v=4"},{"login":"taos15","avatar_url":"https://avatars.githubusercontent.com/u/122130728?v=4"},{"login":"arghyadipchak","avatar_url":"https://avatars.githubusercontent.com/u/32430374?v=4"},{"login":"AuthorShin","avatar_url":"https://avatars.githubusercontent.com/u/4959043?v=4"},{"login":"benniblot","avatar_url":"https://avatars.githubusercontent.com/u/47495914?v=4"},{"login":"bebsworthy","avatar_url":"https://avatars.githubusercontent.com/u/5266759?v=4"},{"login":"catrielmuller","avatar_url":"https://avatars.githubusercontent.com/u/2272323?v=4"},{"login":"Vysp3r","avatar_url":"https://avatars.githubusercontent.com/u/62019735?v=4"},{"login":"DR-Blogs","avatar_url":"https://avatars.githubusercontent.com/u/96393186?v=4"},{"login":"singular0","avatar_url":"https://avatars.githubusercontent.com/u/1925883?v=4"},{"login":"diogovalentte","avatar_url":"https://avatars.githubusercontent.com/u/49578155?v=4"},{"login":"codezninja","avatar_url":"https://avatars.githubusercontent.com/u/4400773?v=4"},{"login":"fabricionaweb","avatar_url":"https://avatars.githubusercontent.com/u/15933?v=4"},{"login":"nyok1912","avatar_url":"https://avatars.githubusercontent.com/u/32163949?v=4"},{"login":"bo3bdo","avatar_url":"https://avatars.githubusercontent.com/u/5271380?v=4"},{"login":"hskrtich","avatar_url":"https://avatars.githubusercontent.com/u/1214484?v=4"},{"login":"ishults","avatar_url":"https://avatars.githubusercontent.com/u/7275442?v=4"},{"login":"jelliuk","avatar_url":"https://avatars.githubusercontent.com/u/4790959?v=4"},{"login":"Moohan","avatar_url":"https://avatars.githubusercontent.com/u/5982260?v=4"},{"login":"tesar-tech","avatar_url":"https://avatars.githubusercontent.com/u/33880579?v=4"},{"login":"jbruell","avatar_url":"https://avatars.githubusercontent.com/u/31685172?v=4"},{"login":"JasonLeeB06","avatar_url":"https://avatars.githubusercontent.com/u/82266978?v=4"},{"login":"jeffersonraimon","avatar_url":"https://avatars.githubusercontent.com/u/80064475?v=4"},{"login":"jo-chemla","avatar_url":"https://avatars.githubusercontent.com/u/16822841?v=4"},{"login":"joser93","avatar_url":"https://avatars.githubusercontent.com/u/6912089?v=4"},{"login":"justijndepover","avatar_url":"https://avatars.githubusercontent.com/u/9008623?v=4"},{"login":"Aandree5","avatar_url":"https://avatars.githubusercontent.com/u/32734153?v=4"},{"login":"mvanhorn","avatar_url":"https://avatars.githubusercontent.com/u/455140?v=4"},{"login":"jackwellerreal","avatar_url":"https://avatars.githubusercontent.com/u/97435840?v=4"},{"login":"afonsojramos","avatar_url":"https://avatars.githubusercontent.com/u/19473034?v=4"},{"login":"Copilot","avatar_url":"https://avatars.githubusercontent.com/in/1143301?v=4"},{"login":"SergioChan","avatar_url":"https://avatars.githubusercontent.com/u/10103766?v=4"},{"login":"toineenzo","avatar_url":"https://avatars.githubusercontent.com/u/4921397?v=4"},{"login":"oben01","avatar_url":"https://avatars.githubusercontent.com/u/22369027?v=4"},{"login":"rezstje","avatar_url":"https://avatars.githubusercontent.com/u/45420315?v=4"},{"login":"castielwaverly","avatar_url":"https://avatars.githubusercontent.com/u/137005689?v=4"},{"login":"xoxyuxu","avatar_url":"https://avatars.githubusercontent.com/u/3543416?v=4"},{"login":"StrandedTurtle","avatar_url":"https://avatars.githubusercontent.com/u/132835255?v=4"},{"login":"MaienM","avatar_url":"https://avatars.githubusercontent.com/u/583129?v=4"},{"login":"kylegrieder","avatar_url":"https://avatars.githubusercontent.com/u/25424229?v=4"},{"login":"HeapReaper","avatar_url":"https://avatars.githubusercontent.com/u/31496522?v=4"},{"login":"lopsch","avatar_url":"https://avatars.githubusercontent.com/u/4073114?v=4"},{"login":"Gilmoursa","avatar_url":"https://avatars.githubusercontent.com/u/10321421?v=4"},{"login":"Bricklou","avatar_url":"https://avatars.githubusercontent.com/u/15181236?v=4"},{"login":"mairas","avatar_url":"https://avatars.githubusercontent.com/u/407010?v=4"},{"login":"itzTheMeow","avatar_url":"https://avatars.githubusercontent.com/u/50887230?v=4"},{"login":"Nerdy-Technician","avatar_url":"https://avatars.githubusercontent.com/u/45691205?v=4"},{"login":"NicolasNewman","avatar_url":"https://avatars.githubusercontent.com/u/16135852?v=4"},{"login":"NikeLaosClericus","avatar_url":"https://avatars.githubusercontent.com/u/39810627?v=4"},{"login":"noahcroghan","avatar_url":"https://avatars.githubusercontent.com/u/27422774?v=4"},{"login":"ohmzi","avatar_url":"https://avatars.githubusercontent.com/u/6551272?v=4"},{"login":"Rhiz3K","avatar_url":"https://avatars.githubusercontent.com/u/33246262?v=4"},{"login":"saschabuehrle","avatar_url":"https://avatars.githubusercontent.com/u/47737812?v=4"},{"login":"CrazyWolf13","avatar_url":"https://avatars.githubusercontent.com/u/96661824?v=4"},{"login":"VIVAAN-DHAWAN","avatar_url":"https://avatars.githubusercontent.com/u/150339722?v=4"},{"login":"generalmat82","avatar_url":"https://avatars.githubusercontent.com/u/110256073?v=4"},{"login":"lthomsen","avatar_url":"https://avatars.githubusercontent.com/u/28560325?v=4"},{"login":"pitschi","avatar_url":"https://avatars.githubusercontent.com/u/6841565?v=4"},{"login":"sharpsalat","avatar_url":"https://avatars.githubusercontent.com/u/15666932?v=4"},{"login":"tech62","avatar_url":"https://avatars.githubusercontent.com/u/16237642?v=4"},{"login":"AartSchinkel","avatar_url":"https://avatars.githubusercontent.com/u/189675191?v=4"},{"login":"m0s4ik","avatar_url":"https://avatars.githubusercontent.com/u/26578395?v=4"},{"login":"ashtonbatty","avatar_url":"https://avatars.githubusercontent.com/u/230635?v=4"},{"login":"Oupsman","avatar_url":"https://avatars.githubusercontent.com/u/11260343?v=4"},{"login":"cethien","avatar_url":"https://avatars.githubusercontent.com/u/60463918?v=4"},{"login":"DZ945brH","avatar_url":"https://avatars.githubusercontent.com/u/42635243?v=4"},{"login":"dgshue","avatar_url":"https://avatars.githubusercontent.com/u/12952156?v=4"},{"login":"d-lindahl","avatar_url":"https://avatars.githubusercontent.com/u/690410?v=4"},{"login":"KartulUdus","avatar_url":"https://avatars.githubusercontent.com/u/23346150?v=4"},{"login":"ieugen","avatar_url":"https://avatars.githubusercontent.com/u/1050920?v=4"},{"login":"faugusto-oliveira","avatar_url":"https://avatars.githubusercontent.com/u/37384074?v=4"},{"login":"frankvHoof93","avatar_url":"https://avatars.githubusercontent.com/u/7346516?v=4"},{"login":"darbyjack","avatar_url":"https://avatars.githubusercontent.com/u/21014720?v=4"},{"login":"febrile42","avatar_url":"https://avatars.githubusercontent.com/u/14597122?v=4"},{"login":"Kaotic3","avatar_url":"https://avatars.githubusercontent.com/u/80522225?v=4"},{"login":"KelianB","avatar_url":"https://avatars.githubusercontent.com/u/11216155?v=4"},{"login":"hotrungnhan","avatar_url":"https://avatars.githubusercontent.com/u/40029631?v=4"},{"login":"leandroscardua","avatar_url":"https://avatars.githubusercontent.com/u/24698541?v=4"}] \ No newline at end of file +[{"login":"ajnart","avatar_url":"https://avatars.githubusercontent.com/u/49837342?v=4"},{"login":"manuel-rw","avatar_url":"https://avatars.githubusercontent.com/u/30572287?v=4"},{"login":"Meierschlumpf","avatar_url":"https://avatars.githubusercontent.com/u/63781622?v=4"},{"login":"SeDemal","avatar_url":"https://avatars.githubusercontent.com/u/26098587?v=4"},{"login":"lammersbjorn","avatar_url":"https://avatars.githubusercontent.com/u/71191962?v=4"},{"login":"LarveyOfficial","avatar_url":"https://avatars.githubusercontent.com/u/39219859?v=4"},{"login":"c00ldude1oo","avatar_url":"https://avatars.githubusercontent.com/u/15677803?v=4"},{"login":"InterN0te","avatar_url":"https://avatars.githubusercontent.com/u/10882916?v=4"},{"login":"Aimsucks","avatar_url":"https://avatars.githubusercontent.com/u/1828486?v=4"},{"login":"JannesV","avatar_url":"https://avatars.githubusercontent.com/u/5984244?v=4"},{"login":"MauriceNino","avatar_url":"https://avatars.githubusercontent.com/u/18658092?v=4"},{"login":"hillaliy","avatar_url":"https://avatars.githubusercontent.com/u/950010?v=4"},{"login":"Skeeww","avatar_url":"https://avatars.githubusercontent.com/u/9639992?v=4"},{"login":"RichyHBM","avatar_url":"https://avatars.githubusercontent.com/u/5472275?v=4"},{"login":"Bonfire","avatar_url":"https://avatars.githubusercontent.com/u/5704760?v=4"},{"login":"wieli99","avatar_url":"https://avatars.githubusercontent.com/u/46794825?v=4"},{"login":"ishaanparlikar","avatar_url":"https://avatars.githubusercontent.com/u/43013132?v=4"},{"login":"Zareix","avatar_url":"https://avatars.githubusercontent.com/u/29818713?v=4"},{"login":"tuggan","avatar_url":"https://avatars.githubusercontent.com/u/4325608?v=4"},{"login":"Spillebulle","avatar_url":"https://avatars.githubusercontent.com/u/46653946?v=4"},{"login":"marektancak0106","avatar_url":"https://avatars.githubusercontent.com/u/25422667?v=4"},{"login":"spkesDE","avatar_url":"https://avatars.githubusercontent.com/u/1682314?v=4"},{"login":"j3lte","avatar_url":"https://avatars.githubusercontent.com/u/2557568?v=4"},{"login":"dslatt","avatar_url":"https://avatars.githubusercontent.com/u/5211001?v=4"},{"login":"Malong11-007","avatar_url":"https://avatars.githubusercontent.com/u/40298510?v=4"},{"login":"StefanB7","avatar_url":"https://avatars.githubusercontent.com/u/22056509?v=4"},{"login":"souravghosh01","avatar_url":"https://avatars.githubusercontent.com/u/37548139?v=4"},{"login":"SR3u","avatar_url":"https://avatars.githubusercontent.com/u/2380522?v=4"},{"login":"Rikpat","avatar_url":"https://avatars.githubusercontent.com/u/33869814?v=4"},{"login":"NormanJS","avatar_url":"https://avatars.githubusercontent.com/u/83409549?v=4"},{"login":"No-Maines-Land","avatar_url":"https://avatars.githubusercontent.com/u/20687725?v=4"},{"login":"stark1tty","avatar_url":"https://avatars.githubusercontent.com/u/69326376?v=4"},{"login":"Momcilo42","avatar_url":"https://avatars.githubusercontent.com/u/10118137?v=4"},{"login":"marius-arch","avatar_url":"https://avatars.githubusercontent.com/u/72494359?v=4"},{"login":"Lumilias","avatar_url":"https://avatars.githubusercontent.com/u/10852161?v=4"},{"login":"Havock94","avatar_url":"https://avatars.githubusercontent.com/u/7635248?v=4"},{"login":"Lollyde","avatar_url":"https://avatars.githubusercontent.com/u/11347524?v=4"},{"login":"krishnamuppaneni","avatar_url":"https://avatars.githubusercontent.com/u/4703366?v=4"},{"login":"devtobi","avatar_url":"https://avatars.githubusercontent.com/u/28538704?v=4"},{"login":"TyxTang","avatar_url":"https://avatars.githubusercontent.com/u/90897955?v=4"},{"login":"beanbag420","avatar_url":"https://avatars.githubusercontent.com/u/19630511?v=4"},{"login":"WillyJL","avatar_url":"https://avatars.githubusercontent.com/u/49810075?v=4"},{"login":"anonysoul","avatar_url":"https://avatars.githubusercontent.com/u/105715150?v=4"},{"login":"cospeedster","avatar_url":"https://avatars.githubusercontent.com/u/52749645?v=4"},{"login":"diederbert","avatar_url":"https://avatars.githubusercontent.com/u/162878798?v=4"},{"login":"digdilem","avatar_url":"https://avatars.githubusercontent.com/u/4052275?v=4"},{"login":"dinghino","avatar_url":"https://avatars.githubusercontent.com/u/14729459?v=4"},{"login":"gfomichev","avatar_url":"https://avatars.githubusercontent.com/u/1596146?v=4"},{"login":"gnattu","avatar_url":"https://avatars.githubusercontent.com/u/12995396?v=4"},{"login":"tuxpizza","avatar_url":"https://avatars.githubusercontent.com/u/84710786?v=4"},{"login":"xmarkurion","avatar_url":"https://avatars.githubusercontent.com/u/46654687?v=4"},{"login":"huanga","avatar_url":"https://avatars.githubusercontent.com/u/732970?v=4"},{"login":"taos15","avatar_url":"https://avatars.githubusercontent.com/u/122130728?v=4"},{"login":"arghyadipchak","avatar_url":"https://avatars.githubusercontent.com/u/32430374?v=4"},{"login":"AuthorShin","avatar_url":"https://avatars.githubusercontent.com/u/4959043?v=4"},{"login":"benniblot","avatar_url":"https://avatars.githubusercontent.com/u/47495914?v=4"},{"login":"bebsworthy","avatar_url":"https://avatars.githubusercontent.com/u/5266759?v=4"},{"login":"catrielmuller","avatar_url":"https://avatars.githubusercontent.com/u/2272323?v=4"},{"login":"Vysp3r","avatar_url":"https://avatars.githubusercontent.com/u/62019735?v=4"},{"login":"DR-Blogs","avatar_url":"https://avatars.githubusercontent.com/u/96393186?v=4"},{"login":"singular0","avatar_url":"https://avatars.githubusercontent.com/u/1925883?v=4"},{"login":"diogovalentte","avatar_url":"https://avatars.githubusercontent.com/u/49578155?v=4"},{"login":"codezninja","avatar_url":"https://avatars.githubusercontent.com/u/4400773?v=4"},{"login":"fabricionaweb","avatar_url":"https://avatars.githubusercontent.com/u/15933?v=4"},{"login":"nyok1912","avatar_url":"https://avatars.githubusercontent.com/u/32163949?v=4"},{"login":"bo3bdo","avatar_url":"https://avatars.githubusercontent.com/u/5271380?v=4"},{"login":"hskrtich","avatar_url":"https://avatars.githubusercontent.com/u/1214484?v=4"},{"login":"ishults","avatar_url":"https://avatars.githubusercontent.com/u/7275442?v=4"},{"login":"jelliuk","avatar_url":"https://avatars.githubusercontent.com/u/4790959?v=4"},{"login":"Moohan","avatar_url":"https://avatars.githubusercontent.com/u/5982260?v=4"},{"login":"tesar-tech","avatar_url":"https://avatars.githubusercontent.com/u/33880579?v=4"},{"login":"jbruell","avatar_url":"https://avatars.githubusercontent.com/u/31685172?v=4"},{"login":"JasonLeeB06","avatar_url":"https://avatars.githubusercontent.com/u/82266978?v=4"},{"login":"jeffersonraimon","avatar_url":"https://avatars.githubusercontent.com/u/80064475?v=4"},{"login":"jo-chemla","avatar_url":"https://avatars.githubusercontent.com/u/16822841?v=4"},{"login":"joser93","avatar_url":"https://avatars.githubusercontent.com/u/6912089?v=4"},{"login":"justijndepover","avatar_url":"https://avatars.githubusercontent.com/u/9008623?v=4"},{"login":"Aandree5","avatar_url":"https://avatars.githubusercontent.com/u/32734153?v=4"},{"login":"afonsojramos","avatar_url":"https://avatars.githubusercontent.com/u/19473034?v=4"},{"login":"mvanhorn","avatar_url":"https://avatars.githubusercontent.com/u/455140?v=4"},{"login":"jackwellerreal","avatar_url":"https://avatars.githubusercontent.com/u/97435840?v=4"},{"login":"Copilot","avatar_url":"https://avatars.githubusercontent.com/in/1143301?v=4"},{"login":"SergioChan","avatar_url":"https://avatars.githubusercontent.com/u/10103766?v=4"},{"login":"toineenzo","avatar_url":"https://avatars.githubusercontent.com/u/4921397?v=4"},{"login":"oben01","avatar_url":"https://avatars.githubusercontent.com/u/22369027?v=4"},{"login":"rezstje","avatar_url":"https://avatars.githubusercontent.com/u/45420315?v=4"},{"login":"castielwaverly","avatar_url":"https://avatars.githubusercontent.com/u/137005689?v=4"},{"login":"xoxyuxu","avatar_url":"https://avatars.githubusercontent.com/u/3543416?v=4"},{"login":"StrandedTurtle","avatar_url":"https://avatars.githubusercontent.com/u/132835255?v=4"},{"login":"MaienM","avatar_url":"https://avatars.githubusercontent.com/u/583129?v=4"},{"login":"kylegrieder","avatar_url":"https://avatars.githubusercontent.com/u/25424229?v=4"},{"login":"HeapReaper","avatar_url":"https://avatars.githubusercontent.com/u/31496522?v=4"},{"login":"lopsch","avatar_url":"https://avatars.githubusercontent.com/u/4073114?v=4"},{"login":"Gilmoursa","avatar_url":"https://avatars.githubusercontent.com/u/10321421?v=4"},{"login":"Lok3sh-kumar","avatar_url":"https://avatars.githubusercontent.com/u/110459168?v=4"},{"login":"Bricklou","avatar_url":"https://avatars.githubusercontent.com/u/15181236?v=4"},{"login":"mairas","avatar_url":"https://avatars.githubusercontent.com/u/407010?v=4"},{"login":"itzTheMeow","avatar_url":"https://avatars.githubusercontent.com/u/50887230?v=4"},{"login":"Nerdy-Technician","avatar_url":"https://avatars.githubusercontent.com/u/45691205?v=4"},{"login":"NicolasNewman","avatar_url":"https://avatars.githubusercontent.com/u/16135852?v=4"},{"login":"NikeLaosClericus","avatar_url":"https://avatars.githubusercontent.com/u/39810627?v=4"},{"login":"noahcroghan","avatar_url":"https://avatars.githubusercontent.com/u/27422774?v=4"},{"login":"ohmzi","avatar_url":"https://avatars.githubusercontent.com/u/6551272?v=4"},{"login":"Rhiz3K","avatar_url":"https://avatars.githubusercontent.com/u/33246262?v=4"},{"login":"saschabuehrle","avatar_url":"https://avatars.githubusercontent.com/u/47737812?v=4"},{"login":"CrazyWolf13","avatar_url":"https://avatars.githubusercontent.com/u/96661824?v=4"},{"login":"VIVAAN-DHAWAN","avatar_url":"https://avatars.githubusercontent.com/u/150339722?v=4"},{"login":"generalmat82","avatar_url":"https://avatars.githubusercontent.com/u/110256073?v=4"},{"login":"lthomsen","avatar_url":"https://avatars.githubusercontent.com/u/28560325?v=4"},{"login":"pitschi","avatar_url":"https://avatars.githubusercontent.com/u/6841565?v=4"},{"login":"sharpsalat","avatar_url":"https://avatars.githubusercontent.com/u/15666932?v=4"},{"login":"tech62","avatar_url":"https://avatars.githubusercontent.com/u/16237642?v=4"},{"login":"AartSchinkel","avatar_url":"https://avatars.githubusercontent.com/u/189675191?v=4"},{"login":"m0s4ik","avatar_url":"https://avatars.githubusercontent.com/u/26578395?v=4"},{"login":"ashtonbatty","avatar_url":"https://avatars.githubusercontent.com/u/230635?v=4"},{"login":"Oupsman","avatar_url":"https://avatars.githubusercontent.com/u/11260343?v=4"},{"login":"Bergasha","avatar_url":"https://avatars.githubusercontent.com/u/25044200?v=4"},{"login":"cethien","avatar_url":"https://avatars.githubusercontent.com/u/60463918?v=4"},{"login":"DZ945brH","avatar_url":"https://avatars.githubusercontent.com/u/42635243?v=4"},{"login":"dgshue","avatar_url":"https://avatars.githubusercontent.com/u/12952156?v=4"},{"login":"d-lindahl","avatar_url":"https://avatars.githubusercontent.com/u/690410?v=4"},{"login":"KartulUdus","avatar_url":"https://avatars.githubusercontent.com/u/23346150?v=4"},{"login":"ieugen","avatar_url":"https://avatars.githubusercontent.com/u/1050920?v=4"},{"login":"faugusto-oliveira","avatar_url":"https://avatars.githubusercontent.com/u/37384074?v=4"},{"login":"frankvHoof93","avatar_url":"https://avatars.githubusercontent.com/u/7346516?v=4"},{"login":"darbyjack","avatar_url":"https://avatars.githubusercontent.com/u/21014720?v=4"},{"login":"febrile42","avatar_url":"https://avatars.githubusercontent.com/u/14597122?v=4"},{"login":"Kaotic3","avatar_url":"https://avatars.githubusercontent.com/u/80522225?v=4"},{"login":"KelianB","avatar_url":"https://avatars.githubusercontent.com/u/11216155?v=4"},{"login":"hotrungnhan","avatar_url":"https://avatars.githubusercontent.com/u/40029631?v=4"},{"login":"leandroscardua","avatar_url":"https://avatars.githubusercontent.com/u/24698541?v=4"}] \ No newline at end of file diff --git a/static-data/translators.json b/static-data/translators.json index 221b28e9d..4d0b1456d 100644 --- a/static-data/translators.json +++ b/static-data/translators.json @@ -1 +1 @@ -[{"username":"carlchina","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/6697/medium/d22bbe7797bbeb30dbdc73a5648d329a_default.png"},{"username":"zielmann","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/7795/medium/ad22b8b8d5eb33e4154d53a454c862fd_default.png"},{"username":"Thalyn","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12494028/medium/5faf5acc945a874ed116c4567edaa09f_default.png"},{"username":"magnushj","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12503488/medium/68f32e33ea7b891960055c9f831f7344_default.png"},{"username":"almontegil","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12580457/medium/f4136cacbdfdb4c28ae7f85dc5f840db_default.png"},{"username":"andibing","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12664938/medium/b8be63e4dcb2e791ced1ffc9e3a049a5.jpg"},{"username":"gronare","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12680911/medium/c6eb14d1504156f34a158e507f73aa3d_default.png"},{"username":"SmartPhoneLover","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12701640/medium/ec95486662ec875cda080e778c3ff702.jpg"},{"username":"marcelotk_15","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12773227/medium/ecf6bea16ab0c873c131df2154ee9718_default.png"},{"username":"S3OD177","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13051544/medium/11dd990837f61f7e8f7d36b041a7fddb_default.png"},{"username":"marcg","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13153301/medium/ebcf60fbf27deb148ec40a659566c6ff_default.png"},{"username":"BeersTeddy","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13185230/medium/2f1f4e1effe74a23422b195cbefb2a95_default.png"},{"username":"jamesmcmahon0","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13245578/medium/586aa873b4abddbd9abc6f3de99ab70e.jpeg"},{"username":"b1nar10","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13286752/medium/aaa1f083e04c572f953fde4563171e4a.jpeg"},{"username":"vannCN","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13330448/medium/9b8a9ee3611e51e951e22d5fd4eb7d8d.jpg"},{"username":"binswm","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13343482/medium/45dde0e6097b9b72705d2eba9dbbc276_default.png"},{"username":"SkewRam","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13496556/medium/188f5c2deb7938eda51eb786cc4539ca.jpeg"},{"username":"raphcatarino","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13547726/medium/b003511e67df13a4b4b5689488fa8099.jpg"},{"username":"shillos5","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13600557/medium/b17650b2b84f829d54fa548c44c1ab27.jpeg"},{"username":"wolong98","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13641407/medium/f4634edc58c7857a357e5293543c15cf.jpg"},{"username":"spair0039","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14012333/medium/72430e96027c09c19141cac38eae4617.png"},{"username":"MajorMarcin","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14072183/medium/0a197e64bb92b6ea94cecbb607512c45.jpg"},{"username":"KosmoMoustache","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14103535/medium/51c438cdd9f86e8811c75f4869de479c.png"},{"username":"Soochaehwa","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14118689/medium/496a1ce63111547bf455a1e0a7ac75f1_default.png"},{"username":"garryfield","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14187035/medium/520c964706676045f6eeec1b08edc880.jpg"},{"username":"droidenko","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14444264/medium/4aa3a8e824f72bc9e5ec0d8de307095e.jpeg"},{"username":"maathis","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14489950/medium/6eeddf13a757ee35f78f1763f94d95e1.jpeg"},{"username":"bukvam","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14546502/medium/a819dc40a13c57395a17dc46e847d3ce.jpg"},{"username":"gm.cinalli","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14670666/medium/2d466a0fdbda40764526be86c97c0ab4.jpeg"},{"username":"antoine2tt","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14722148/medium/f88d926900862dd59007ea4b3419cb9d.png"},{"username":"cretzen","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14799754/medium/3ba5c1805ec20c000d5810c07985afe9.jpg"},{"username":"ktKongTong","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14817246/medium/97cfc3c028dbdaf85ebd1102da71e58c.jpeg"},{"username":"marinkaberg","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14861042/medium/2d5c4e62613f03082f3e645fa92efd59.jpeg"},{"username":"djismgaming","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14934947/medium/f5a8570713c34ab0f7d5405d105e2a9a.jpeg"},{"username":"f1refa11","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14949159/medium/fd2ae63b8eb4462200ba96abf943c1b9.png"},{"username":"jeffersonraimon","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15057621/medium/6dff469563860e2618bc9d45646d0ab4.png"},{"username":"giop98","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15057987/medium/b8a4825d3fc39fc662f35ee258db4b2d.jpeg"},{"username":"tomislav.kraljevic","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15118857/medium/e133f1061cc92850b854d05d8faaeafd.png"},{"username":"Walkx","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15202182/medium/5c37361ae45aeed487b34582c1f7ca37.png"},{"username":"andrea.rosso","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15246318/medium/11f3f5ef44ec7f55b6f143090e208704_default.png"},{"username":"ajnart","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15270132/medium/a0f107a463c8910ee96bc2fa843a17e3.jpeg"},{"username":"Witchling","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15282238/medium/9e196b6783415b10ca571b7673c2ef33.jpg"},{"username":"guineuu","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15292058/medium/22fbb758bda3b7805d50bf21d38f2c20.jpeg"},{"username":"Bulgus","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15304568/medium/0e8787e5ceb02ed5c96a514d0068ae87.jpg"},{"username":"Narno","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15314996/medium/a6ba093f24884250a1bf6c6f2ca50f1b_default.png"},{"username":"BySempron","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15315986/medium/e3d22d7b1423c6823a9f36d595ed4bdb.png"},{"username":"jonathan.berglin.work","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15359236/medium/95930b2093db13b76179782f7322c5d5.png"},{"username":"icdmkg","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15405504/medium/f334d16cde1ac4ffb935b440d03465af.gif"},{"username":"irithys","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15405614/medium/3086461c47cce0a0c031925e5f943412.png"},{"username":"JannesV","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15419912/medium/a7809eb4b817d7c49b62cf10ae86b950.png"},{"username":"benniblot","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15419914/medium/8086b1573d31f152ba41949b3a172445.jpeg"},{"username":"pacjo","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15419916/medium/3cbeddbd7bc01faafb5a3bf47bba915b_default.png"},{"username":"Payou6994","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15419934/medium/37c9b9b17dfb578404c1c1ddb73ba7a8.png"},{"username":"fzibi21","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15419976/medium/0da688450358e0290a7b7359cc1f7328.png"},{"username":"WowMurdock","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15420118/medium/de19576fccb495b6dfe4c4c04a56b834.png"},{"username":"hbooo","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15420120/medium/6c7c7f8db785061356ebb03d044d3329.jpeg"},{"username":"Manicraft1001","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15420178/medium/50ec94563a06a9f74f33bd09f01eed4d.jpg"},{"username":"Void123","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15420354/medium/86929d44df92a00f9fe900a985c196df_default.png"},{"username":"R4cc","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15422606/medium/a390979662b84694f59de30bdb732141.jpeg"},{"username":"fabricionaweb","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15425808/medium/a9354142e7ae5152c144409d55fda551_default.png"},{"username":"JokeOfDead","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15426890/medium/a383eac9365e9de64fd4ab1d6fd0cb95.jpeg"},{"username":"hkz","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15427174/medium/c88acefb0d7306e1f7470e872029fb39_default.png"},{"username":"Steken","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15428516/medium/d5aea5653c769c3a523182bdb60d1664.png"},{"username":"flar.anton","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15428592/medium/b01fdb365d892e9f811f77fcb50a80a2.jpeg"},{"username":"Bon","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15433542/medium/5397da4dfc821f20b6ac14fe0c514e9a.jpeg"},{"username":"bfkadan","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15434162/medium/4f9a0b43cfe3acaea60124c14ba7f44a.png"},{"username":"BunnySweety","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15434662/medium/f0ef200a6a0dcf0e1d0e9ecd4148f560_default.png"},{"username":"HRKings","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15436168/medium/69a31e461d38549f01864e7ef10d642e.png"},{"username":"wiston81","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15439078/medium/10f292c8d12a7c21a44b54495fa4a3d8.jpeg"},{"username":"qaz0911","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15440860/medium/3d3e50ee388c72dc4bf7a771761f2d89_default.png"},{"username":"qqyule","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15441462/medium/7a3cdf82710ffb5d8f388bc0bd010665.png"},{"username":"Bims0n","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15445560/medium/603220b603eeb3367e0f0d3fa675247c.jpg"},{"username":"TariqDaCoder","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15446228/medium/50b0f4040112bbd67690b769477398e5_default.png"},{"username":"Anarchon","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15449644/medium/e925e1f3e3ffbf0f982391ce263a1a28.jpeg"},{"username":"Ashun","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15453020/medium/ccdcf51c73d6aae40751bb30beee1915_default.png"},{"username":"sebekmartin","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15454038/medium/bcfb44598cdfd1d7cd4eb35812538962.jpeg"},{"username":"RagnarGraves","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15459882/medium/143d5af850c1154070a218bea124e9cb_default.png"},{"username":"PrtmPhlp","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15462414/medium/b80db55e9de301432dcd1f8c8b24fd49_default.png"},{"username":"DooYoo","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15470768/medium/2a18cf4be67094724b508c9e1e698a21_default.png"},{"username":"espentruls","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15486092/medium/8e38afc3a4ff669226a0cfd3e420ff3a.jpeg"},{"username":"frisco82","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15486922/medium/4e1c5d4189b42508e660daa3c1c25b2a.jpeg"},{"username":"lupineDK","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15491798/medium/461bd501e8371c062bf29ea171aedd36_default.png"},{"username":"hillaliy","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15492732/medium/0bae17b421604892d888e3fc70cf0587.jpeg"},{"username":"MarcOrfilaCarreras","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15501072/medium/ea52b26c3c6f21e4931e38e3ce3f3d6e.png"},{"username":"robertbridda","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15507822/medium/a368c2e30411bb2da9b49290084191f3.png"},{"username":"MKoniuszko","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15518090/medium/87605434fcc839f6763ab07c50f6d232.jpeg"},{"username":"HeroSizy","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15518710/medium/4e79c0e98cbeb536dd961e656331b509.png"},{"username":"dwt136","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15520022/medium/554422503f2baea43ace85facb4546fb_default.png"},{"username":"asifthewebguy","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15526719/medium/b18931dd0c800d725048bd440646198b_default.png"},{"username":"eiloogs","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15545537/medium/f290a2f1190983530a9b76b2e858a609.gif"},{"username":"_vytdv","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15547289/medium/60d8644cc8ad6f11a92ccea4a14cd098_default.png"},{"username":"crendasien","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15554645/medium/598ab1d4aaf6b8dccd5ba16be92da7b9.jpeg"},{"username":"edxo","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15573823/medium/8a565b732a75a77f840dd123cdb30bf4.png"},{"username":"somerlev","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15617065/medium/f4b13513e311ec902d90b2f718412c55.jpg"},{"username":"kid1412621","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15643771/medium/dd455e32de652fa88e6fd97598bdffa7.png"},{"username":"suming","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15644717/medium/244159dfe10fa03436205506f80c9e25.png"},{"username":"nick.gher","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15647517/medium/5374a2c6fef60a3fbf0edf86b997c351_default.png"},{"username":"DataCat","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15650315/medium/ce7c1365adf35c5d490d77500a4607fb_default.png"},{"username":"dizo89","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15658375/medium/0ca745e5017d491fe1b22b0239904de8.jpeg"},{"username":"tee_noodle","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15674577/medium/804d9cf06f5196026acb6436b809d0da_default.png"},{"username":"Marty88","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15674593/medium/492b1509d52bd2809dea768121217125.jpeg"},{"username":"Spillebulle","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15677023/medium/20b98bb85062e07afe0e63618f8a5feb.PNG"},{"username":"petitmewen","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15685239/medium/15de9b62d2e0bc25013435f1784bbcc1.jpeg"},{"username":"NoProsNoNoobs","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15687709/medium/ae8f65fafeb8bcf74dcb8871bbe46461.png"},{"username":"y.gybson","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15690777/medium/b5cb5d2d5768904ff6586c727e3a6c77.jpeg"},{"username":"inside90","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15704947/medium/c1355fcb30dd76f8e39d98d1d49f1c52.png"},{"username":"RJSkudra","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15709853/medium/c3abf2774913dc4e81fb261d36d7668c.png"},{"username":"binge203","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15713937/medium/77c1cfa4314673db80e6881fd9f64668.gif"},{"username":"Mailootje","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15714337/medium/743c3bc4ab1989966a375eeeec83d8b8.jpeg"},{"username":"tim-wiegers","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15719805/medium/08a96f064813350661cd6b20bf3d7d99.png"},{"username":"GkhnG","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15722911/medium/71a027caec489ef6ce82bcf1888329d0_default.png"},{"username":"Zoen-Millo","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15760967/medium/9e956f11adc5b34f5636268b5c485dbf.jpg"},{"username":"HooinKyoma","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15792897/medium/9489f0a9b368e0e827ae758b740a2eed.jpeg"},{"username":"wathergeen","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15794495/medium/a83b7fc80d180669ebfd6deb3544cfd0.png"},{"username":"MoeToo","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15818233/medium/d35cd6953717706eaf20f6c143c62947.png"},{"username":"itodouble","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15836233/medium/e984caea18fb0673bf319fcf28cef649.png"},{"username":"loslocitos","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15847901/medium/fe30d12fd2cf38212f929e13b169f9ec.jpeg"},{"username":"Oversleep","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15849065/medium/06141f13a6d541d753f3c2f2947b8068_default.png"},{"username":"Beardy","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15865139/medium/fca6b9d2b3f52e286d1568f52b83b6a0_default.png"},{"username":"raelyan","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15875457/medium/2f4fda1d1aaa5dcc79b328baf3f03151.jpeg"},{"username":"dolphin738","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15913763/medium/ee6fede7b8528ca642329ada80d1cc18.png"},{"username":"brunotco","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15916719/medium/09db45880fc05abc18adb8d932a5ecf9_default.png"},{"username":"LukeIsHereToDevelop","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15924739/medium/3e6cd3940297bb4ba7bb039c93e6f918.png"},{"username":"kennit","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15925879/medium/6b0733ad3c5949b91c55e4d8b03db8a5_default.png"},{"username":"kuunpire","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15950309/medium/8192a4f08f07086828ac9f74ed29a169.jpeg"},{"username":"Sandor-dev","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15951759/medium/0216c2da4eb028164ebbecf1c72f6271_default.png"},{"username":"Meierschlumpf","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15953187/medium/23c744faef1ab84fbdc9351a7850aab6.jpeg"},{"username":"harmlesscat","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15970733/medium/dd8d0214a0250c932bb518b1b55e45a2_default.png"},{"username":"OrzWTF","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15976121/medium/4c4557cbff7ff7b0503455bc59c020e0.jpeg"},{"username":"tagaishi","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15977271/medium/eade504c83a5a1ff831c80a538fbdb44_default.png"},{"username":"azurite928","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15981895/medium/547ccc459ee123e78b5401c499f1022d.png"},{"username":"Ronner231","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16021342/medium/7734d550df2de5a2fec2ffff33e7024c.jpeg"},{"username":"ugyes","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16034148/medium/ed001e3f470a2dea9a8ce955b18e7bd5.png"},{"username":"rpieja","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16045554/medium/bd55baca2ef8b92502a760cc9ee7c505_default.png"},{"username":"flambyisyou","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16051620/medium/3a3bc0c90f6b95ab4ef74396a0a17beb.png"},{"username":"Topbcy","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16077170/medium/d3aed33ea56330338756cfcd89477cfe.jpeg"},{"username":"ai5d02sb","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16084674/medium/7c8119fe2a5ca71bb15f636916a42b95_default.png"},{"username":"explosiveparrot","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16097722/medium/7762f80fc1da63f5b2eb87de9d640324_default.png"},{"username":"DestroyCom","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16111544/medium/83e3d3ac5c8bfed634cd1a074854db67.png"},{"username":"neotraxo","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16116966/medium/b30df957873329019a8a90a69f6efe3a.png"},{"username":"gzxpa","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16123314/medium/487b05577310a946b0b98bc72c11213b_default.png"},{"username":"TORIK","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16124732/medium/de770890e6411860ac06e8cc2dc0abd0_default.png"},{"username":"sergio.pibot","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16138114/medium/80db802b2fcd6bbf19ea4c7413367dca.jpeg"},{"username":"karam-ajaj","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16139838/medium/7539ab958d3c85d5f67e7849483ea8f7.jpeg"},{"username":"SimonHenz97","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16151520/medium/9b94a85b536688e2d94538c384fd0a76.png"},{"username":"olsson82","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16198988/medium/eeaab4cba210c537424d4204754b604e.png"},{"username":"bo3bdo","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16222806/medium/8652f3beacb6098dde5a4cd0a29fe80d.jpeg"},{"username":"AngryPlayer04","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16245192/medium/342715274f4dc31fd4132cdae9126188.jpeg"},{"username":"ehatamm","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16245884/medium/979d5e5526fe545be9bf46db481f7182.jpeg"},{"username":"Balionelis","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16246058/medium/986d62ba363594c517d966fe9178dcbb.png"},{"username":"kwargs","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16248816/medium/19ce11f04bf1ae2637e0483399c675f9_default.png"},{"username":"lucicu","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16273892/medium/3b09e8f3bead221ef98d6a45a16851dc_default.png"},{"username":"Michael5564445","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16357688/medium/9d9026a8bf3eaccb0417da240932ebf7.png"},{"username":"Netscream","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16359198/medium/694e0c8572dc8a47e6d63da31ec5b992.png"},{"username":"belicstefan04","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16368142/medium/4742dcfd7bf7ad7e33e37ac44fba7e0f.png"},{"username":"Hamad2066","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16371478/medium/f37bbc7685b6757defdbd1494236af61.jpeg"},{"username":"3ct0pl4sm","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16399616/medium/ce796d1c40163975624c0a483e56374f.png"},{"username":"sanchez.marcell626","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16433865/medium/3ddde1314764cc2205ffdcd1ae719a91.png"},{"username":"Minoctis","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16490079/medium/91fd0849303a4eca033d660d0a7b6854.jpeg"},{"username":"homarr","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16657003/medium/3afabfd3de949a54ff5d40ad04552aba.png"},{"username":"mwgg","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16658379/medium/763dd09f5c238cc5324d4d5357985292.png"},{"username":"kiwinho","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16671855/medium/bfe3a2d497dc4748a04d02e4ae2139cb.jpeg"},{"username":"tjvg4m34r13","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16679135/medium/7a483b9b0abe0d925e54808e1cb38fa0.jpeg"},{"username":"Diegoem","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16705921/medium/29ce68ebc191fc3f65499511b12de98f_default.png"},{"username":"bouks69","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16746091/medium/b58b1fc08520de416198be9df5d093eb.jpeg"},{"username":"futhgar","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16748067/medium/b8c526b097f9aa9d687250cac3df16dc.jpeg"},{"username":"GrimSQL","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16764343/medium/fff729a76d1f8fbcdaee9bb0c0f5f3fd.png"},{"username":"chnikel","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16767047/medium/3dda2a37e03049dafefe6697bb00f75c.png"},{"username":"yuki19870616","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16767951/medium/f59c848e38382cda6c8464b0a7b9e642_default.png"},{"username":"noamdal","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16782245/medium/b4a72a61f9caf138f0322c0cb987e6d0.png"},{"username":"MalithRukshan","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16809057/medium/084d6a5b35fbbeacc70e7021197d222f.png"},{"username":"minirope19","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16930929/medium/e8cbbc26747e31c5a8959215e1dd067b.jpeg"},{"username":"prubin","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17503492/medium/96ac87644691e49c1f983f2f094e541c_default.png"},{"username":"sam-tam-sam","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17578052/medium/1628dc2954ce3ae95e0caaa2bd60ab0c.png"},{"username":"toineenzo","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12371852/medium/9f1b2bedb4794c59479cd289bdba9fab.png"},{"username":"ArcaneWater","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12421105/medium/1ac82517a6c658f6b62c48aebcb17e86_default.png"},{"username":"Ebedami","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12974801/medium/b596dea5c09454c4c828eac558389755.jpg"},{"username":"perrygamerptyt","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13117177/medium/1406c949507ecd438480473c8a1f6124.jpg"},{"username":"SergiSvK","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13320543/medium/3df3c79cc5f0c53716c559cd3fd2fc20.png"},{"username":"jeongeon","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13362856/medium/eb572f72cde5e5526c517ca3a6489850_default.png"},{"username":"MrTranCFCVN","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13439475/medium/a1789d69c85d90c667194b5d6a518c08_default.png"},{"username":"Tobost06","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13467449/medium/9227dc07f6366bc060d28304f5cb8ce7.jpeg"},{"username":"Kf637","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13530622/medium/118b00bb72d01d3229c97409637e25d1_default.png"},{"username":"Cruellest","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13610615/medium/2a74faa0947ee08d18f7946fb8602c1e.jpg"},{"username":"olavinto","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14424644/medium/37c01732bd2b7a272b421807a05ccfeb.png"},{"username":"Thario","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14491372/medium/dd5e142070802a672900121aa3f332a6_default.png"},{"username":"Wjouter","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14585620/medium/b91a51513332bdc0a0a9ecbed99ecbc3.png"},{"username":"albanobattistella","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14686194/medium/564afcfd76f5cd24751bd669cf4943aa.jpg"},{"username":"Kachelkaiser","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14763406/medium/203a781797da4a18fe0f4e7db8fd547c.png"},{"username":"regi4","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14842936/medium/f7e1d672f562a7db61ba6b4b8f4de41d.png"},{"username":"baton5129","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14891064/medium/8d438f557b82977dd02f0ead3187265f.PNG"},{"username":"meviper","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15958903/medium/209f521ce33a05831fa8d7d1b7babc20_default.png"},{"username":"GerOriki","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16268376/medium/da00e54b08cd40348231d92873a0cd0a_default.png"},{"username":"TheRedCyclops","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16486399/medium/7f39226180120d5a9efcb8e13d8aec8e_default.png"},{"username":"dymek37","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16659221/medium/d1dc6d11d7e7fc370efc5070e837ae03.png"},{"username":"B3CKDOOR","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16744975/medium/e6c7b2d45e76465d54e6efb67c83da7d.png"},{"username":"on9686","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16768173/medium/6282fed9aafe0262a173a5ec0887e5d9_default.png"},{"username":"Larsluph","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16779933/medium/ed7d36f8b44013bad3a16ac0491bd0f7.png"},{"username":"FOEDI","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16781001/medium/6b4552fb72e359aa016f7a054078fac8.png"},{"username":"xmarkurion","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16805443/medium/34a20428427de5aaef99c7d140646803.png"},{"username":"gregid","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16806705/medium/f3c8f4a20436e8538929130142bbc104.png"},{"username":"Flameberg","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16820453/medium/d47be866148474c9d05fec6fbe406d88.png"},{"username":"Playfust","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16872865/medium/5f4609cc711e5fb9fa8a0b178635769c.jpeg"},{"username":"Cotignac","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16879297/medium/81ff353edf030699a159bb79cda26e84.jpg"},{"username":"LeaHun","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16916375/medium/618520f0543b1e212ebc83d66b683d4b_default.png"},{"username":"jackwellerreal","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16919717/medium/37bbfdc03893f141133ccc619593266d.png"},{"username":"renan.costaalencar","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16938427/medium/6d25b25320c5bde734118dec4af9cd5b.png"},{"username":"andrius.cim","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16967845/medium/46028fc1a0ddfaaeebcb37b39739625d_default.png"},{"username":"xjxexrxrxyx","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17041412/medium/d34d664166db6fb25579bca9ff3b50b4.jpeg"},{"username":"bilhal.fellah","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17044572/medium/d1ec6f17e4aa013e9eededc83778d2c6.png"},{"username":"the_octo","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17058060/medium/7423ab3a4cabb67f5decb2e2dfa2b675_default.png"},{"username":"BamesJond","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17075846/medium/128d7ffd40254c8e376f45678e2fc620_default.png"},{"username":"Kornikus","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17134240/medium/d7a42b2c30db46ff152699ca01f9e11a_default.png"},{"username":"xoxyuxu","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17176526/medium/108055aa24343043de852e412bec74be.png"},{"username":"cryptodntsleep","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17249408/medium/b9e15aa2b24a0dbc1e4f194aa8ed5109.png"},{"username":"yaniorg","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17261842/medium/7dbfc77f21c826845961243c44540d26.jpg"},{"username":"Vidariondr","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17262438/medium/45fbc71be02ef27296b6f423e97b6a03_default.png"},{"username":"Fastery","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17263066/medium/701b0edc59676174863ad651528bbb37.jpeg"},{"username":"stratself","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17290388/medium/57d9d8161b3fd0abf4dc5558b4a1fa05_default.png"},{"username":"wuzeqin","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17292606/medium/b9e0308410b31337e2d7aa6ed346d718_default.png"},{"username":"ChristopherBull","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17304338/medium/21a175ce596bfae81430767467908b90.jpeg"},{"username":"darkbcnlord","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17311436/medium/b271bc0b419ec3a26ddb7b63ff8ffc4b.png"},{"username":"zhoran1997","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17344028/medium/c8f450b5a8f1e9494a45dc60af7ce63e.png"},{"username":"Pakura","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17396930/medium/18602738018348bf2ffa83a78bf165e2_default.png"},{"username":"gilalves1703","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17447768/medium/3f1de562a5c2391c88bbf8d5cf40a266.png"},{"username":"iServeCloud","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17470960/medium/11649b99d609438d66455a4d2fdc70f7.png"},{"username":"guibolzantvbox","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17474136/medium/59a576e6da538664ceb972253916fa02.jpeg"},{"username":"ivann136","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17506062/medium/67d09ada36fac5f444a1f7517e3a97bd_default.png"},{"username":"Maheshivara","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17516082/medium/576e1e6581cf969615b265de6aeff8b7.jpeg"},{"username":"brunogui81","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17517542/medium/7c0d174e810435af518fe8e0c05e151f.jpeg"},{"username":"michelwilhelm","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17542190/medium/4c6b33fd8993ff565fa75add2ce8355e.jpeg"},{"username":"dias-gxstavo","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17547376/medium/8c6b109aae8aeb651192346a39ae9e5b.jpeg"},{"username":"ToxykAuBleu","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17614030/medium/8d21e0608560172b121bd46126d827c7.jpeg"},{"username":"e.erdemir","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17617994/medium/7259a29db9941c6171a8cbbd643b92bd.png"},{"username":"sjonkreutz","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17618306/medium/7601c45d015189a863656e21348d223b.png"},{"username":"volovich0377","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17620388/medium/c929a8fbd24f96d17620fa497ade7309.png"},{"username":"Hamstio1","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17624898/medium/406e0bf40094c24a01c3064f8f5b2a82.png"},{"username":"i3laa","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17627462/medium/613a9d283ed8b69819ee181903851660.jpeg"},{"username":"bastiengrignon","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17629884/medium/ddfc3339285bd65195fcd834e512e9b7.jpeg"},{"username":"kauhee","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17644600/medium/def1b9066cf9275b4273a5a5dca7f325.png"},{"username":"hongminzheng","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17683860/medium/a764f4963912777d27a7cd98e136a2b2.png"},{"username":"d-lindahl","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17726729/medium/cbde348582fbabcfadd89a56cd5a0b40.png"},{"username":"Enoal-Fauchille-Bolle","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17734649/medium/6e6fb29198782dc7856ea21811db669c.jpeg"},{"username":"manos-grigorakis","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17737973/medium/b37f685587c920c81fdaf9a357962434.jpeg"},{"username":"SertraFurr","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17738703/medium/5f1e9f270e315ee3185cf2ccb3f5a833.png"},{"username":"Kle.Ma","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17739207/medium/b767d6093e6608f251ca152f53d359b8_default.png"},{"username":"WalkerLukas","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17744485/medium/87508ce83a9b6e5391f55883a66a054b.png"},{"username":"mtya","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17748447/medium/f574d5a770d74814f8c49ee68e00f5b7.jpeg"},{"username":"Elaktrato","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17750977/medium/f4109d379f29dc8ea2e7799ac9e208c8.png"},{"username":"marcelmeis","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17753627/medium/4ba32116c27e427d3073e45713f6564e.png"}] \ No newline at end of file +[{"username":"carlchina","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/6697/medium/d22bbe7797bbeb30dbdc73a5648d329a_default.png"},{"username":"zielmann","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/7795/medium/ad22b8b8d5eb33e4154d53a454c862fd_default.png"},{"username":"Thalyn","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12494028/medium/5faf5acc945a874ed116c4567edaa09f_default.png"},{"username":"magnushj","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12503488/medium/68f32e33ea7b891960055c9f831f7344_default.png"},{"username":"almontegil","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12580457/medium/f4136cacbdfdb4c28ae7f85dc5f840db_default.png"},{"username":"andibing","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12664938/medium/b8be63e4dcb2e791ced1ffc9e3a049a5.jpg"},{"username":"gronare","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12680911/medium/c6eb14d1504156f34a158e507f73aa3d_default.png"},{"username":"SmartPhoneLover","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12701640/medium/ec95486662ec875cda080e778c3ff702.jpg"},{"username":"marcelotk_15","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12773227/medium/ecf6bea16ab0c873c131df2154ee9718_default.png"},{"username":"S3OD177","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13051544/medium/11dd990837f61f7e8f7d36b041a7fddb_default.png"},{"username":"marcg","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13153301/medium/ebcf60fbf27deb148ec40a659566c6ff_default.png"},{"username":"BeersTeddy","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13185230/medium/2f1f4e1effe74a23422b195cbefb2a95_default.png"},{"username":"jamesmcmahon0","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13245578/medium/586aa873b4abddbd9abc6f3de99ab70e.jpeg"},{"username":"b1nar10","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13286752/medium/aaa1f083e04c572f953fde4563171e4a.jpeg"},{"username":"vannCN","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13330448/medium/9b8a9ee3611e51e951e22d5fd4eb7d8d.jpg"},{"username":"binswm","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13343482/medium/45dde0e6097b9b72705d2eba9dbbc276_default.png"},{"username":"SkewRam","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13496556/medium/188f5c2deb7938eda51eb786cc4539ca.jpeg"},{"username":"raphcatarino","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13547726/medium/b003511e67df13a4b4b5689488fa8099.jpg"},{"username":"shillos5","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13600557/medium/b17650b2b84f829d54fa548c44c1ab27.jpeg"},{"username":"wolong98","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13641407/medium/f4634edc58c7857a357e5293543c15cf.jpg"},{"username":"spair0039","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14012333/medium/72430e96027c09c19141cac38eae4617.png"},{"username":"MajorMarcin","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14072183/medium/0a197e64bb92b6ea94cecbb607512c45.jpg"},{"username":"KosmoMoustache","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14103535/medium/51c438cdd9f86e8811c75f4869de479c.png"},{"username":"Soochaehwa","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14118689/medium/496a1ce63111547bf455a1e0a7ac75f1_default.png"},{"username":"garryfield","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14187035/medium/520c964706676045f6eeec1b08edc880.jpg"},{"username":"droidenko","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14444264/medium/4aa3a8e824f72bc9e5ec0d8de307095e.jpeg"},{"username":"maathis","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14489950/medium/6eeddf13a757ee35f78f1763f94d95e1.jpeg"},{"username":"bukvam","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14546502/medium/a819dc40a13c57395a17dc46e847d3ce.jpg"},{"username":"gm.cinalli","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14670666/medium/2d466a0fdbda40764526be86c97c0ab4.jpeg"},{"username":"antoine2tt","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14722148/medium/f88d926900862dd59007ea4b3419cb9d.png"},{"username":"cretzen","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14799754/medium/3ba5c1805ec20c000d5810c07985afe9.jpg"},{"username":"ktKongTong","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14817246/medium/97cfc3c028dbdaf85ebd1102da71e58c.jpeg"},{"username":"marinkaberg","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14861042/medium/2d5c4e62613f03082f3e645fa92efd59.jpeg"},{"username":"djismgaming","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14934947/medium/f5a8570713c34ab0f7d5405d105e2a9a.jpeg"},{"username":"f1refa11","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14949159/medium/fd2ae63b8eb4462200ba96abf943c1b9.png"},{"username":"jeffersonraimon","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15057621/medium/6dff469563860e2618bc9d45646d0ab4.png"},{"username":"giop98","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15057987/medium/b8a4825d3fc39fc662f35ee258db4b2d.jpeg"},{"username":"tomislav.kraljevic","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15118857/medium/e133f1061cc92850b854d05d8faaeafd.png"},{"username":"Walkx","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15202182/medium/5c37361ae45aeed487b34582c1f7ca37.png"},{"username":"andrea.rosso","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15246318/medium/11f3f5ef44ec7f55b6f143090e208704_default.png"},{"username":"ajnart","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15270132/medium/a0f107a463c8910ee96bc2fa843a17e3.jpeg"},{"username":"Witchling","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15282238/medium/9e196b6783415b10ca571b7673c2ef33.jpg"},{"username":"guineuu","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15292058/medium/22fbb758bda3b7805d50bf21d38f2c20.jpeg"},{"username":"Bulgus","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15304568/medium/0e8787e5ceb02ed5c96a514d0068ae87.jpg"},{"username":"Narno","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15314996/medium/a6ba093f24884250a1bf6c6f2ca50f1b_default.png"},{"username":"BySempron","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15315986/medium/e3d22d7b1423c6823a9f36d595ed4bdb.png"},{"username":"jonathan.berglin.work","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15359236/medium/95930b2093db13b76179782f7322c5d5.png"},{"username":"icdmkg","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15405504/medium/f334d16cde1ac4ffb935b440d03465af.gif"},{"username":"irithys","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15405614/medium/3086461c47cce0a0c031925e5f943412.png"},{"username":"JannesV","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15419912/medium/a7809eb4b817d7c49b62cf10ae86b950.png"},{"username":"benniblot","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15419914/medium/8086b1573d31f152ba41949b3a172445.jpeg"},{"username":"pacjo","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15419916/medium/3cbeddbd7bc01faafb5a3bf47bba915b_default.png"},{"username":"Payou6994","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15419934/medium/37c9b9b17dfb578404c1c1ddb73ba7a8.png"},{"username":"fzibi21","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15419976/medium/0da688450358e0290a7b7359cc1f7328.png"},{"username":"WowMurdock","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15420118/medium/de19576fccb495b6dfe4c4c04a56b834.png"},{"username":"hbooo","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15420120/medium/6c7c7f8db785061356ebb03d044d3329.jpeg"},{"username":"Manicraft1001","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15420178/medium/50ec94563a06a9f74f33bd09f01eed4d.jpg"},{"username":"Void123","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15420354/medium/86929d44df92a00f9fe900a985c196df_default.png"},{"username":"R4cc","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15422606/medium/a390979662b84694f59de30bdb732141.jpeg"},{"username":"fabricionaweb","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15425808/medium/a9354142e7ae5152c144409d55fda551_default.png"},{"username":"JokeOfDead","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15426890/medium/a383eac9365e9de64fd4ab1d6fd0cb95.jpeg"},{"username":"hkz","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15427174/medium/c88acefb0d7306e1f7470e872029fb39_default.png"},{"username":"Steken","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15428516/medium/d5aea5653c769c3a523182bdb60d1664.png"},{"username":"flar.anton","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15428592/medium/b01fdb365d892e9f811f77fcb50a80a2.jpeg"},{"username":"Bon","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15433542/medium/5397da4dfc821f20b6ac14fe0c514e9a.jpeg"},{"username":"bfkadan","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15434162/medium/4f9a0b43cfe3acaea60124c14ba7f44a.png"},{"username":"BunnySweety","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15434662/medium/f0ef200a6a0dcf0e1d0e9ecd4148f560_default.png"},{"username":"HRKings","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15436168/medium/69a31e461d38549f01864e7ef10d642e.png"},{"username":"wiston81","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15439078/medium/10f292c8d12a7c21a44b54495fa4a3d8.jpeg"},{"username":"qaz0911","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15440860/medium/3d3e50ee388c72dc4bf7a771761f2d89_default.png"},{"username":"qqyule","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15441462/medium/7a3cdf82710ffb5d8f388bc0bd010665.png"},{"username":"Bims0n","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15445560/medium/603220b603eeb3367e0f0d3fa675247c.jpg"},{"username":"TariqDaCoder","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15446228/medium/50b0f4040112bbd67690b769477398e5_default.png"},{"username":"Anarchon","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15449644/medium/e925e1f3e3ffbf0f982391ce263a1a28.jpeg"},{"username":"Ashun","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15453020/medium/ccdcf51c73d6aae40751bb30beee1915_default.png"},{"username":"sebekmartin","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15454038/medium/bcfb44598cdfd1d7cd4eb35812538962.jpeg"},{"username":"RagnarGraves","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15459882/medium/143d5af850c1154070a218bea124e9cb_default.png"},{"username":"PrtmPhlp","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15462414/medium/b80db55e9de301432dcd1f8c8b24fd49_default.png"},{"username":"DooYoo","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15470768/medium/2a18cf4be67094724b508c9e1e698a21_default.png"},{"username":"espentruls","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15486092/medium/8e38afc3a4ff669226a0cfd3e420ff3a.jpeg"},{"username":"frisco82","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15486922/medium/4e1c5d4189b42508e660daa3c1c25b2a.jpeg"},{"username":"lupineDK","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15491798/medium/461bd501e8371c062bf29ea171aedd36_default.png"},{"username":"hillaliy","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15492732/medium/0bae17b421604892d888e3fc70cf0587.jpeg"},{"username":"MarcOrfilaCarreras","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15501072/medium/ea52b26c3c6f21e4931e38e3ce3f3d6e.png"},{"username":"robertbridda","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15507822/medium/a368c2e30411bb2da9b49290084191f3.png"},{"username":"MKoniuszko","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15518090/medium/87605434fcc839f6763ab07c50f6d232.jpeg"},{"username":"HeroSizy","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15518710/medium/4e79c0e98cbeb536dd961e656331b509.png"},{"username":"dwt136","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15520022/medium/554422503f2baea43ace85facb4546fb_default.png"},{"username":"asifthewebguy","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15526719/medium/b18931dd0c800d725048bd440646198b_default.png"},{"username":"eiloogs","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15545537/medium/f290a2f1190983530a9b76b2e858a609.gif"},{"username":"_vytdv","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15547289/medium/60d8644cc8ad6f11a92ccea4a14cd098_default.png"},{"username":"crendasien","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15554645/medium/598ab1d4aaf6b8dccd5ba16be92da7b9.jpeg"},{"username":"edxo","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15573823/medium/8a565b732a75a77f840dd123cdb30bf4.png"},{"username":"somerlev","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15617065/medium/f4b13513e311ec902d90b2f718412c55.jpg"},{"username":"kid1412621","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15643771/medium/dd455e32de652fa88e6fd97598bdffa7.png"},{"username":"suming","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15644717/medium/244159dfe10fa03436205506f80c9e25.png"},{"username":"nick.gher","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15647517/medium/5374a2c6fef60a3fbf0edf86b997c351_default.png"},{"username":"DataCat","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15650315/medium/ce7c1365adf35c5d490d77500a4607fb_default.png"},{"username":"dizo89","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15658375/medium/0ca745e5017d491fe1b22b0239904de8.jpeg"},{"username":"tee_noodle","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15674577/medium/804d9cf06f5196026acb6436b809d0da_default.png"},{"username":"Marty88","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15674593/medium/492b1509d52bd2809dea768121217125.jpeg"},{"username":"Spillebulle","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15677023/medium/20b98bb85062e07afe0e63618f8a5feb.PNG"},{"username":"petitmewen","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15685239/medium/15de9b62d2e0bc25013435f1784bbcc1.jpeg"},{"username":"NoProsNoNoobs","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15687709/medium/ae8f65fafeb8bcf74dcb8871bbe46461.png"},{"username":"y.gybson","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15690777/medium/b5cb5d2d5768904ff6586c727e3a6c77.jpeg"},{"username":"inside90","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15704947/medium/c1355fcb30dd76f8e39d98d1d49f1c52.png"},{"username":"RJSkudra","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15709853/medium/c3abf2774913dc4e81fb261d36d7668c.png"},{"username":"binge203","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15713937/medium/77c1cfa4314673db80e6881fd9f64668.gif"},{"username":"Mailootje","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15714337/medium/743c3bc4ab1989966a375eeeec83d8b8.jpeg"},{"username":"tim-wiegers","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15719805/medium/08a96f064813350661cd6b20bf3d7d99.png"},{"username":"GkhnG","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15722911/medium/71a027caec489ef6ce82bcf1888329d0_default.png"},{"username":"Zoen-Millo","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15760967/medium/9e956f11adc5b34f5636268b5c485dbf.jpg"},{"username":"HooinKyoma","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15792897/medium/9489f0a9b368e0e827ae758b740a2eed.jpeg"},{"username":"wathergeen","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15794495/medium/a83b7fc80d180669ebfd6deb3544cfd0.png"},{"username":"MoeToo","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15818233/medium/d35cd6953717706eaf20f6c143c62947.png"},{"username":"itodouble","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15836233/medium/e984caea18fb0673bf319fcf28cef649.png"},{"username":"loslocitos","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15847901/medium/fe30d12fd2cf38212f929e13b169f9ec.jpeg"},{"username":"Oversleep","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15849065/medium/06141f13a6d541d753f3c2f2947b8068_default.png"},{"username":"Beardy","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15865139/medium/fca6b9d2b3f52e286d1568f52b83b6a0_default.png"},{"username":"raelyan","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15875457/medium/2f4fda1d1aaa5dcc79b328baf3f03151.jpeg"},{"username":"dolphin738","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15913763/medium/ee6fede7b8528ca642329ada80d1cc18.png"},{"username":"brunotco","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15916719/medium/09db45880fc05abc18adb8d932a5ecf9_default.png"},{"username":"LukeIsHereToDevelop","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15924739/medium/3e6cd3940297bb4ba7bb039c93e6f918.png"},{"username":"kennit","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15925879/medium/6b0733ad3c5949b91c55e4d8b03db8a5_default.png"},{"username":"kuunpire","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15950309/medium/8192a4f08f07086828ac9f74ed29a169.jpeg"},{"username":"Sandor-dev","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15951759/medium/0216c2da4eb028164ebbecf1c72f6271_default.png"},{"username":"Meierschlumpf","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15953187/medium/23c744faef1ab84fbdc9351a7850aab6.jpeg"},{"username":"harmlesscat","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15970733/medium/dd8d0214a0250c932bb518b1b55e45a2_default.png"},{"username":"OrzWTF","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15976121/medium/4c4557cbff7ff7b0503455bc59c020e0.jpeg"},{"username":"tagaishi","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15977271/medium/eade504c83a5a1ff831c80a538fbdb44_default.png"},{"username":"azurite928","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15981895/medium/547ccc459ee123e78b5401c499f1022d.png"},{"username":"Ronner231","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16021342/medium/7734d550df2de5a2fec2ffff33e7024c.jpeg"},{"username":"ugyes","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16034148/medium/ed001e3f470a2dea9a8ce955b18e7bd5.png"},{"username":"rpieja","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16045554/medium/bd55baca2ef8b92502a760cc9ee7c505_default.png"},{"username":"flambyisyou","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16051620/medium/3a3bc0c90f6b95ab4ef74396a0a17beb.png"},{"username":"Topbcy","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16077170/medium/d3aed33ea56330338756cfcd89477cfe.jpeg"},{"username":"ai5d02sb","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16084674/medium/7c8119fe2a5ca71bb15f636916a42b95_default.png"},{"username":"explosiveparrot","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16097722/medium/7762f80fc1da63f5b2eb87de9d640324_default.png"},{"username":"DestroyCom","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16111544/medium/83e3d3ac5c8bfed634cd1a074854db67.png"},{"username":"neotraxo","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16116966/medium/b30df957873329019a8a90a69f6efe3a.png"},{"username":"gzxpa","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16123314/medium/487b05577310a946b0b98bc72c11213b_default.png"},{"username":"TORIK","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16124732/medium/de770890e6411860ac06e8cc2dc0abd0_default.png"},{"username":"sergio.pibot","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16138114/medium/80db802b2fcd6bbf19ea4c7413367dca.jpeg"},{"username":"karam-ajaj","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16139838/medium/7539ab958d3c85d5f67e7849483ea8f7.jpeg"},{"username":"SimonHenz97","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16151520/medium/9b94a85b536688e2d94538c384fd0a76.png"},{"username":"olsson82","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16198988/medium/eeaab4cba210c537424d4204754b604e.png"},{"username":"bo3bdo","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16222806/medium/8652f3beacb6098dde5a4cd0a29fe80d.jpeg"},{"username":"AngryPlayer04","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16245192/medium/342715274f4dc31fd4132cdae9126188.jpeg"},{"username":"ehatamm","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16245884/medium/979d5e5526fe545be9bf46db481f7182.jpeg"},{"username":"Balionelis","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16246058/medium/986d62ba363594c517d966fe9178dcbb.png"},{"username":"kwargs","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16248816/medium/19ce11f04bf1ae2637e0483399c675f9_default.png"},{"username":"lucicu","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16273892/medium/3b09e8f3bead221ef98d6a45a16851dc_default.png"},{"username":"Michael5564445","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16357688/medium/9d9026a8bf3eaccb0417da240932ebf7.png"},{"username":"Netscream","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16359198/medium/694e0c8572dc8a47e6d63da31ec5b992.png"},{"username":"belicstefan04","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16368142/medium/4742dcfd7bf7ad7e33e37ac44fba7e0f.png"},{"username":"Hamad2066","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16371478/medium/f37bbc7685b6757defdbd1494236af61.jpeg"},{"username":"3ct0pl4sm","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16399616/medium/ce796d1c40163975624c0a483e56374f.png"},{"username":"sanchez.marcell626","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16433865/medium/3ddde1314764cc2205ffdcd1ae719a91.png"},{"username":"Minoctis","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16490079/medium/91fd0849303a4eca033d660d0a7b6854.jpeg"},{"username":"homarr","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16657003/medium/3afabfd3de949a54ff5d40ad04552aba.png"},{"username":"mwgg","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16658379/medium/763dd09f5c238cc5324d4d5357985292.png"},{"username":"kiwinho","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16671855/medium/bfe3a2d497dc4748a04d02e4ae2139cb.jpeg"},{"username":"tjvg4m34r13","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16679135/medium/7a483b9b0abe0d925e54808e1cb38fa0.jpeg"},{"username":"Diegoem","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16705921/medium/29ce68ebc191fc3f65499511b12de98f_default.png"},{"username":"bouks69","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16746091/medium/b58b1fc08520de416198be9df5d093eb.jpeg"},{"username":"futhgar","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16748067/medium/b8c526b097f9aa9d687250cac3df16dc.jpeg"},{"username":"GrimSQL","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16764343/medium/fff729a76d1f8fbcdaee9bb0c0f5f3fd.png"},{"username":"chnikel","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16767047/medium/3dda2a37e03049dafefe6697bb00f75c.png"},{"username":"yuki19870616","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16767951/medium/f59c848e38382cda6c8464b0a7b9e642_default.png"},{"username":"noamdal","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16782245/medium/b4a72a61f9caf138f0322c0cb987e6d0.png"},{"username":"MalithRukshan","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16809057/medium/084d6a5b35fbbeacc70e7021197d222f.png"},{"username":"minirope19","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16930929/medium/e8cbbc26747e31c5a8959215e1dd067b.jpeg"},{"username":"prubin","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17503492/medium/96ac87644691e49c1f983f2f094e541c_default.png"},{"username":"sam-tam-sam","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17578052/medium/1628dc2954ce3ae95e0caaa2bd60ab0c.png"},{"username":"KydoxOwn","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17764383/medium/0bc150a09d77f5fda43988e6342777dc.png"},{"username":"toineenzo","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12371852/medium/9f1b2bedb4794c59479cd289bdba9fab.png"},{"username":"ArcaneWater","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12421105/medium/1ac82517a6c658f6b62c48aebcb17e86_default.png"},{"username":"Ebedami","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12974801/medium/b596dea5c09454c4c828eac558389755.jpg"},{"username":"perrygamerptyt","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13117177/medium/1406c949507ecd438480473c8a1f6124.jpg"},{"username":"SergiSvK","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13320543/medium/3df3c79cc5f0c53716c559cd3fd2fc20.png"},{"username":"jeongeon","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13362856/medium/eb572f72cde5e5526c517ca3a6489850_default.png"},{"username":"MrTranCFCVN","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13439475/medium/a1789d69c85d90c667194b5d6a518c08_default.png"},{"username":"Tobost06","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13467449/medium/9227dc07f6366bc060d28304f5cb8ce7.jpeg"},{"username":"Kf637","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13530622/medium/118b00bb72d01d3229c97409637e25d1_default.png"},{"username":"Cruellest","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13610615/medium/2a74faa0947ee08d18f7946fb8602c1e.jpg"},{"username":"olavinto","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14424644/medium/37c01732bd2b7a272b421807a05ccfeb.png"},{"username":"Thario","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14491372/medium/dd5e142070802a672900121aa3f332a6_default.png"},{"username":"Wjouter","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14585620/medium/b91a51513332bdc0a0a9ecbed99ecbc3.png"},{"username":"albanobattistella","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14686194/medium/564afcfd76f5cd24751bd669cf4943aa.jpg"},{"username":"Kachelkaiser","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14763406/medium/203a781797da4a18fe0f4e7db8fd547c.png"},{"username":"regi4","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14842936/medium/f7e1d672f562a7db61ba6b4b8f4de41d.png"},{"username":"baton5129","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14891064/medium/8d438f557b82977dd02f0ead3187265f.PNG"},{"username":"meviper","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/15958903/medium/209f521ce33a05831fa8d7d1b7babc20_default.png"},{"username":"GerOriki","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16268376/medium/da00e54b08cd40348231d92873a0cd0a_default.png"},{"username":"TheRedCyclops","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16486399/medium/7f39226180120d5a9efcb8e13d8aec8e_default.png"},{"username":"dymek37","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16659221/medium/d1dc6d11d7e7fc370efc5070e837ae03.png"},{"username":"B3CKDOOR","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16744975/medium/e6c7b2d45e76465d54e6efb67c83da7d.png"},{"username":"on9686","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16768173/medium/6282fed9aafe0262a173a5ec0887e5d9_default.png"},{"username":"Larsluph","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16779933/medium/ed7d36f8b44013bad3a16ac0491bd0f7.png"},{"username":"FOEDI","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16781001/medium/6b4552fb72e359aa016f7a054078fac8.png"},{"username":"xmarkurion","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16805443/medium/34a20428427de5aaef99c7d140646803.png"},{"username":"gregid","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16806705/medium/f3c8f4a20436e8538929130142bbc104.png"},{"username":"Flameberg","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16820453/medium/d47be866148474c9d05fec6fbe406d88.png"},{"username":"Playfust","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16872865/medium/5f4609cc711e5fb9fa8a0b178635769c.jpeg"},{"username":"Cotignac","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16879297/medium/81ff353edf030699a159bb79cda26e84.jpg"},{"username":"LeaHun","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16916375/medium/618520f0543b1e212ebc83d66b683d4b_default.png"},{"username":"jackwellerreal","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16919717/medium/37bbfdc03893f141133ccc619593266d.png"},{"username":"renan.costaalencar","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16938427/medium/6d25b25320c5bde734118dec4af9cd5b.png"},{"username":"andrius.cim","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/16967845/medium/46028fc1a0ddfaaeebcb37b39739625d_default.png"},{"username":"xjxexrxrxyx","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17041412/medium/d34d664166db6fb25579bca9ff3b50b4.jpeg"},{"username":"bilhal.fellah","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17044572/medium/d1ec6f17e4aa013e9eededc83778d2c6.png"},{"username":"the_octo","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17058060/medium/7423ab3a4cabb67f5decb2e2dfa2b675_default.png"},{"username":"BamesJond","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17075846/medium/128d7ffd40254c8e376f45678e2fc620_default.png"},{"username":"Kornikus","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17134240/medium/d7a42b2c30db46ff152699ca01f9e11a_default.png"},{"username":"Master3307","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17161852/medium/94ec577dd53e16b434f2aac9c34f64c6.png"},{"username":"xoxyuxu","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17176526/medium/108055aa24343043de852e412bec74be.png"},{"username":"cryptodntsleep","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17249408/medium/b9e15aa2b24a0dbc1e4f194aa8ed5109.png"},{"username":"yaniorg","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17261842/medium/7dbfc77f21c826845961243c44540d26.jpg"},{"username":"Vidariondr","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17262438/medium/45fbc71be02ef27296b6f423e97b6a03_default.png"},{"username":"Fastery","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17263066/medium/701b0edc59676174863ad651528bbb37.jpeg"},{"username":"stratself","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17290388/medium/57d9d8161b3fd0abf4dc5558b4a1fa05_default.png"},{"username":"wuzeqin","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17292606/medium/b9e0308410b31337e2d7aa6ed346d718_default.png"},{"username":"ChristopherBull","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17304338/medium/21a175ce596bfae81430767467908b90.jpeg"},{"username":"darkbcnlord","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17311436/medium/b271bc0b419ec3a26ddb7b63ff8ffc4b.png"},{"username":"zhoran1997","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17344028/medium/c8f450b5a8f1e9494a45dc60af7ce63e.png"},{"username":"Pakura","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17396930/medium/18602738018348bf2ffa83a78bf165e2_default.png"},{"username":"gilalves1703","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17447768/medium/3f1de562a5c2391c88bbf8d5cf40a266.png"},{"username":"iServeCloud","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17470960/medium/11649b99d609438d66455a4d2fdc70f7.png"},{"username":"guibolzantvbox","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17474136/medium/59a576e6da538664ceb972253916fa02.jpeg"},{"username":"ivann136","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17506062/medium/67d09ada36fac5f444a1f7517e3a97bd_default.png"},{"username":"Maheshivara","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17516082/medium/576e1e6581cf969615b265de6aeff8b7.jpeg"},{"username":"brunogui81","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17517542/medium/7c0d174e810435af518fe8e0c05e151f.jpeg"},{"username":"michelwilhelm","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17542190/medium/4c6b33fd8993ff565fa75add2ce8355e.jpeg"},{"username":"dias-gxstavo","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17547376/medium/8c6b109aae8aeb651192346a39ae9e5b.jpeg"},{"username":"ToxykAuBleu","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17614030/medium/8d21e0608560172b121bd46126d827c7.jpeg"},{"username":"e.erdemir","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17617994/medium/7259a29db9941c6171a8cbbd643b92bd.png"},{"username":"sjonkreutz","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17618306/medium/7601c45d015189a863656e21348d223b.png"},{"username":"volovich0377","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17620388/medium/c929a8fbd24f96d17620fa497ade7309.png"},{"username":"Hamstio1","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17624898/medium/406e0bf40094c24a01c3064f8f5b2a82.png"},{"username":"i3laa","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17627462/medium/613a9d283ed8b69819ee181903851660.jpeg"},{"username":"bastiengrignon","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17629884/medium/ddfc3339285bd65195fcd834e512e9b7.jpeg"},{"username":"kauhee","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17644600/medium/def1b9066cf9275b4273a5a5dca7f325.png"},{"username":"hongminzheng","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17683860/medium/a764f4963912777d27a7cd98e136a2b2.png"},{"username":"d-lindahl","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17726729/medium/cbde348582fbabcfadd89a56cd5a0b40.png"},{"username":"Enoal-Fauchille-Bolle","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17734649/medium/6e6fb29198782dc7856ea21811db669c.jpeg"},{"username":"manos-grigorakis","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17737973/medium/b37f685587c920c81fdaf9a357962434.jpeg"},{"username":"SertraFurr","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17738703/medium/5f1e9f270e315ee3185cf2ccb3f5a833.png"},{"username":"Kle.Ma","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17739207/medium/b767d6093e6608f251ca152f53d359b8_default.png"},{"username":"WalkerLukas","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17744485/medium/87508ce83a9b6e5391f55883a66a054b.png"},{"username":"mtya","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17748447/medium/f574d5a770d74814f8c49ee68e00f5b7.jpeg"},{"username":"Elaktrato","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17750977/medium/f4109d379f29dc8ea2e7799ac9e208c8.png"},{"username":"marcelmeis","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17753627/medium/4ba32116c27e427d3073e45713f6564e.png"},{"username":"ProKafelek12e","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17760377/medium/5199af152f1ea42cee718e7729bd41ff.png"},{"username":"zalaghi","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/17763367/medium/a06f7b15222663517b58b8dc8902f8a1.png"}] \ No newline at end of file From abd16354bc9ef3dddbb497fd16b03476535046ac Mon Sep 17 00:00:00 2001 From: Thomas Date: Fri, 26 Jun 2026 20:16:34 +0200 Subject: [PATCH 09/11] feat: re-add Docker quick-access button in user menu (#6054) Adds a Docker button to the user avatar dropdown menu (admin-only) that opens a 1400px modal containing the same Docker container table as /manage/tools/docker. Closes #2165 --- .../manage/tools/docker/docker-table.tsx | 19 ++++++++++++------ .../app/[locale]/manage/tools/docker/page.tsx | 2 +- .../header/docker-quick-access-modal.tsx | 10 ++++++++++ .../src/components/layout/header/user.tsx | 4 +++- .../src/components/user-avatar-menu.tsx | 20 +++++++++++++++++-- packages/translation/src/lang/en.json | 3 ++- 6 files changed, 47 insertions(+), 11 deletions(-) create mode 100644 apps/nextjs/src/components/layout/header/docker-quick-access-modal.tsx diff --git a/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx index d2c1def97..764f91844 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx @@ -149,16 +149,20 @@ const createColumns = (t: TranslationFunction): MRT_ColumnDef[] }, ]; -export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["getContainers"]) { +interface DockerTableProps { + initialData?: RouterOutputs["docker"]["getContainers"]; +} + +export function DockerTable({ initialData }: DockerTableProps) { const t = useI18n(); const tDocker = useScopedI18n("docker"); - const { data } = clientApi.docker.getContainers.useQuery(undefined, { - initialData: { containers, timestamp }, + const { data, isFetching } = clientApi.docker.getContainers.useQuery(undefined, { + initialData, refetchOnMount: false, refetchOnWindowFocus: false, refetchOnReconnect: false, }); - const relativeTime = useTimeAgo(data.timestamp); + const relativeTime = useTimeAgo(data?.timestamp ?? new Date()); const utils = clientApi.useUtils(); const { mutate, isPending } = clientApi.docker.invalidate.useMutation({ async onSuccess() { @@ -176,8 +180,11 @@ export function DockerTable({ containers, timestamp }: RouterOutputs["docker"][" }, }); + const containers = data?.containers ?? []; + const table = useTranslatedMantineReactTable({ - data: data.containers, + data: containers, + state: { isLoading: isFetching }, enableDensityToggle: false, enableColumnActions: false, enableColumnFilters: false, @@ -190,7 +197,7 @@ export function DockerTable({ containers, timestamp }: RouterOutputs["docker"][" enableBottomToolbar: false, positionGlobalFilter: "right", mantineSearchTextInputProps: { - placeholder: tDocker("table.search", { count: String(data.containers.length) }), + placeholder: tDocker("table.search", { count: String(containers.length) }), style: { minWidth: 300 }, autoFocus: true, }, diff --git a/apps/nextjs/src/app/[locale]/manage/tools/docker/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/docker/page.tsx index 1da3db128..97544314d 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/docker/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/docker/page.tsx @@ -23,7 +23,7 @@ export default async function DockerPage() { {tDocker("title")} - + ); diff --git a/apps/nextjs/src/components/layout/header/docker-quick-access-modal.tsx b/apps/nextjs/src/components/layout/header/docker-quick-access-modal.tsx new file mode 100644 index 000000000..c9826b8a9 --- /dev/null +++ b/apps/nextjs/src/components/layout/header/docker-quick-access-modal.tsx @@ -0,0 +1,10 @@ +"use client"; + +import { createModal } from "@homarr/modals"; + +import { DockerTable } from "~/app/[locale]/manage/tools/docker/docker-table"; + +export const DockerQuickAccessModal = createModal(() => ).withOptions({ + defaultTitle: (t) => t("docker.title"), + size: 1400, +}); diff --git a/apps/nextjs/src/components/layout/header/user.tsx b/apps/nextjs/src/components/layout/header/user.tsx index 6ec2cf306..af3d72121 100644 --- a/apps/nextjs/src/components/layout/header/user.tsx +++ b/apps/nextjs/src/components/layout/header/user.tsx @@ -3,6 +3,7 @@ import { UnstyledButton } from "@mantine/core"; import { api } from "@homarr/api/server"; import { auth } from "@homarr/auth/next"; +import { env } from "@homarr/docker/env"; import { CurrentUserAvatar } from "~/components/user-avatar"; import { UserAvatarMenu } from "~/components/user-avatar-menu"; @@ -11,9 +12,10 @@ import { UpdateIndicator } from "./update"; export const UserButton = async () => { const session = await auth(); const isAdmin = session?.user.permissions.includes("admin"); + const isDockerEnabled = isAdmin && env.ENABLE_DOCKER; const availableUpdatesPromise = isAdmin ? api.updateChecker.getAvailableUpdates() : undefined; return ( - + }> diff --git a/apps/nextjs/src/components/user-avatar-menu.tsx b/apps/nextjs/src/components/user-avatar-menu.tsx index 71ec2b218..cf6d013a2 100644 --- a/apps/nextjs/src/components/user-avatar-menu.tsx +++ b/apps/nextjs/src/components/user-avatar-menu.tsx @@ -5,7 +5,15 @@ import { useCallback, useEffect } from "react"; import { useRouter } from "next/navigation"; import { Center, Menu, Stack, Text, useMantineColorScheme } from "@mantine/core"; import { useHotkeys, useTimeout } from "@mantine/hooks"; -import { IconCheck, IconHome, IconLogin, IconLogout, IconSettings, IconTool } from "@tabler/icons-react"; +import { + IconBrandDocker, + IconCheck, + IconHome, + IconLogin, + IconLogout, + IconSettings, + IconTool, +} from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { signOut, useSession } from "@homarr/auth/client"; @@ -17,14 +25,16 @@ import { Link } from "@homarr/ui"; import { useAuthContext } from "~/app/[locale]/_client-providers/session"; import { CurrentColorSchemeCombobox } from "./color-scheme/current-color-scheme-combobox"; import { CurrentLanguageCombobox } from "./language/current-language-combobox"; +import { DockerQuickAccessModal } from "./layout/header/docker-quick-access-modal"; import { AvailableUpdatesMenuItem } from "./layout/header/update"; interface UserAvatarMenuProps { children: ReactNode; availableUpdatesPromise?: Promise; + isDockerEnabled?: boolean; } -export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatarMenuProps) => { +export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnabled }: UserAvatarMenuProps) => { const t = useScopedI18n("common.userAvatar.menu"); const { toggleColorScheme } = useMantineColorScheme(); useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]); @@ -34,6 +44,7 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatar const { logoutUrl } = useAuthContext(); const { openModal } = useModalAction(LogoutModal); + const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal); const handleSignout = useCallback(async () => { await signOut({ @@ -80,6 +91,11 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatar }> {t("management")} + {isDockerEnabled && ( + } onClick={() => openDockerModal()}> + {t("docker")} + + )} )} diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 8b73d45b3..190a13733 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1468,6 +1468,7 @@ "switchToDarkMode": "Switch to dark mode", "switchToLightMode": "Switch to light mode", "management": "Management", + "docker": "Docker", "preferences": "Your preferences", "logout": "Logout", "login": "Login", @@ -4307,7 +4308,7 @@ "notStarted": "Not started" }, "name": { - "management": "Management", +"management": "Management", "dashboard": "Dashboard" } } From ab9aca8b6030cf13d108240f7bcc828ba86ac2b5 Mon Sep 17 00:00:00 2001 From: Releases Homarr <175486441+homarr-releases[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 18:23:57 +0000 Subject: [PATCH 10/11] chore(release): 1.68.0 [skip ci] --- CHANGELOG.md | 26 ++++++++++++++++++++++++++ package.json | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61183ce87..a37858942 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,29 @@ +## [1.68.0](https://github.com/homarr-labs/homarr/compare/v1.67.0...v1.68.0) (2026-06-26) + +### Features + +* add VPN status widget ([#5812](https://github.com/homarr-labs/homarr/issues/5812)) ([a2bb53a](https://github.com/homarr-labs/homarr/commit/a2bb53a8b8fd17da3d2e23851b75d4e179f66225)) +* re-add Docker quick-access button in user menu ([#6054](https://github.com/homarr-labs/homarr/issues/6054)) ([abd1635](https://github.com/homarr-labs/homarr/commit/abd16354bc9ef3dddbb497fd16b03476535046ac)), closes [#2165](https://github.com/homarr-labs/homarr/issues/2165) +* ship source maps for readable production stack traces ([#6031](https://github.com/homarr-labs/homarr/issues/6031)) ([89380f8](https://github.com/homarr-labs/homarr/commit/89380f81ddd132f5b1041a8f2c3b6c6b138923dd)) + +### Bug Fixes + +* **beszel:** harden cache layer and prevent blank widgets ([#6052](https://github.com/homarr-labs/homarr/issues/6052)) ([49c2663](https://github.com/homarr-labs/homarr/commit/49c266374df12fea2404710232ced42e2de11dbf)), closes [#6030](https://github.com/homarr-labs/homarr/issues/6030) +* **beszel:** improve stats date display, add click-to-stats modal, fix grid ordering ([#6026](https://github.com/homarr-labs/homarr/issues/6026)) ([55fb227](https://github.com/homarr-labs/homarr/commit/55fb227305fe51963e486ffb25c4ce6811a2f0e4)) +* **beszel:** leverage TanStack Query for stale-while-revalidate caching ([#6055](https://github.com/homarr-labs/homarr/issues/6055)) ([bbc4589](https://github.com/homarr-labs/homarr/commit/bbc4589c9e1608721fb58a6f9b03cca8d65c1c4a)) +* **beszel:** use 480m interval for 1w/30d stats views ([4383da3](https://github.com/homarr-labs/homarr/commit/4383da38ef634327c288626c58480cc1e5a2f805)) +* **beszel:** widget transparency, error handling, and cache invalidation ([#5993](https://github.com/homarr-labs/homarr/issues/5993)) ([39e4245](https://github.com/homarr-labs/homarr/commit/39e424563a3187dcf0dba5f654e2225be76aef51)) +* **icon-picker:** keep URL visible in edit mode, add SVG-first ordering ([#6051](https://github.com/homarr-labs/homarr/issues/6051)) ([e01571b](https://github.com/homarr-labs/homarr/commit/e01571b221812cd19a6d67ebe3cf44432104ec27)) +* **integrations:** prevent certificate trust button from submitting the form ([#6020](https://github.com/homarr-labs/homarr/issues/6020)) ([0f66bfd](https://github.com/homarr-labs/homarr/commit/0f66bfd6c128253ba644d80d7c908de5314cf997)) +* **manage/about:** bypass self-fetch + add manage error boundary (LLM exploration) ([#6009](https://github.com/homarr-labs/homarr/issues/6009)) ([19f9769](https://github.com/homarr-labs/homarr/commit/19f9769d9e669d56d793e9f6a0d265316113d743)), closes [#6008](https://github.com/homarr-labs/homarr/issues/6008) [#6008](https://github.com/homarr-labs/homarr/issues/6008) [#6008](https://github.com/homarr-labs/homarr/issues/6008) +* **plex:** use series title for media releases instead of season label ([#6083](https://github.com/homarr-labs/homarr/issues/6083)) ([2443b89](https://github.com/homarr-labs/homarr/commit/2443b89b46f88fddbf41f0382a9f3bc5469c6fa8)) +* show app first in add item modal ([#6087](https://github.com/homarr-labs/homarr/issues/6087)) ([915c8a3](https://github.com/homarr-labs/homarr/commit/915c8a3501551fdccf786d0c34dc01107d873ce0)) +* **truenas:** report actual memory usage and pool free space ([#6017](https://github.com/homarr-labs/homarr/issues/6017)) ([f69e545](https://github.com/homarr-labs/homarr/commit/f69e545d6429aece0e55975c3b2426f20e91e545)) +* **unraid:** report disk free space instead of total size ([#6018](https://github.com/homarr-labs/homarr/issues/6018)) ([1fb97ee](https://github.com/homarr-labs/homarr/commit/1fb97ee9c6fbe656a7dd3a9d1e436125982533f4)) +* **widgets:** clean up health monitoring disk and size display ([#6019](https://github.com/homarr-labs/homarr/issues/6019)) ([e29c79a](https://github.com/homarr-labs/homarr/commit/e29c79a9028d9311dcb3d8323e0950a444589c7a)) +* **widgets:** hide health monitoring default tab option for non-proxmox integrations ([#6021](https://github.com/homarr-labs/homarr/issues/6021)) ([52df56b](https://github.com/homarr-labs/homarr/commit/52df56b19da8e0bcb6c2e5df47b7936ec63f3149)) +* **widgets:** hide system disk usage value when it overflows and show it in a tooltip ([#6022](https://github.com/homarr-labs/homarr/issues/6022)) ([a19c9d8](https://github.com/homarr-labs/homarr/commit/a19c9d88caacc86b42854c732818f0b5e5cc8dd5)) + ## [1.67.0](https://github.com/homarr-labs/homarr/compare/v1.66.1...v1.67.0) (2026-06-19) ### Features diff --git a/package.json b/package.json index bd36b5962..040b93355 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "homarr", - "version": "1.67.0", + "version": "1.68.0", "private": true, "scripts": { "build": "cross-env CI=true turbo build", From 73bed51df895250f415efd7a87e721e403b3fd99 Mon Sep 17 00:00:00 2001 From: Thomas Date: Fri, 26 Jun 2026 20:27:25 +0200 Subject: [PATCH 11/11] fix(widgets): make downloads widget table background transparent (#6025) The MantineReactTable defaults table cell backgrounds to var(--mantine-color-body) which is opaque. This makes the downloads widget table block appear solid while the footer and other widgets correctly show the board's background. Added a styles.css targeting table elements to force background-color: transparent, following the same pattern used by the beszel-system-table widget. Also added bg=transparent to mantinePaperProps for the Paper wrapper. --- packages/widgets/src/downloads/component.tsx | 3 ++- packages/widgets/src/downloads/styles.css | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 packages/widgets/src/downloads/styles.css diff --git a/packages/widgets/src/downloads/component.tsx b/packages/widgets/src/downloads/component.tsx index 60e970c22..c74723d86 100644 --- a/packages/widgets/src/downloads/component.tsx +++ b/packages/widgets/src/downloads/component.tsx @@ -1,6 +1,7 @@ "use client"; import "../widgets-common.css"; +import "./styles.css"; import { useCallback, useMemo, useState } from "react"; import type { MantineStyleProp } from "@mantine/core"; @@ -577,7 +578,7 @@ export default function DownloadClientsWidget({ enableColumnOrdering: isEditMode, enableRowVirtualization: true, rowVirtualizerOptions: { overscan: 5 }, - mantinePaperProps: { flex: 1, withBorder: false, shadow: undefined }, + mantinePaperProps: { flex: 1, withBorder: false, shadow: undefined, bg: "transparent" }, mantineTableContainerProps: { style: { height: "100%" } }, mantineTableProps: { className: "downloads-widget-table", diff --git a/packages/widgets/src/downloads/styles.css b/packages/widgets/src/downloads/styles.css new file mode 100644 index 000000000..ae9bee8c1 --- /dev/null +++ b/packages/widgets/src/downloads/styles.css @@ -0,0 +1,12 @@ +/* Make the MantineReactTable transparent so the board card's opacity-controlled + background shows through. MRT defaults to var(--mantine-color-body) which is opaque. */ +.downloads-widget-table, +.downloads-widget-table .mantine-Table-table, +.downloads-widget-table .mantine-Table-thead, +.downloads-widget-table .mantine-Table-tbody, +.downloads-widget-table .mantine-Table-tfoot, +.downloads-widget-table .mantine-Table-tr, +.downloads-widget-table th, +.downloads-widget-table td { + background-color: transparent !important; +}