chore(release): automatic release v1.59.0
This commit is contained in:
93 files changed
+9648
-1236
No files matched your search
@@ -33,6 +33,7 @@ body:
|
||||
options:
|
||||
# The below comment is used to insert a new version with on-release.yml
|
||||
#NEXT_VERSION#
|
||||
- 1.58.1
|
||||
- 1.58.0
|
||||
- 1.57.1
|
||||
- 1.57.0
|
||||
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
|
||||
jobs:
|
||||
skip-stability-days:
|
||||
if: ${{ !startsWith(github.head_ref, 'renovate/') }}
|
||||
if: ${{ !startsWith(github.head_ref, 'renovate/') && github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
name: Skip Stability Days
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 1
|
||||
|
||||
+5
-1
@@ -67,4 +67,8 @@ e2e/shared/tmp
|
||||
apps/nextjs/public/images/background.png
|
||||
|
||||
# next-intl
|
||||
en.d.json.ts
|
||||
en.d.json.ts
|
||||
|
||||
# personal agent config
|
||||
AGENTS.md
|
||||
.opencode
|
||||
@@ -55,10 +55,10 @@
|
||||
"@mantine/modals": "^8.3.18",
|
||||
"@mantine/tiptap": "^8.3.18",
|
||||
"@million/lint": "1.0.14",
|
||||
"@tabler/icons-react": "^3.41.0",
|
||||
"@tanstack/react-query": "^5.95.2",
|
||||
"@tanstack/react-query-devtools": "^5.95.2",
|
||||
"@tanstack/react-query-next-experimental": "^5.95.2",
|
||||
"@tabler/icons-react": "^3.41.1",
|
||||
"@tanstack/react-query": "^5.96.2",
|
||||
"@tanstack/react-query-devtools": "^5.96.2",
|
||||
"@tanstack/react-query-next-experimental": "^5.96.2",
|
||||
"@trpc/client": "^11.16.0",
|
||||
"@trpc/next": "^11.16.0",
|
||||
"@trpc/react-query": "^11.16.0",
|
||||
@@ -70,20 +70,20 @@
|
||||
"chroma-js": "^3.2.0",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.20",
|
||||
"dotenv": "^17.3.1",
|
||||
"dotenv": "^17.4.1",
|
||||
"flag-icons": "^7.5.0",
|
||||
"glob": "^13.0.6",
|
||||
"isomorphic-dompurify": "^3.7.1",
|
||||
"jotai": "^2.19.0",
|
||||
"mantine-react-table": "2.0.0-beta.9",
|
||||
"next": "16.2.1",
|
||||
"next": "16.2.3",
|
||||
"postcss-preset-mantine": "^1.18.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-error-boundary": "^6.1.1",
|
||||
"react-simple-code-editor": "^0.14.1",
|
||||
"sass": "^1.98.0",
|
||||
"sass": "^1.99.0",
|
||||
"superjson": "2.2.6",
|
||||
"swagger-ui-react": "^5.32.1",
|
||||
"use-deep-compare-effect": "^1.8.1",
|
||||
@@ -94,7 +94,7 @@
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/chroma-js": "3.1.2",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/prismjs": "^1.26.6",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
|
||||
@@ -38,18 +38,18 @@
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@homarr/widgets": "workspace:^0.1.0",
|
||||
"dayjs": "^1.11.20",
|
||||
"dotenv": "^17.3.1",
|
||||
"dotenv": "^17.4.1",
|
||||
"fastify": "^5.8.4",
|
||||
"superjson": "2.2.6",
|
||||
"undici": "7.24.6"
|
||||
"undici": "8.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/node": "^24.12.2",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"esbuild": "^0.27.4",
|
||||
"esbuild": "^0.28.0",
|
||||
"eslint": "^9.39.4",
|
||||
"prettier": "^3.8.1",
|
||||
"tsx": "4.20.4",
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@homarr/redis": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"dotenv": "^17.4.1",
|
||||
"tsx": "4.20.4",
|
||||
"ws": "^8.20.0"
|
||||
},
|
||||
@@ -34,7 +34,7 @@
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"esbuild": "^0.27.4",
|
||||
"esbuild": "^0.28.0",
|
||||
"eslint": "^9.39.4",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "^5.9.3"
|
||||
|
||||
+38
-24
@@ -64,6 +64,13 @@
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/anchor" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/anchor.svg" alt="Anchor" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Anchor</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/aria2" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/PapirusDevelopmentTeam/papirus_icons@latest/src/system_downloads_3.svg" alt="Aria2" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -97,15 +104,15 @@
|
||||
<br/>
|
||||
<p align="center">Deluge</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/docker-hub" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/docker.svg" alt="Docker Hub" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Docker<br/>Hub</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/emby" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/emby.svg" alt="Emby" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -146,15 +153,15 @@
|
||||
<br/>
|
||||
<p align="center">Home<br/>Assistant</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/ical" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/ical.svg" alt="iCal" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">iCal</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/immich" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/immich.svg" alt="Immich" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -195,15 +202,15 @@
|
||||
<br/>
|
||||
<p align="center">Nextcloud</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/npm" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/npm.svg" alt="NPM" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">NPM</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/ntfy" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/ntfy.svg" alt="ntfy" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -244,15 +251,15 @@
|
||||
<br/>
|
||||
<p align="center">Pi-hole</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/plex" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/plex.svg" alt="Plex" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Plex</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/prowlarr" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/prowlarr.svg" alt="Prowlarr" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -293,15 +300,15 @@
|
||||
<br/>
|
||||
<p align="center">Readarr</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/sabnzbd" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sabnzbd.svg" alt="SABnzbd" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">SABnzbd</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/search-ch" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/search-ch.png" alt="Search.ch" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -330,13 +337,20 @@
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/speedtest-tracker" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/speedtest-tracker.png" alt="Speedtest Tracker" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Speedtest<br/>Tracker</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/tdarr" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/tdarr.png" alt="Tdarr" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Tdarr</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/tracearr" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/tracearr.svg" alt="Tracearr" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -349,8 +363,8 @@
|
||||
<br/>
|
||||
<p align="center">Transmission</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/truenas" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/truenas.svg" alt="TrueNAS" width="90" height="90" />
|
||||
<br/>
|
||||
|
||||
+7
-7
@@ -43,7 +43,7 @@
|
||||
"@semantic-release/npm": "^13.1.5",
|
||||
"@semantic-release/release-notes-generator": "^14.1.0",
|
||||
"@testcontainers/redis": "^11.13.0",
|
||||
"@turbo/gen": "^2.8.21",
|
||||
"@turbo/gen": "^2.9.3",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"@vitest/coverage-v8": "^4.1.2",
|
||||
"@vitest/ui": "^4.1.2",
|
||||
@@ -54,7 +54,7 @@
|
||||
"prettier": "^3.8.1",
|
||||
"semantic-release": "^25.0.3",
|
||||
"testcontainers": "^11.13.0",
|
||||
"turbo": "^2.8.21",
|
||||
"turbo": "^2.9.3",
|
||||
"typescript": "^5.9.3",
|
||||
"vite-tsconfig-paths": "^6.1.1",
|
||||
"vitest": "^4.1.2"
|
||||
@@ -79,18 +79,18 @@
|
||||
"overrides": {
|
||||
"@babel/helpers@<7.26.10": ">=7.29.2",
|
||||
"@babel/runtime@<7.26.10": ">=7.29.2",
|
||||
"axios@>=1.0.0 <1.8.2": ">=1.14.0",
|
||||
"axios@>=1.0.0 <1.8.2": ">=1.15.0",
|
||||
"brace-expansion@>=2.0.0 <=2.0.1": ">=5.0.5",
|
||||
"brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.5",
|
||||
"esbuild@<=0.24.2": ">=0.27.4",
|
||||
"esbuild@<=0.24.2": ">=0.28.0",
|
||||
"form-data@>=4.0.0 <4.0.4": ">=4.0.5",
|
||||
"hono@<4.6.5": ">=4.12.9",
|
||||
"hono@<4.6.5": ">=4.12.10",
|
||||
"linkifyjs@<4.3.2": ">=4.3.2",
|
||||
"nanoid@>=4.0.0 <5.0.9": ">=5.1.7",
|
||||
"prismjs@<1.30.0": ">=1.30.0",
|
||||
"proxmox-api>undici": "7.24.6",
|
||||
"proxmox-api>undici": "8.0.2",
|
||||
"react-is": "^19.2.4",
|
||||
"rollup@>=4.0.0 <4.22.4": ">=4.60.0",
|
||||
"rollup@>=4.0.0 <4.22.4": ">=4.60.1",
|
||||
"sha.js@<=2.4.11": ">=2.4.12",
|
||||
"tar-fs@>=3.0.0 <3.0.9": ">=3.1.2",
|
||||
"tar-fs@>=2.0.0 <2.1.3": ">=3.1.2",
|
||||
|
||||
@@ -41,13 +41,13 @@
|
||||
"@homarr/translation": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@kubernetes/client-node": "^1.4.0",
|
||||
"@tanstack/react-query": "^5.95.2",
|
||||
"@tanstack/react-query": "^5.96.2",
|
||||
"@trpc/client": "^11.16.0",
|
||||
"@trpc/react-query": "^11.16.0",
|
||||
"@trpc/server": "^11.16.0",
|
||||
"@trpc/tanstack-react-query": "^11.16.0",
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
"next": "16.2.1",
|
||||
"next": "16.2.3",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"superjson": "2.2.6",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createSaltAsync, hashPasswordAsync } from "@homarr/auth";
|
||||
import { hashPasswordAsync } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import { generateSecureRandomToken } from "@homarr/common/server";
|
||||
import { db, eq } from "@homarr/db";
|
||||
@@ -14,7 +14,6 @@ export const apiKeysRouter = createTRPCRouter({
|
||||
columns: {
|
||||
id: true,
|
||||
apiKey: false,
|
||||
salt: false,
|
||||
},
|
||||
with: {
|
||||
user: {
|
||||
@@ -29,14 +28,12 @@ export const apiKeysRouter = createTRPCRouter({
|
||||
});
|
||||
}),
|
||||
create: permissionRequiredProcedure.requiresPermission("admin").mutation(async ({ ctx }) => {
|
||||
const salt = await createSaltAsync();
|
||||
const randomToken = generateSecureRandomToken(64);
|
||||
const hashedRandomToken = await hashPasswordAsync(randomToken, salt);
|
||||
const hashedRandomToken = await hashPasswordAsync(randomToken);
|
||||
const id = createId();
|
||||
await db.insert(apiKeys).values({
|
||||
id,
|
||||
apiKey: hashedRandomToken,
|
||||
salt,
|
||||
userId: ctx.session.user.id,
|
||||
});
|
||||
return {
|
||||
|
||||
@@ -208,6 +208,7 @@ async function setupUrlRestrictionAsync(dbUrls: string[]) {
|
||||
hideDescription: false,
|
||||
textLinesClamp: 3,
|
||||
maximumAmountPosts: 5,
|
||||
showPosterImage: true,
|
||||
} satisfies WidgetComponentProps<"rssFeed">["options"]),
|
||||
}) satisfies InferInsertModel<typeof items>,
|
||||
),
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { MySqlRawQueryResult } from "drizzle-orm/mysql2";
|
||||
import type { QueryResult } from "pg";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createSaltAsync, hashPasswordAsync } from "@homarr/auth";
|
||||
import { comparePasswordsAsync, hashPasswordAsync } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import type { Database } from "@homarr/db";
|
||||
@@ -11,8 +11,8 @@ import { and, eq, handleTransactionsAsync, like } from "@homarr/db";
|
||||
import { getMaxGroupPositionAsync } from "@homarr/db/queries";
|
||||
import { boards, groupMembers, groupPermissions, groups, invites, users } from "@homarr/db/schema";
|
||||
import { selectUserSchema } from "@homarr/db/validationSchemas";
|
||||
import { credentialsAdminGroup } from "@homarr/definitions";
|
||||
import type { SupportedAuthProvider } from "@homarr/definitions";
|
||||
import { credentialsAdminGroup } from "@homarr/definitions";
|
||||
import { byIdSchema } from "@homarr/validation/common";
|
||||
import type { userBaseCreateSchema } from "@homarr/validation/user";
|
||||
import {
|
||||
@@ -92,15 +92,13 @@ export const userRouter = createTRPCRouter({
|
||||
|
||||
await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, "credentials", input.username);
|
||||
|
||||
const salt = await createSaltAsync();
|
||||
const hashedPassword = await hashPasswordAsync(input.password, salt);
|
||||
const hashedPassword = await hashPasswordAsync(input.password);
|
||||
|
||||
const userId = createId();
|
||||
const user = {
|
||||
id: userId,
|
||||
name: input.username,
|
||||
password: hashedPassword,
|
||||
salt,
|
||||
};
|
||||
|
||||
await handleTransactionsAsync(ctx.db, {
|
||||
@@ -393,7 +391,6 @@ export const userRouter = createTRPCRouter({
|
||||
columns: {
|
||||
id: true,
|
||||
password: true,
|
||||
salt: true,
|
||||
provider: true,
|
||||
},
|
||||
where: eq(users.id, input.userId),
|
||||
@@ -423,8 +420,7 @@ export const userRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
if (isPreviousPasswordRequired) {
|
||||
const previousPasswordHash = await hashPasswordAsync(input.previousPassword, dbUser.salt ?? "");
|
||||
const isValid = previousPasswordHash === dbUser.password;
|
||||
const isValid = await comparePasswordsAsync(input.previousPassword, dbUser.password ?? "");
|
||||
|
||||
if (!isValid) {
|
||||
throw new TRPCError({
|
||||
@@ -434,8 +430,7 @@ export const userRouter = createTRPCRouter({
|
||||
}
|
||||
}
|
||||
|
||||
const salt = await createSaltAsync();
|
||||
const hashedPassword = await hashPasswordAsync(input.password, salt);
|
||||
const hashedPassword = await hashPasswordAsync(input.password);
|
||||
await ctx.db
|
||||
.update(users)
|
||||
.set({
|
||||
@@ -583,8 +578,7 @@ export const userRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
const createUserAsync = async (db: Database, input: Omit<z.infer<typeof userBaseCreateSchema>, "groupIds">) => {
|
||||
const salt = await createSaltAsync();
|
||||
const hashedPassword = await hashPasswordAsync(input.password, salt);
|
||||
const hashedPassword = await hashPasswordAsync(input.password);
|
||||
|
||||
const userId = createId();
|
||||
await db.insert(users).values({
|
||||
@@ -592,7 +586,6 @@ const createUserAsync = async (db: Database, input: Omit<z.infer<typeof userBase
|
||||
name: input.username,
|
||||
email: input.email,
|
||||
password: hashedPassword,
|
||||
salt,
|
||||
});
|
||||
return userId;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { anchorNotesListInputSchema, anchorNoteUpdateInputSchema, createIntegrationAsync } from "@homarr/integrations";
|
||||
import { anchorNoteRequestHandler, anchorNotesListRequestHandler } from "@homarr/request-handler/anchor-notes";
|
||||
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
|
||||
const noteIdInput = z.object({
|
||||
noteId: z.string(),
|
||||
});
|
||||
|
||||
const logger = createLogger({ module: "anchorNotesRouter" });
|
||||
|
||||
const isJsonDeltaString = (value: string) => {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return typeof parsed === "object" && parsed !== null && Array.isArray((parsed as { ops?: unknown }).ops);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeAnchorContent = (content: string | undefined) => {
|
||||
if (content === undefined) return undefined;
|
||||
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) {
|
||||
return JSON.stringify({ ops: [{ insert: "\n" }] });
|
||||
}
|
||||
|
||||
if (isJsonDeltaString(content)) return content;
|
||||
|
||||
const normalizedText = content.endsWith("\n") ? content : `${content}\n`;
|
||||
return JSON.stringify({ ops: [{ insert: normalizedText }] });
|
||||
};
|
||||
|
||||
export const anchorNotesRouter = createTRPCRouter({
|
||||
listNotes: publicProcedure
|
||||
.concat(createOneIntegrationMiddleware("query", "anchor"))
|
||||
.input(anchorNotesListInputSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const handler = anchorNotesListRequestHandler.handler(ctx.integration, { ...input, limit: input.limit ?? 50 });
|
||||
|
||||
const { data } = await handler.getCachedOrUpdatedDataAsync({
|
||||
forceUpdate: false,
|
||||
});
|
||||
|
||||
return data;
|
||||
}),
|
||||
getNote: publicProcedure
|
||||
.concat(createOneIntegrationMiddleware("query", "anchor"))
|
||||
.input(noteIdInput)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const handler = anchorNoteRequestHandler.handler(ctx.integration, { noteId: input.noteId });
|
||||
|
||||
const { data } = await handler.getCachedOrUpdatedDataAsync({
|
||||
forceUpdate: false,
|
||||
});
|
||||
|
||||
return data;
|
||||
}),
|
||||
updateNote: protectedProcedure
|
||||
.concat(createOneIntegrationMiddleware("interact", "anchor"))
|
||||
.input(anchorNoteUpdateInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const integrationInstance = await createIntegrationAsync(ctx.integration);
|
||||
const normalizedContent = normalizeAnchorContent(input.content);
|
||||
const isContentNormalized =
|
||||
input.content !== undefined && normalizedContent !== undefined && input.content !== normalizedContent;
|
||||
|
||||
try {
|
||||
const updatedNote = await integrationInstance.updateNoteAsync({
|
||||
noteId: input.noteId,
|
||||
...(input.title !== undefined ? { title: input.title } : {}),
|
||||
...(normalizedContent !== undefined ? { content: normalizedContent } : {}),
|
||||
});
|
||||
|
||||
const noteHandler = anchorNoteRequestHandler.handler(ctx.integration, { noteId: input.noteId });
|
||||
await noteHandler.invalidateAsync();
|
||||
|
||||
return updatedNote;
|
||||
} catch (error) {
|
||||
if (error instanceof ResponseError && error.statusCode === 400) {
|
||||
logger.warn("Anchor update note failed validation", {
|
||||
noteId: input.noteId,
|
||||
integrationId: ctx.integration.id,
|
||||
contentLength: input.content?.length ?? 0,
|
||||
contentIsJsonDelta: input.content ? isJsonDeltaString(input.content) : false,
|
||||
contentNormalized: isContentNormalized,
|
||||
});
|
||||
}
|
||||
|
||||
if (error instanceof ResponseError && error.statusCode === 403) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You do not have permission to edit this note",
|
||||
});
|
||||
}
|
||||
|
||||
if (error instanceof ResponseError && error.statusCode === 404) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Note not found",
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { lazy } from "@trpc/server";
|
||||
import { createTRPCRouter } from "../../trpc";
|
||||
|
||||
export const widgetRouter = createTRPCRouter({
|
||||
anchorNotes: lazy(() => import("./anchor-notes").then((mod) => mod.anchorNotesRouter)),
|
||||
coolify: lazy(() => import("./coolify").then((mod) => mod.coolifyRouter)),
|
||||
immich: lazy(() => import("./immich").then((mod) => mod.immichRouter)),
|
||||
notebook: lazy(() => import("./notebook").then((mod) => mod.notebookRouter)),
|
||||
@@ -28,4 +29,5 @@ export const widgetRouter = createTRPCRouter({
|
||||
notifications: lazy(() => import("./notifications").then((mod) => mod.notificationsRouter)),
|
||||
timetable: lazy(() => import("./timetable").then((mod) => mod.timetableRouter)),
|
||||
tracearr: lazy(() => import("./tracearr").then((mod) => mod.tracearrRouter)),
|
||||
speedtestTracker: lazy(() => import("./speedtest-tracker").then((mod) => mod.speedtestTrackerRouter)),
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { observable } from "@trpc/server/observable";
|
||||
import SuperJSON from "superjson";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
@@ -5,45 +6,59 @@ import type { Session } from "@homarr/auth";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { eq } from "@homarr/db";
|
||||
import { items, users } from "@homarr/db/schema";
|
||||
import type { ExtendedFeedEntry } from "@homarr/request-handler/rss-feeds";
|
||||
import { rssFeedsRequestHandler } from "@homarr/request-handler/rss-feeds";
|
||||
|
||||
import type { WidgetComponentProps } from "../../../../widgets/src";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const feedsInput = z.object({
|
||||
urls: z.array(z.string()).max(100),
|
||||
maximumAmountPosts: z.number(),
|
||||
});
|
||||
|
||||
export const rssFeedRouter = createTRPCRouter({
|
||||
getFeeds: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
urls: z.array(z.string()).max(100),
|
||||
maximumAmountPosts: z.number(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const urls = (await canAccessAllFeedsAsync(ctx.db, ctx.session))
|
||||
? input.urls
|
||||
: await restrictUrlsAsync(ctx.db, input.urls);
|
||||
getFeeds: publicProcedure.input(feedsInput).query(async ({ ctx, input }) => {
|
||||
const urls = (await canAccessAllFeedsAsync(ctx.db, ctx.session))
|
||||
? input.urls
|
||||
: await restrictUrlsAsync(ctx.db, input.urls);
|
||||
|
||||
const rssFeeds = await Promise.all(
|
||||
urls.map(async (url) => {
|
||||
const innerHandler = rssFeedsRequestHandler.handler({
|
||||
url,
|
||||
count: input.maximumAmountPosts,
|
||||
});
|
||||
return await innerHandler.getCachedOrUpdatedDataAsync({
|
||||
forceUpdate: false,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
return rssFeeds
|
||||
.flatMap((rssFeed) => rssFeed.data.entries)
|
||||
.slice(0, input.maximumAmountPosts)
|
||||
.sort((entryA, entryB) => {
|
||||
return entryA.published && entryB.published
|
||||
? new Date(entryB.published).getTime() - new Date(entryA.published).getTime()
|
||||
: 0;
|
||||
const rssFeeds = await Promise.all(
|
||||
urls.map(async (url) => {
|
||||
const innerHandler = rssFeedsRequestHandler.handler({
|
||||
url,
|
||||
count: input.maximumAmountPosts,
|
||||
});
|
||||
}),
|
||||
return await innerHandler.getCachedOrUpdatedDataAsync({
|
||||
forceUpdate: false,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
return rssFeeds
|
||||
.flatMap((rssFeed) => rssFeed.data.entries)
|
||||
.slice(0, input.maximumAmountPosts)
|
||||
.sort((entryA, entryB) => {
|
||||
return entryA.published && entryB.published
|
||||
? new Date(entryB.published).getTime() - new Date(entryA.published).getTime()
|
||||
: 0;
|
||||
});
|
||||
}),
|
||||
subscribeFeeds: publicProcedure.input(feedsInput).subscription(({ input }) => {
|
||||
// It is not necessary to check access to all feeds here, as the client will only subscribe to feeds which already exist
|
||||
return observable<{ url: string; entries: ExtendedFeedEntry[] }>((emit) => {
|
||||
const unsubscribes = input.urls.map((url) => {
|
||||
const innerHandler = rssFeedsRequestHandler.handler({ url, count: input.maximumAmountPosts });
|
||||
return innerHandler.subscribe((data) => {
|
||||
emit.next({ url, entries: data.entries });
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
||||
export async function canAccessAllFeedsAsync(db: Database, session: Session | null) {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { observable } from "@trpc/server/observable";
|
||||
|
||||
import type { SpeedtestTrackerDashboardData } from "@homarr/integrations/types";
|
||||
import { speedtestTrackerRequestHandler } from "@homarr/request-handler/speedtest-tracker";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const speedtestTrackerRouter = createTRPCRouter({
|
||||
getDashboard: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", "speedtestTracker"))
|
||||
.query(async ({ ctx }) => {
|
||||
const results = await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const innerHandler = speedtestTrackerRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
dashboard: data,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return results;
|
||||
}),
|
||||
|
||||
subscribeToDashboard: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", "speedtestTracker"))
|
||||
.subscription(({ ctx }) => {
|
||||
return observable<{
|
||||
integrationId: string;
|
||||
dashboard: SpeedtestTrackerDashboardData;
|
||||
timestamp: Date;
|
||||
}>((emit) => {
|
||||
const unsubscribes = ctx.integrations.map((integration) => {
|
||||
const innerHandler = speedtestTrackerRequestHandler.handler(integration, {});
|
||||
return innerHandler.subscribe((dashboard) => {
|
||||
emit.next({
|
||||
integrationId: integration.id,
|
||||
dashboard,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import type { Database } from "@homarr/db";
|
||||
import { eq } from "@homarr/db";
|
||||
import { apiKeys } from "@homarr/db/schema";
|
||||
|
||||
import { hashPasswordAsync } from "../security";
|
||||
import { comparePasswordsAsync } from "../security";
|
||||
import { createSessionAsync } from "../server";
|
||||
|
||||
const logger = createLogger({ module: "apiKeyAuth" });
|
||||
@@ -41,7 +41,6 @@ export const getSessionFromApiKeyAsync = async (
|
||||
columns: {
|
||||
id: true,
|
||||
apiKey: true,
|
||||
salt: true,
|
||||
},
|
||||
with: {
|
||||
user: {
|
||||
@@ -60,9 +59,9 @@ export const getSessionFromApiKeyAsync = async (
|
||||
return null;
|
||||
}
|
||||
|
||||
const hashedApiKey = await hashPasswordAsync(apiKey, apiKeyFromDb.salt);
|
||||
const isValid = await comparePasswordsAsync(apiKey, apiKeyFromDb.apiKey);
|
||||
|
||||
if (apiKeyFromDb.apiKey !== hashedApiKey) {
|
||||
if (!isValid) {
|
||||
logger.warn("Failed to authenticate with api-key", { ipAddress, userAgent, reason: "API_KEY_MISMATCH" });
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createId } from "@homarr/common";
|
||||
import { apiKeys, users } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { createSaltAsync, hashPasswordAsync } from "../../security";
|
||||
import { hashPasswordAsync } from "../../security";
|
||||
import { getSessionFromApiKeyAsync } from "../get-api-key-session";
|
||||
|
||||
// Mock the logger to avoid console output during tests
|
||||
@@ -118,11 +118,9 @@ const setupAsync = async (options?: SetupOptions) => {
|
||||
});
|
||||
|
||||
if (options?.token) {
|
||||
const salt = await createSaltAsync();
|
||||
await db.insert(apiKeys).values({
|
||||
id: defaultApiKeyId,
|
||||
apiKey: await hashPasswordAsync(options.token, salt),
|
||||
salt,
|
||||
apiKey: await hashPasswordAsync(options.token),
|
||||
userId: defaultUserId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"bcrypt": "^6.0.0",
|
||||
"cookies": "^0.9.1",
|
||||
"ldapts": "8.1.7",
|
||||
"next": "16.2.1",
|
||||
"next": "16.2.3",
|
||||
"next-auth": "5.0.0-beta.30",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import bcrypt from "bcrypt";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
@@ -7,6 +6,8 @@ import { and, eq } from "@homarr/db";
|
||||
import { users } from "@homarr/db/schema";
|
||||
import type { userSignInSchema } from "@homarr/validation/user";
|
||||
|
||||
import { comparePasswordsAsync } from "../../../security";
|
||||
|
||||
const logger = createLogger({ module: "basicAuthorization" });
|
||||
|
||||
export const authorizeWithBasicCredentialsAsync = async (
|
||||
@@ -23,7 +24,7 @@ export const authorizeWithBasicCredentialsAsync = async (
|
||||
}
|
||||
|
||||
logger.info("User is trying to log in. Checking password...", { userName: user.name });
|
||||
const isValidPassword = await bcrypt.compare(credentials.password, user.password);
|
||||
const isValidPassword = await comparePasswordsAsync(credentials.password, user.password);
|
||||
|
||||
if (!isValidPassword) {
|
||||
logger.warn("Password for user was incorrect", { userName: user.name });
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createId } from "@homarr/common";
|
||||
import { users } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { createSaltAsync, hashPasswordAsync } from "../../security";
|
||||
import { hashPasswordAsync } from "../../security";
|
||||
import { authorizeWithBasicCredentialsAsync } from "../credentials/authorization/basic-authorization";
|
||||
|
||||
const defaultUserId = createId();
|
||||
@@ -13,12 +13,10 @@ describe("authorizeWithBasicCredentials", () => {
|
||||
test("should authorize user with correct credentials", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const salt = await createSaltAsync();
|
||||
await db.insert(users).values({
|
||||
id: defaultUserId,
|
||||
name: "test",
|
||||
salt,
|
||||
password: await hashPasswordAsync("test", salt),
|
||||
password: await hashPasswordAsync("test"),
|
||||
});
|
||||
|
||||
// Act
|
||||
@@ -34,12 +32,10 @@ describe("authorizeWithBasicCredentials", () => {
|
||||
test("should not authorize user with incorrect credentials", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const salt = await createSaltAsync();
|
||||
await db.insert(users).values({
|
||||
id: defaultUserId,
|
||||
name: "test",
|
||||
salt,
|
||||
password: await hashPasswordAsync("test", salt),
|
||||
password: await hashPasswordAsync("test"),
|
||||
});
|
||||
|
||||
// Act
|
||||
@@ -55,12 +51,10 @@ describe("authorizeWithBasicCredentials", () => {
|
||||
test("should not authorize user with incorrect username", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const salt = await createSaltAsync();
|
||||
await db.insert(users).values({
|
||||
id: defaultUserId,
|
||||
name: "test",
|
||||
salt,
|
||||
password: await hashPasswordAsync("test", salt),
|
||||
password: await hashPasswordAsync("test"),
|
||||
});
|
||||
|
||||
// Act
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import bcrypt from "bcrypt";
|
||||
|
||||
export const createSaltAsync = async () => {
|
||||
return bcrypt.genSalt(10);
|
||||
export const hashPasswordAsync = async (password: string) => {
|
||||
return bcrypt.hash(password, 10);
|
||||
};
|
||||
|
||||
export const hashPasswordAsync = async (password: string, salt: string) => {
|
||||
return bcrypt.hash(password, salt);
|
||||
/**
|
||||
* Compares a plaintext password with a hashed password and returns whether they match.
|
||||
* @param password plaintext password
|
||||
* @param hash hashed password
|
||||
* @returns wheter it is the same
|
||||
*/
|
||||
export const comparePasswordsAsync = async (password: string, hash: string) => {
|
||||
return await bcrypt.compare(password, hash);
|
||||
};
|
||||
@@ -1,25 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createSaltAsync, hashPasswordAsync } from "../security";
|
||||
|
||||
describe("createSalt should return a salt", () => {
|
||||
it("should return a salt", async () => {
|
||||
const result = await createSaltAsync();
|
||||
expect(result).toBeDefined();
|
||||
expect(result.length).toBeGreaterThan(25);
|
||||
});
|
||||
it("should return a different salt each time", async () => {
|
||||
const result1 = await createSaltAsync();
|
||||
const result2 = await createSaltAsync();
|
||||
expect(result1).not.toEqual(result2);
|
||||
});
|
||||
});
|
||||
import { comparePasswordsAsync, hashPasswordAsync } from "../security";
|
||||
|
||||
describe("hashPassword should return a hash", () => {
|
||||
it("should return a hash", async () => {
|
||||
const password = "password";
|
||||
const salt = await createSaltAsync();
|
||||
const result = await hashPasswordAsync(password, salt);
|
||||
const result = await hashPasswordAsync(password);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.length).toBeGreaterThan(55);
|
||||
expect(result).not.toEqual(password);
|
||||
@@ -27,21 +13,44 @@ describe("hashPassword should return a hash", () => {
|
||||
it("should return a different hash each time", async () => {
|
||||
const password = "password";
|
||||
const password2 = "another password";
|
||||
const salt = await createSaltAsync();
|
||||
|
||||
const result1 = await hashPasswordAsync(password, salt);
|
||||
const result2 = await hashPasswordAsync(password2, salt);
|
||||
const result1 = await hashPasswordAsync(password);
|
||||
const result2 = await hashPasswordAsync(password2);
|
||||
|
||||
expect(result1).not.toEqual(result2);
|
||||
});
|
||||
it("should return a different hash for the same password with different salts", async () => {
|
||||
const password = "password";
|
||||
const salt1 = await createSaltAsync();
|
||||
const salt2 = await createSaltAsync();
|
||||
|
||||
const result1 = await hashPasswordAsync(password, salt1);
|
||||
const result2 = await hashPasswordAsync(password, salt2);
|
||||
const result1 = await hashPasswordAsync(password);
|
||||
const result2 = await hashPasswordAsync(password);
|
||||
|
||||
expect(result1).not.toEqual(result2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("comparePasswordsAsync should compare passwords correctly", () => {
|
||||
it("should return true for matching passwords", async () => {
|
||||
// Arrange
|
||||
const password = "password";
|
||||
const hash = await hashPasswordAsync(password);
|
||||
|
||||
// Act
|
||||
const result = await comparePasswordsAsync(password, hash);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
it("should return false for non-matching passwords", async () => {
|
||||
// Arrange
|
||||
const password = "password";
|
||||
const wrongPassword = "wrongpassword";
|
||||
const hash = await hashPasswordAsync(password);
|
||||
|
||||
// Act
|
||||
const result = await comparePasswordsAsync(wrongPassword, hash);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -30,13 +30,13 @@
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"dotenv": "^17.3.1"
|
||||
"dotenv": "^17.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"esbuild": "^0.27.4",
|
||||
"esbuild": "^0.28.0",
|
||||
"eslint": "^9.39.4",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { command, string } from "@drizzle-team/brocli";
|
||||
|
||||
import { createSaltAsync, hashPasswordAsync } from "@homarr/auth";
|
||||
import { hashPasswordAsync } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import { generateSecureRandomToken } from "@homarr/common/server";
|
||||
import { and, count, db, eq } from "@homarr/db";
|
||||
@@ -67,9 +67,8 @@ export const recreateAdmin = command({
|
||||
permission: "admin",
|
||||
});
|
||||
|
||||
const salt = await createSaltAsync();
|
||||
const password = generateSecureRandomToken(24);
|
||||
const hashedPassword = await hashPasswordAsync(password, salt);
|
||||
const hashedPassword = await hashPasswordAsync(password);
|
||||
|
||||
const userId = createId();
|
||||
await db.insert(users).values({
|
||||
@@ -77,7 +76,6 @@ export const recreateAdmin = command({
|
||||
name: result.data,
|
||||
provider: "credentials",
|
||||
password: hashedPassword,
|
||||
salt,
|
||||
});
|
||||
|
||||
await db.insert(groupMembers).values({
|
||||
|
||||
@@ -22,7 +22,7 @@ export const resetPassword = command({
|
||||
where: and(eq(users.name, options.username), eq(users.provider, "credentials")),
|
||||
});
|
||||
|
||||
if (!user?.salt) {
|
||||
if (!user?.password) {
|
||||
console.error(`User ${options.username} not found`);
|
||||
return;
|
||||
}
|
||||
@@ -33,7 +33,7 @@ export const resetPassword = command({
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
password: await hashPasswordAsync(newPassword, user.salt),
|
||||
password: await hashPasswordAsync(newPassword),
|
||||
})
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ export const usersUpdatePassword = command({
|
||||
where: options.id ? eq(users.id, options.id) : eq(users.name, options.username!),
|
||||
});
|
||||
|
||||
if (!user?.salt) {
|
||||
if (!user?.password) {
|
||||
console.error("User not found or has no credentials record");
|
||||
return;
|
||||
}
|
||||
@@ -37,7 +37,7 @@ export const usersUpdatePassword = command({
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
password: await hashPasswordAsync(options.password, user.salt),
|
||||
password: await hashPasswordAsync(options.password),
|
||||
})
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
|
||||
@@ -30,11 +30,11 @@
|
||||
"@paralleldrive/cuid2": "^3.3.0",
|
||||
"cookie": "1.1.1",
|
||||
"dayjs": "^1.11.20",
|
||||
"next": "16.2.1",
|
||||
"next": "16.2.3",
|
||||
"octokit": "^5.0.5",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"undici": "7.24.6",
|
||||
"undici": "8.0.2",
|
||||
"zod": "^4.3.6",
|
||||
"zod-validation-error": "^5.0.0"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
|
||||
export const formatMetadata = (metadata: Record<string, unknown> | Error, ignoreKeys?: string[]) => {
|
||||
const metadataObject = metadata instanceof ErrorWithMetadata ? metadata.metadata : metadata;
|
||||
const metadataObject =
|
||||
metadata instanceof ErrorWithMetadata || ("metadata" in metadata && typeof metadata.metadata === "object")
|
||||
? (metadata.metadata as Record<string, unknown>)
|
||||
: metadata;
|
||||
|
||||
const filteredMetadata = Object.keys(metadataObject)
|
||||
.filter((key) => !ignoreKeys?.includes(key))
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/core": "workspace:^0.1.0",
|
||||
"@homarr/cron-jobs": "workspace:^0.1.0",
|
||||
"@tanstack/react-query": "^5.95.2",
|
||||
"@tanstack/react-query": "^5.96.2",
|
||||
"@trpc/client": "^11.16.0",
|
||||
"@trpc/server": "^11.16.0",
|
||||
"@trpc/tanstack-react-query": "^11.16.0",
|
||||
|
||||
@@ -5,9 +5,9 @@ import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import { EVERY_10_MINUTES } from "@homarr/cron-jobs-core/expressions";
|
||||
import { db, eq } from "@homarr/db";
|
||||
import { items } from "@homarr/db/schema";
|
||||
// This import is done that way to avoid circular dependencies.
|
||||
import { rssFeedsRequestHandler } from "@homarr/request-handler/rss-feeds";
|
||||
|
||||
// This import is done that way to avoid circular dependencies.
|
||||
import type { WidgetComponentProps } from "../../../widgets";
|
||||
import { createCronJob } from "../lib";
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `apiKey` DROP COLUMN `salt`;--> statement-breakpoint
|
||||
ALTER TABLE `user` DROP COLUMN `salt`;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -260,6 +260,13 @@
|
||||
"when": 1760968518445,
|
||||
"tag": "0036_add_app_reference_to_integration",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 37,
|
||||
"version": "5",
|
||||
"when": 1775399941430,
|
||||
"tag": "0037_remove_salt",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "apiKey" DROP COLUMN "salt";--> statement-breakpoint
|
||||
ALTER TABLE "user" DROP COLUMN "salt";
|
||||
File diff suppressed because it is too large.
Load diff
@@ -22,6 +22,13 @@
|
||||
"when": 1760968530084,
|
||||
"tag": "0002_add_app_reference_to_integration",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1775399963041,
|
||||
"tag": "0003_remove_salt",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `apiKey` DROP COLUMN `salt`;--> statement-breakpoint
|
||||
ALTER TABLE `user` DROP COLUMN `salt`;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -246,6 +246,13 @@
|
||||
"when": 1760968503571,
|
||||
"tag": "0034_add_app_reference_to_integration",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 35,
|
||||
"version": "6",
|
||||
"when": 1775399918831,
|
||||
"tag": "0035_remove_salt",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -52,7 +52,7 @@
|
||||
"@testcontainers/mysql": "^11.13.0",
|
||||
"@testcontainers/postgresql": "^11.13.0",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"dotenv": "^17.4.1",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"drizzle-zod": "^0.8.3",
|
||||
@@ -67,7 +67,7 @@
|
||||
"@types/better-sqlite3": "7.6.13",
|
||||
"@types/pg": "^8.20.0",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"esbuild": "^0.27.4",
|
||||
"esbuild": "^0.28.0",
|
||||
"eslint": "^9.39.4",
|
||||
"prettier": "^3.8.1",
|
||||
"tsx": "4.20.4",
|
||||
|
||||
@@ -51,7 +51,6 @@ export * from "@homarr/core/infrastructure/certificates/hostnames/db/mysql";
|
||||
export const apiKeys = mysqlTable("apiKey", {
|
||||
id: varchar({ length: 64 }).notNull().primaryKey(),
|
||||
apiKey: text().notNull(),
|
||||
salt: text().notNull(),
|
||||
userId: varchar({ length: 64 })
|
||||
.notNull()
|
||||
.references((): AnyMySqlColumn => users.id, {
|
||||
@@ -66,7 +65,6 @@ export const users = mysqlTable("user", {
|
||||
emailVerified: timestamp(),
|
||||
image: text(),
|
||||
password: text(),
|
||||
salt: text(),
|
||||
provider: varchar({ length: 64 }).$type<SupportedAuthProvider>().default("credentials").notNull(),
|
||||
homeBoardId: varchar({ length: 64 }).references((): AnyMySqlColumn => boards.id, {
|
||||
onDelete: "set null",
|
||||
|
||||
@@ -50,7 +50,6 @@ export * from "@homarr/core/infrastructure/certificates/hostnames/db/postgresql"
|
||||
export const apiKeys = pgTable("apiKey", {
|
||||
id: varchar({ length: 64 }).notNull().primaryKey(),
|
||||
apiKey: text().notNull(),
|
||||
salt: text().notNull(),
|
||||
userId: varchar({ length: 64 })
|
||||
.notNull()
|
||||
.references((): AnyPgColumn => users.id, {
|
||||
@@ -65,7 +64,6 @@ export const users = pgTable("user", {
|
||||
emailVerified: timestamp(),
|
||||
image: text(),
|
||||
password: text(),
|
||||
salt: text(),
|
||||
provider: varchar({ length: 64 }).$type<SupportedAuthProvider>().default("credentials").notNull(),
|
||||
homeBoardId: varchar({ length: 64 }).references((): AnyPgColumn => boards.id, {
|
||||
onDelete: "set null",
|
||||
|
||||
@@ -33,7 +33,6 @@ export * from "@homarr/core/infrastructure/certificates/hostnames/db/sqlite";
|
||||
export const apiKeys = sqliteTable("apiKey", {
|
||||
id: text().notNull().primaryKey(),
|
||||
apiKey: text().notNull(),
|
||||
salt: text().notNull(),
|
||||
userId: text()
|
||||
.notNull()
|
||||
.references((): AnySQLiteColumn => users.id, {
|
||||
@@ -48,7 +47,6 @@ export const users = sqliteTable("user", {
|
||||
emailVerified: int({ mode: "timestamp_ms" }),
|
||||
image: text(),
|
||||
password: text(),
|
||||
salt: text(),
|
||||
provider: text().$type<SupportedAuthProvider>().default("credentials").notNull(),
|
||||
homeBoardId: text().references((): AnySQLiteColumn => boards.id, {
|
||||
onDelete: "set null",
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"fast-xml-parser": "^5.5.9",
|
||||
"fast-xml-parser": "^5.5.10",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -312,6 +312,14 @@ export const integrationDefs = {
|
||||
category: ["calendar"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/ical"),
|
||||
},
|
||||
anchor: {
|
||||
name: "Anchor",
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/anchor.svg",
|
||||
category: ["notes"],
|
||||
// @ts-expect-error - docs page will be created when integration is merged
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/anchor"),
|
||||
},
|
||||
truenas: {
|
||||
name: "TrueNAS",
|
||||
secretKinds: [["username", "password"]],
|
||||
@@ -356,6 +364,14 @@ export const integrationDefs = {
|
||||
category: ["mediaMonitoring"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/tracearr"),
|
||||
},
|
||||
speedtestTracker: {
|
||||
name: "Speedtest Tracker",
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/speedtest-tracker.png",
|
||||
category: ["speedtest"],
|
||||
// @ts-expect-error - docs page will be created when integration is merged
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/speedtest-tracker"),
|
||||
},
|
||||
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
|
||||
mock: {
|
||||
name: "Mock",
|
||||
@@ -444,7 +460,9 @@ export const integrationCategories = [
|
||||
"firewall",
|
||||
"timetable",
|
||||
"photoService",
|
||||
"notes",
|
||||
"mediaMonitoring",
|
||||
"speedtest",
|
||||
] as const;
|
||||
|
||||
export type IntegrationCategory = (typeof integrationCategories)[number];
|
||||
@@ -5,6 +5,7 @@ export const widgetKinds = [
|
||||
"iframe",
|
||||
"video",
|
||||
"notebook",
|
||||
"anchorNote",
|
||||
"dnsHoleSummary",
|
||||
"dnsHoleControls",
|
||||
"smartHome-entityState",
|
||||
@@ -35,5 +36,6 @@ export const widgetKinds = [
|
||||
"immich-serverStats",
|
||||
"immich-albumCarousel",
|
||||
"tracearr",
|
||||
"speedtestTracker",
|
||||
] as const;
|
||||
export type WidgetKind = (typeof widgetKinds)[number];
|
||||
@@ -47,7 +47,7 @@
|
||||
"proxmox-api": "1.1.1",
|
||||
"timezones-ical-library": "^2.1.3",
|
||||
"tsdav": "^2.1.8",
|
||||
"undici": "7.24.6",
|
||||
"undici": "8.0.2",
|
||||
"xml2js": "^0.6.2",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { fetch as undiciFetch } from "undici";
|
||||
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
|
||||
import type { IntegrationTestingInput } from "../base/integration";
|
||||
import { Integration } from "../base/integration";
|
||||
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||
import { anchorNoteSchema, anchorNoteSummaryListSchema } from "./anchor-types";
|
||||
import type { AnchorNote, AnchorNotesListInput, AnchorNoteSummary, AnchorNoteUpdateInput } from "./anchor-types";
|
||||
|
||||
const logger = createLogger({ module: "anchorIntegration" });
|
||||
|
||||
export class AnchorIntegration extends Integration {
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const response = await input.fetchAsync(this.url("/api/notes", { limit: 1 }), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.getSecretValue("apiKey")}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) return TestConnectionError.StatusResult(response);
|
||||
|
||||
anchorNoteSummaryListSchema.parse(await response.json());
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
public async listNotesAsync(input: AnchorNotesListInput = {}): Promise<AnchorNoteSummary[]> {
|
||||
const queryParams = buildListQuery(input);
|
||||
const response = await this.requestAsync(fetchWithTrustedCertificatesAsync, "/api/notes", {
|
||||
queryParams,
|
||||
});
|
||||
return anchorNoteSummaryListSchema.parse(response);
|
||||
}
|
||||
|
||||
public async getNoteAsync(noteId: string): Promise<AnchorNote> {
|
||||
const response = await this.requestAsync(fetchWithTrustedCertificatesAsync, `/api/notes/${noteId}`);
|
||||
return anchorNoteSchema.parse(response);
|
||||
}
|
||||
|
||||
public async updateNoteAsync(input: AnchorNoteUpdateInput): Promise<AnchorNote> {
|
||||
const { noteId, ...updateData } = input;
|
||||
const response = await this.requestAsync(fetchWithTrustedCertificatesAsync, `/api/notes/${noteId}`, {
|
||||
method: "PATCH",
|
||||
body: updateData,
|
||||
});
|
||||
if (response === null) {
|
||||
return this.getNoteAsync(noteId);
|
||||
}
|
||||
return anchorNoteSchema.parse(response);
|
||||
}
|
||||
|
||||
private async requestAsync(
|
||||
fetchAsync: typeof undiciFetch,
|
||||
path: `/${string}`,
|
||||
options?: {
|
||||
queryParams?: Record<string, string>;
|
||||
method?: string;
|
||||
body?: unknown;
|
||||
},
|
||||
): Promise<unknown> {
|
||||
const url = this.url(path, options?.queryParams);
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${this.getSecretValue("apiKey")}`,
|
||||
};
|
||||
|
||||
if (options?.body !== undefined) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
const response = await fetchAsync(url, {
|
||||
method: options?.method ?? "GET",
|
||||
headers,
|
||||
body: options?.body !== undefined ? JSON.stringify(options.body) : undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorBody: string | null = null;
|
||||
try {
|
||||
errorBody = await response.text();
|
||||
} catch {
|
||||
errorBody = null;
|
||||
}
|
||||
logger.warn("Anchor request failed", {
|
||||
statusCode: response.status,
|
||||
url,
|
||||
errorBody: errorBody ? errorBody.slice(0, 200) : null,
|
||||
});
|
||||
throw new ResponseError(response);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
}
|
||||
|
||||
const buildListQuery = (input: AnchorNotesListInput) => {
|
||||
const queryParams: Record<string, string> = {};
|
||||
|
||||
if (input.search?.trim()) {
|
||||
queryParams.search = input.search.trim();
|
||||
}
|
||||
|
||||
if (input.tagId?.trim()) {
|
||||
queryParams.tagId = input.tagId.trim();
|
||||
}
|
||||
|
||||
if (typeof input.limit === "number") {
|
||||
queryParams.limit = input.limit.toString();
|
||||
}
|
||||
|
||||
return Object.keys(queryParams).length > 0 ? queryParams : undefined;
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const anchorNotesListInputSchema = z.object({
|
||||
search: z.string().optional(),
|
||||
tagId: z.string().optional(),
|
||||
limit: z.number().min(1).max(200).optional(),
|
||||
});
|
||||
export type AnchorNotesListInput = z.infer<typeof anchorNotesListInputSchema>;
|
||||
|
||||
export const anchorNotePermissionSchema = z.enum(["owner", "viewer", "editor"]);
|
||||
export type AnchorNotePermission = z.infer<typeof anchorNotePermissionSchema>;
|
||||
|
||||
export const anchorNoteSummarySchema = z.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
updatedAt: z.string(),
|
||||
isPinned: z.boolean(),
|
||||
tagIds: z.array(z.string()),
|
||||
permission: anchorNotePermissionSchema,
|
||||
});
|
||||
export type AnchorNoteSummary = z.infer<typeof anchorNoteSummarySchema>;
|
||||
|
||||
export const anchorNoteSchema = anchorNoteSummarySchema.extend({
|
||||
content: z.string().nullable().optional(),
|
||||
createdAt: z.string(),
|
||||
isArchived: z.boolean(),
|
||||
background: z.string().nullable().optional(),
|
||||
userId: z.string(),
|
||||
});
|
||||
export type AnchorNote = z.infer<typeof anchorNoteSchema>;
|
||||
|
||||
export const anchorNoteSummaryListSchema = z.array(anchorNoteSummarySchema);
|
||||
|
||||
export const anchorNoteUpdateInputSchema = z
|
||||
.object({
|
||||
noteId: z.string(),
|
||||
title: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
})
|
||||
.refine((value) => value.title !== undefined || value.content !== undefined, {
|
||||
message: "At least one field to update must be provided",
|
||||
});
|
||||
export type AnchorNoteUpdateInput = z.infer<typeof anchorNoteUpdateInputSchema>;
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
|
||||
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
|
||||
import { AnchorIntegration } from "../anchor/anchor-integration";
|
||||
import { CodebergIntegration } from "../codeberg/codeberg-integration";
|
||||
import { CoolifyIntegration } from "../coolify/coolify-integration";
|
||||
import { DashDotIntegration } from "../dashdot/dashdot-integration";
|
||||
@@ -42,6 +43,7 @@ import { ProxmoxIntegration } from "../proxmox/proxmox-integration";
|
||||
import { QuayIntegration } from "../quay/quay-integration";
|
||||
import { SearchChIntegration } from "../search-ch/search-ch-integration";
|
||||
import { SeerrIntegration } from "../seerr/seerr-integration";
|
||||
import { SpeedtestTrackerIntegration } from "../speedtest-tracker/speedtest-tracker-integration";
|
||||
import { TracearrIntegration } from "../tracearr/tracearr-integration";
|
||||
import { TrueNasIntegration } from "../truenas/truenas-integration";
|
||||
import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration";
|
||||
@@ -71,6 +73,7 @@ type IntegrationInstance = new (integration: IntegrationInput) => Integration;
|
||||
|
||||
// factories are an array, to differentiate in js between class constructors and functions
|
||||
export const integrationCreators = {
|
||||
anchor: AnchorIntegration,
|
||||
piHole: [createPiHoleIntegrationAsync],
|
||||
adGuardHome: AdGuardHomeIntegration,
|
||||
homeAssistant: HomeAssistantIntegration,
|
||||
@@ -117,6 +120,7 @@ export const integrationCreators = {
|
||||
glances: GlancesIntegration,
|
||||
searchCh: SearchChIntegration,
|
||||
immich: ImmichIntegration,
|
||||
speedtestTracker: SpeedtestTrackerIntegration,
|
||||
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
|
||||
|
||||
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// General integrations
|
||||
export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration";
|
||||
export { AnchorIntegration } from "./anchor/anchor-integration";
|
||||
export { Aria2Integration } from "./download-client/aria2/aria2-integration";
|
||||
export { DelugeIntegration } from "./download-client/deluge/deluge-integration";
|
||||
export { NzbGetIntegration } from "./download-client/nzbget/nzbget-integration";
|
||||
@@ -31,6 +32,7 @@ export { ICalIntegration } from "./ical/ical-integration";
|
||||
export { CoolifyIntegration } from "./coolify/coolify-integration";
|
||||
export { ImmichIntegration } from "./immich/immich-integration";
|
||||
export { TracearrIntegration } from "./tracearr/tracearr-integration";
|
||||
export { SpeedtestTrackerIntegration } from "./speedtest-tracker/speedtest-tracker-integration";
|
||||
|
||||
// Types
|
||||
export type { IntegrationInput } from "./base/integration";
|
||||
@@ -57,9 +59,19 @@ export type {
|
||||
export type { ReleasesRepository, ReleaseResponse } from "./interfaces/releases-providers/releases-providers-types";
|
||||
export type { Notification } from "./interfaces/notifications/notification-types";
|
||||
export type { ImmichServerStats, ImmichAlbum, ImmichAsset } from "./immich/immich-integration";
|
||||
export type {
|
||||
AnchorNote,
|
||||
AnchorNotePermission,
|
||||
AnchorNoteUpdateInput,
|
||||
AnchorNotesListInput,
|
||||
AnchorNoteSummary,
|
||||
} from "./anchor/anchor-types";
|
||||
export type { TracearrDashboardData } from "./tracearr/tracearr-types";
|
||||
export type { SpeedtestTrackerDashboardData } from "./speedtest-tracker/speedtest-tracker-types";
|
||||
|
||||
// Schemas
|
||||
export { anchorNotesListInputSchema } from "./anchor/anchor-types";
|
||||
export { anchorNoteUpdateInputSchema } from "./anchor/anchor-types";
|
||||
export { downloadClientItemSchema } from "./interfaces/downloads/download-client-items";
|
||||
|
||||
// Helpers
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import type { IntegrationTestingInput } from "../base/integration";
|
||||
import { Integration } from "../base/integration";
|
||||
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||
import {
|
||||
speedtestTrackerLatestResultEnvelopeSchema,
|
||||
speedtestTrackerResultsCollectionSchema,
|
||||
speedtestTrackerStatsEnvelopeSchema,
|
||||
} from "./speedtest-tracker-types";
|
||||
import type {
|
||||
SpeedtestTrackerDashboardData,
|
||||
SpeedtestTrackerResult,
|
||||
SpeedtestTrackerResultsCollection,
|
||||
SpeedtestTrackerStats,
|
||||
} from "./speedtest-tracker-types";
|
||||
|
||||
export class SpeedtestTrackerIntegration extends Integration {
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const url = this.url("/api/v1/results/latest");
|
||||
const response = await input.fetchAsync(url, {
|
||||
headers: this.getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return TestConnectionError.StatusResult(response);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
public async getLatestResultAsync(): Promise<SpeedtestTrackerResult | null> {
|
||||
const response = await this.getAsync("/api/v1/results/latest");
|
||||
|
||||
// Speedtest Tracker returns 404 when no tests have been run yet (fresh install)
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ResponseError(response);
|
||||
}
|
||||
|
||||
return speedtestTrackerLatestResultEnvelopeSchema.parse(await response.json()).data;
|
||||
}
|
||||
|
||||
public async getStatsAsync(): Promise<SpeedtestTrackerStats | null> {
|
||||
const response = await this.getAsync("/api/v1/stats");
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ResponseError(response);
|
||||
}
|
||||
|
||||
return speedtestTrackerStatsEnvelopeSchema.parse(await response.json()).data;
|
||||
}
|
||||
|
||||
public async getRecentResultsAsync(perPage = 10): Promise<SpeedtestTrackerResultsCollection> {
|
||||
const response = await this.getAsync("/api/v1/results", {
|
||||
result_count: String(perPage),
|
||||
sort: "-created_at",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ResponseError(response);
|
||||
}
|
||||
|
||||
return speedtestTrackerResultsCollectionSchema.parse(await response.json());
|
||||
}
|
||||
|
||||
public async getDashboardDataAsync(): Promise<SpeedtestTrackerDashboardData> {
|
||||
const [latestResult, stats, recentCollection] = await Promise.all([
|
||||
this.getLatestResultAsync(),
|
||||
this.getStatsAsync(),
|
||||
this.getRecentResultsAsync(30),
|
||||
]);
|
||||
|
||||
return {
|
||||
latestResult,
|
||||
stats,
|
||||
recentResults: recentCollection.data,
|
||||
};
|
||||
}
|
||||
|
||||
private async getAsync(path: `/api/v1/${string}`, queryParams?: Record<string, string>) {
|
||||
return await fetchWithTrustedCertificatesAsync(this.url(path, queryParams), {
|
||||
headers: this.getAuthHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
private getAuthHeaders(): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${this.getSecretValue("apiKey")}`,
|
||||
Accept: "application/json",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const createEnvelopeSchema = <T extends z.ZodType>(dataSchema: T) => z.object({ data: dataSchema });
|
||||
|
||||
const parseTimestamp = (timestamp: string): Date => new Date(`${timestamp.replace(" ", "T")}Z`);
|
||||
|
||||
export const speedtestTrackerResultSchema = z.object({
|
||||
id: z.number().int(),
|
||||
ping: z.number().nullable(),
|
||||
download_bits: z.number().int().nullish(),
|
||||
upload_bits: z.number().int().nullish(),
|
||||
healthy: z.boolean().nullable(),
|
||||
created_at: z.string().transform(parseTimestamp),
|
||||
});
|
||||
|
||||
export type SpeedtestTrackerResult = z.infer<typeof speedtestTrackerResultSchema>;
|
||||
|
||||
export const speedtestTrackerLatestResultEnvelopeSchema = createEnvelopeSchema(speedtestTrackerResultSchema);
|
||||
|
||||
export const speedtestTrackerStatsBandwidthSchema = z.object({
|
||||
avg: z.number(),
|
||||
avg_bits: z.number().optional(),
|
||||
min: z.number(),
|
||||
max: z.number(),
|
||||
});
|
||||
|
||||
export const speedtestTrackerStatsSchema = z.object({
|
||||
ping: z.object({
|
||||
avg: z.number(),
|
||||
min: z.number(),
|
||||
max: z.number(),
|
||||
}),
|
||||
download: speedtestTrackerStatsBandwidthSchema,
|
||||
upload: speedtestTrackerStatsBandwidthSchema,
|
||||
total_results: z.number().int(),
|
||||
});
|
||||
|
||||
export type SpeedtestTrackerStats = z.infer<typeof speedtestTrackerStatsSchema>;
|
||||
|
||||
export const speedtestTrackerStatsEnvelopeSchema = createEnvelopeSchema(speedtestTrackerStatsSchema);
|
||||
|
||||
export const speedtestTrackerResultsCollectionSchema = z.object({
|
||||
data: z.array(speedtestTrackerResultSchema),
|
||||
meta: z.object({
|
||||
current_page: z.number().int(),
|
||||
last_page: z.number().int(),
|
||||
total: z.number().int(),
|
||||
per_page: z.number().int().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type SpeedtestTrackerResultsCollection = z.infer<typeof speedtestTrackerResultsCollectionSchema>;
|
||||
|
||||
export interface SpeedtestTrackerDashboardData {
|
||||
latestResult: SpeedtestTrackerResult | null;
|
||||
stats: SpeedtestTrackerStats | null;
|
||||
recentResults: SpeedtestTrackerResult[];
|
||||
}
|
||||
@@ -12,5 +12,7 @@ export * from "./unifi-controller/unifi-controller-types";
|
||||
export * from "./opnsense/opnsense-types";
|
||||
export * from "./interfaces/media-releases";
|
||||
export * from "./coolify/coolify-types";
|
||||
export * from "./anchor/anchor-types";
|
||||
export * from "./interfaces/timetable/timetable-types";
|
||||
export * from "./tracearr/tracearr-types";
|
||||
export * from "./speedtest-tracker/speedtest-tracker-types";
|
||||
@@ -34,9 +34,9 @@
|
||||
"@homarr/ui": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@mantine/core": "^8.3.18",
|
||||
"@tabler/icons-react": "^3.41.0",
|
||||
"@tabler/icons-react": "^3.41.1",
|
||||
"dayjs": "^1.11.20",
|
||||
"next": "16.2.1",
|
||||
"next": "16.2.3",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"zod": "^4.3.6"
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"dependencies": {
|
||||
"@homarr/ui": "workspace:^0.1.0",
|
||||
"@mantine/notifications": "^8.3.18",
|
||||
"@tabler/icons-react": "^3.41.0"
|
||||
"@tabler/icons-react": "^3.41.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
|
||||
@@ -39,8 +39,8 @@
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@mantine/core": "^8.3.18",
|
||||
"@mantine/hooks": "^8.3.18",
|
||||
"adm-zip": "0.5.16",
|
||||
"next": "16.2.1",
|
||||
"adm-zip": "0.5.17",
|
||||
"next": "16.2.3",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"superjson": "2.2.6",
|
||||
|
||||
@@ -16,7 +16,7 @@ export const mapAndDecryptUsers = (importUsers: OldmarrImportUser[], encryptionT
|
||||
({
|
||||
id,
|
||||
password,
|
||||
salt,
|
||||
salt: _saltIsNotNeededAsPartOfPassword,
|
||||
settings,
|
||||
...user
|
||||
}): InferInsertModel<typeof users> & { oldId: string; isAdmin: boolean } => ({
|
||||
@@ -30,7 +30,6 @@ export const mapAndDecryptUsers = (importUsers: OldmarrImportUser[], encryptionT
|
||||
pingIconsEnabled: settings?.replacePingWithIcons,
|
||||
isAdmin: user.isAdmin || user.isOwner,
|
||||
password: decryptSecretWithKey(password, key),
|
||||
salt: decryptSecretWithKey(salt, key),
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -122,6 +122,7 @@ const optionMapping: OptionMapping = {
|
||||
maximumAmountPosts: (oldOptions) => oldOptions.maximumAmountOfPosts,
|
||||
textLinesClamp: (oldOptions) => oldOptions.textLinesClamp,
|
||||
hideDescription: () => undefined,
|
||||
showPosterImage: () => true, // This was the default in oldmarr
|
||||
},
|
||||
notebook: {
|
||||
allowReadOnlyCheck: (oldOptions) => oldOptions.allowReadOnlyCheck,
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"dayjs": "^1.11.20",
|
||||
"octokit": "^5.0.5",
|
||||
"superjson": "2.2.6",
|
||||
"undici": "7.24.6",
|
||||
"undici": "8.0.2",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import type { AnchorNote, AnchorNotesListInput, AnchorNoteSummary } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
|
||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||
|
||||
export const anchorNotesListRequestHandler = createCachedIntegrationRequestHandler<
|
||||
AnchorNoteSummary[],
|
||||
"anchor",
|
||||
AnchorNotesListInput
|
||||
>({
|
||||
queryKey: "anchorNotesList",
|
||||
cacheDuration: dayjs.duration(15, "seconds"),
|
||||
async requestAsync(integration, input) {
|
||||
const instance = await createIntegrationAsync(integration);
|
||||
return instance.listNotesAsync(input);
|
||||
},
|
||||
});
|
||||
|
||||
export const anchorNoteRequestHandler = createCachedIntegrationRequestHandler<AnchorNote, "anchor", { noteId: string }>(
|
||||
{
|
||||
queryKey: "anchorNote",
|
||||
cacheDuration: dayjs.duration(15, "seconds"),
|
||||
async requestAsync(integration, input) {
|
||||
const instance = await createIntegrationAsync(integration);
|
||||
return instance.getNoteAsync(input.noteId);
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -28,7 +28,7 @@ export const rssFeedsRequestHandler = createCachedWidgetRequestHandler({
|
||||
|
||||
return {
|
||||
...result,
|
||||
entries: result.entries?.slice(0, input.count) ?? [],
|
||||
entries: result.entries?.map((entry) => ({ ...entry, feedUrl: input.url })).slice(0, input.count) ?? [],
|
||||
};
|
||||
},
|
||||
cacheDuration: dayjs.duration(5, "minutes"),
|
||||
@@ -109,6 +109,7 @@ const getFirstMediaProperty = (feedObject: object) => {
|
||||
* This interface adds properties on top of the default ones.
|
||||
*/
|
||||
export interface ExtendedFeedEntry extends FeedEntry {
|
||||
feedUrl: string;
|
||||
enclosure?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { SpeedtestTrackerDashboardData } from "@homarr/integrations/types";
|
||||
|
||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||
|
||||
export const speedtestTrackerRequestHandler = createCachedIntegrationRequestHandler<
|
||||
SpeedtestTrackerDashboardData,
|
||||
"speedtestTracker",
|
||||
Record<string, never>
|
||||
>({
|
||||
async requestAsync(integration, _input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getDashboardDataAsync();
|
||||
},
|
||||
cacheDuration: dayjs.duration(10, "minutes"),
|
||||
queryKey: "speedtestTrackerDashboard",
|
||||
});
|
||||
@@ -26,7 +26,7 @@
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"@homarr/server-settings": "workspace:^0.1.0",
|
||||
"@mantine/dates": "^8.3.18",
|
||||
"next": "16.2.1",
|
||||
"next": "16.2.3",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
},
|
||||
|
||||
@@ -36,9 +36,9 @@
|
||||
"@mantine/core": "^8.3.18",
|
||||
"@mantine/hooks": "^8.3.18",
|
||||
"@mantine/spotlight": "^8.3.18",
|
||||
"@tabler/icons-react": "^3.41.0",
|
||||
"@tabler/icons-react": "^3.41.1",
|
||||
"jotai": "^2.19.0",
|
||||
"next": "16.2.1",
|
||||
"next": "16.2.3",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"use-deep-compare-effect": "^1.8.1"
|
||||
|
||||
@@ -32,8 +32,8 @@
|
||||
"dayjs": "^1.11.20",
|
||||
"deepmerge": "4.3.1",
|
||||
"mantine-react-table": "2.0.0-beta.9",
|
||||
"next": "16.2.1",
|
||||
"next-intl": "4.8.3",
|
||||
"next": "16.2.3",
|
||||
"next-intl": "4.9.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
},
|
||||
|
||||
@@ -1526,13 +1526,13 @@
|
||||
}
|
||||
},
|
||||
"timetable": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"title": "",
|
||||
"noStation": "",
|
||||
"name": "Timetable",
|
||||
"description": "Displays departure times for a station",
|
||||
"title": "Departures {station}",
|
||||
"noStation": "No station selected",
|
||||
"option": {
|
||||
"station": {
|
||||
"label": ""
|
||||
"label": "Station"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1977,177 +1977,177 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "Failed to fetch containers stats"
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"name": "Coolify",
|
||||
"description": "Overview of your Coolify instance with servers and applications",
|
||||
"option": {
|
||||
"showServers": {
|
||||
"label": ""
|
||||
"label": "Show servers"
|
||||
},
|
||||
"showApplications": {
|
||||
"label": ""
|
||||
"label": "Show applications"
|
||||
},
|
||||
"showServices": {
|
||||
"label": ""
|
||||
"label": "Show services"
|
||||
}
|
||||
},
|
||||
"empty": {
|
||||
"servers": "",
|
||||
"applications": "",
|
||||
"services": ""
|
||||
"servers": "No servers found",
|
||||
"applications": "No applications found",
|
||||
"services": "No services found"
|
||||
},
|
||||
"footer": {
|
||||
"updated": ""
|
||||
"updated": "Updated {when}"
|
||||
},
|
||||
"error": {
|
||||
"noIntegration": "",
|
||||
"internalServerError": ""
|
||||
"noIntegration": "No Coolify integration configured",
|
||||
"internalServerError": "Failed to fetch Coolify data"
|
||||
},
|
||||
"server": {
|
||||
"buildServer": ""
|
||||
"buildServer": "Build"
|
||||
},
|
||||
"instances": {
|
||||
"title": ""
|
||||
"title": "Instances"
|
||||
}
|
||||
},
|
||||
"tracearr": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"name": "Tracearr",
|
||||
"description": "Enhanced media server monitoring with detailed stream and transcoding data",
|
||||
"option": {
|
||||
"showStreams": {
|
||||
"label": ""
|
||||
"label": "Show active streams"
|
||||
},
|
||||
"showStats": {
|
||||
"label": ""
|
||||
"label": "Show stats summary"
|
||||
},
|
||||
"showRecentActivity": {
|
||||
"label": ""
|
||||
"label": "Show recent activity"
|
||||
},
|
||||
"showViolations": {
|
||||
"label": ""
|
||||
"label": "Show violations"
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"activeStreams": "",
|
||||
"totalUsers": "",
|
||||
"transcodes": "",
|
||||
"bandwidth": ""
|
||||
"activeStreams": "Active Streams",
|
||||
"totalUsers": "Total Users",
|
||||
"transcodes": "Transcodes",
|
||||
"bandwidth": "Bandwidth"
|
||||
},
|
||||
"streams": {
|
||||
"empty": "",
|
||||
"user": "",
|
||||
"media": "",
|
||||
"state": "",
|
||||
"quality": "",
|
||||
"device": "",
|
||||
"progress": "",
|
||||
"empty": "No active streams",
|
||||
"user": "User",
|
||||
"media": "Media",
|
||||
"state": "State",
|
||||
"quality": "Quality",
|
||||
"device": "Device",
|
||||
"progress": "Progress",
|
||||
"videoDecision": {
|
||||
"directPlay": "",
|
||||
"transcode": "",
|
||||
"directStream": ""
|
||||
"directPlay": "Direct Play",
|
||||
"transcode": "Transcode",
|
||||
"directStream": "Direct Stream"
|
||||
}
|
||||
},
|
||||
"recentActivity": {
|
||||
"title": "",
|
||||
"empty": "",
|
||||
"watched": "",
|
||||
"partial": ""
|
||||
"title": "Recent Activity",
|
||||
"empty": "No recent activity",
|
||||
"watched": "Watched",
|
||||
"partial": "Partial"
|
||||
},
|
||||
"violations": {
|
||||
"title": "",
|
||||
"empty": "",
|
||||
"rule": "",
|
||||
"user": ""
|
||||
"title": "Violations",
|
||||
"empty": "No recent violations",
|
||||
"rule": "Rule",
|
||||
"user": "User"
|
||||
},
|
||||
"noSectionsEnabled": "",
|
||||
"noSectionsEnabled": "No sections enabled",
|
||||
"error": {
|
||||
"noIntegration": "",
|
||||
"internalServerError": ""
|
||||
"noIntegration": "No Tracearr integration configured",
|
||||
"internalServerError": "Failed to fetch Tracearr data"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"location": {
|
||||
"query": "",
|
||||
"latitude": "",
|
||||
"longitude": "",
|
||||
"disabledTooltip": "",
|
||||
"unknownLocation": "",
|
||||
"search": "",
|
||||
"query": "City / Postal code",
|
||||
"latitude": "Latitude",
|
||||
"longitude": "Longitude",
|
||||
"disabledTooltip": "Please enter a city or postal code",
|
||||
"unknownLocation": "Unknown location",
|
||||
"search": "Search",
|
||||
"table": {
|
||||
"header": {
|
||||
"city": "",
|
||||
"country": "",
|
||||
"coordinates": "",
|
||||
"population": ""
|
||||
"city": "City",
|
||||
"country": "Country",
|
||||
"coordinates": "Coordinates",
|
||||
"population": "Population"
|
||||
},
|
||||
"action": {
|
||||
"select": ""
|
||||
"select": "Select {city}, {countryCode}"
|
||||
},
|
||||
"population": {
|
||||
"fallback": ""
|
||||
"fallback": "Unknown"
|
||||
}
|
||||
}
|
||||
},
|
||||
"integration": {
|
||||
"noData": "",
|
||||
"description": ""
|
||||
"noData": "No integration found",
|
||||
"description": "Click <here></here> to create a new integration"
|
||||
},
|
||||
"app": {
|
||||
"noData": "",
|
||||
"description": "",
|
||||
"quickCreate": ""
|
||||
"noData": "No app found",
|
||||
"description": "Click <here></here> to create a new app",
|
||||
"quickCreate": "Create app on the fly"
|
||||
},
|
||||
"error": {
|
||||
"noIntegration": "",
|
||||
"noData": ""
|
||||
"noIntegration": "No integration selected",
|
||||
"noData": "No integration data available"
|
||||
},
|
||||
"option": {}
|
||||
},
|
||||
"video": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"name": "Video Stream",
|
||||
"description": "Embed a video stream or video from a camera or a website",
|
||||
"option": {
|
||||
"feedUrl": {
|
||||
"label": ""
|
||||
"label": "Feed URL"
|
||||
},
|
||||
"hasAutoPlay": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Autoplay",
|
||||
"description": "Autoplay only works when muted because of browser restrictions"
|
||||
},
|
||||
"isMuted": {
|
||||
"label": ""
|
||||
"label": "Muted"
|
||||
},
|
||||
"hasControls": {
|
||||
"label": ""
|
||||
"label": "Show controls"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"noUrl": "",
|
||||
"forYoutubeUseIframe": ""
|
||||
"noUrl": "No Video URL provided",
|
||||
"forYoutubeUseIframe": "For YouTube videos use the iframe option"
|
||||
}
|
||||
},
|
||||
"mediaServer": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"name": "Current media server streams",
|
||||
"description": "Show the current streams on your media servers",
|
||||
"option": {
|
||||
"showOnlyPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Show only currently playing",
|
||||
"description": "Disabling this will not work for Plex"
|
||||
}
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "",
|
||||
"user": "",
|
||||
"name": "",
|
||||
"id": "",
|
||||
"currentlyPlaying": "Currently playing",
|
||||
"user": "User",
|
||||
"name": "Name",
|
||||
"id": "Id",
|
||||
"metadata": {
|
||||
"title": "",
|
||||
"title": "Stats for nerds",
|
||||
"video": {
|
||||
"title": "",
|
||||
"resolution": ""
|
||||
"title": "Video",
|
||||
"resolution": "Resolution"
|
||||
},
|
||||
"audio": {
|
||||
"title": "",
|
||||
@@ -4335,13 +4335,13 @@
|
||||
}
|
||||
},
|
||||
"newUser": {
|
||||
"label": ""
|
||||
"label": "Create a new user"
|
||||
},
|
||||
"newInvite": {
|
||||
"label": ""
|
||||
"label": "Create a new invite"
|
||||
},
|
||||
"newGroup": {
|
||||
"label": ""
|
||||
"label": "Create a new group"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4384,12 +4384,12 @@
|
||||
"description": ""
|
||||
},
|
||||
"torrent": {
|
||||
"name": "",
|
||||
"description": ""
|
||||
"name": "Torrents",
|
||||
"description": "Search for torrents on torrentdownloads.pro"
|
||||
},
|
||||
"youTube": {
|
||||
"name": "",
|
||||
"description": ""
|
||||
"name": "YouTube",
|
||||
"description": "Search for videos on YouTube"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4398,10 +4398,10 @@
|
||||
"help": {
|
||||
"group": {
|
||||
"mode": {
|
||||
"title": ""
|
||||
"title": "Modes"
|
||||
},
|
||||
"help": {
|
||||
"title": "",
|
||||
"title": "Help",
|
||||
"option": {
|
||||
"documentation": {
|
||||
"label": "Documentation"
|
||||
|
||||
@@ -1606,6 +1606,34 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"anchorNote": {
|
||||
"name": "Anchor Note",
|
||||
"description": "Displays a selected note from Anchor",
|
||||
"option": {
|
||||
"noteId": {
|
||||
"label": "Note"
|
||||
},
|
||||
"showTitle": {
|
||||
"label": "Show title"
|
||||
},
|
||||
"showUpdatedAt": {
|
||||
"label": "Show last updated"
|
||||
}
|
||||
},
|
||||
"empty": "Select a note to display",
|
||||
"emptyContent": "This note has no content",
|
||||
"notFound": "Note not found",
|
||||
"noNotes": "No notes found",
|
||||
"integrationRequired": "Select an Anchor integration first",
|
||||
"updatedAt": "Updated {date}",
|
||||
"untitled": "Untitled",
|
||||
"edit": "Edit",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"readOnlyViewer": "You have viewer access to this note. Editing is disabled.",
|
||||
"saveForbidden": "You do not have permission to edit this note.",
|
||||
"saveFailed": "Failed to save this note. Please try again."
|
||||
},
|
||||
"iframe": {
|
||||
"name": "iFrame",
|
||||
"description": "Embed any content from the internet. Some websites may restrict access.",
|
||||
@@ -2516,6 +2544,9 @@
|
||||
},
|
||||
"hideDescription": {
|
||||
"label": "Hide description"
|
||||
},
|
||||
"showPosterImage": {
|
||||
"label": "Show poster image"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2729,6 +2760,45 @@
|
||||
"noItems": "No notifications to display.",
|
||||
"option": {}
|
||||
},
|
||||
"speedtestTracker": {
|
||||
"name": "Speedtest Tracker",
|
||||
"description": "Displays speed test results from your Speedtest Tracker instance.",
|
||||
"option": {
|
||||
"showLatestResult": {
|
||||
"label": "Show latest result"
|
||||
},
|
||||
"showStats": {
|
||||
"label": "Show averages"
|
||||
},
|
||||
"showRecentResults": {
|
||||
"label": "Show recent results"
|
||||
},
|
||||
"showPingGraph": {
|
||||
"label": "Show ping graph"
|
||||
}
|
||||
},
|
||||
"latestResult": "Latest Result",
|
||||
"averages": "Averages",
|
||||
"tests": "{count} tests",
|
||||
"recentResults": "Recent Results",
|
||||
"recentResultsSpeed": "Speed",
|
||||
"recentResultsPing": "Ping",
|
||||
"download": "Download",
|
||||
"upload": "Upload",
|
||||
"ping": "Ping",
|
||||
"status": "Status",
|
||||
"avgDownload": "Avg Download",
|
||||
"avgUpload": "Avg Upload",
|
||||
"avgPing": "Avg Ping",
|
||||
"healthy": "Healthy",
|
||||
"unhealthy": "Unhealthy",
|
||||
"ok": "OK",
|
||||
"fail": "Fail",
|
||||
"noSectionsEnabled": "Enable at least one section in widget options.",
|
||||
"error": {
|
||||
"internalServerError": "Failed to fetch speedtest data"
|
||||
}
|
||||
},
|
||||
"systemResources": {
|
||||
"name": "System resources",
|
||||
"description": "CPU, Memory, Disk and other hardware usage of your system",
|
||||
|
||||
@@ -1526,13 +1526,13 @@
|
||||
}
|
||||
},
|
||||
"timetable": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"title": "",
|
||||
"noStation": "",
|
||||
"name": "Orario",
|
||||
"description": "Visualizza gli orari di partenza per una stazione",
|
||||
"title": "Partenze {station}",
|
||||
"noStation": "Nessuna stazione selezionata",
|
||||
"option": {
|
||||
"station": {
|
||||
"label": ""
|
||||
"label": "Stazione"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3525,7 +3525,7 @@
|
||||
"label": "Stato del server Minecraft"
|
||||
},
|
||||
"timetable": {
|
||||
"label": ""
|
||||
"label": "Orario"
|
||||
},
|
||||
"iconsUpdater": {
|
||||
"label": "Aggiornatore Icone"
|
||||
|
||||
@@ -7,15 +7,15 @@
|
||||
"description": "Para começar, por favor selecione como você deseja configurar sua instância Homarr.",
|
||||
"action": {
|
||||
"scratch": "Começar do zero",
|
||||
"importOldmarr": "Importe de uma instância Homarr anterior a 1.0"
|
||||
"importOldmarr": "Importe de uma instância do Homarr anterior a 1.0"
|
||||
}
|
||||
},
|
||||
"import": {
|
||||
"title": "Importar dados",
|
||||
"subtitle": "Você pode importar dados de uma instância existente do Homarr.",
|
||||
"dropzone": {
|
||||
"title": "Arraste o arquivo zip aqui ou clique para procurar",
|
||||
"description": "O arquivo zip enviado será processado e você poderá selecionar o que deseja importar"
|
||||
"title": "Arraste o arquivo ZIP aqui ou clique para procurar",
|
||||
"description": "O arquivo ZIP enviado será processado e você poderá selecionar o que deseja importar"
|
||||
},
|
||||
"fileInfo": {
|
||||
"action": {
|
||||
@@ -27,7 +27,7 @@
|
||||
"description": "Configure o comportamento da importação"
|
||||
},
|
||||
"boardSelection": {
|
||||
"title": "{count} painéis encontrados",
|
||||
"title": "{count} Painéis encontrados",
|
||||
"description": "Escolha todos os painéis com o tamanho que você deseja importar",
|
||||
"action": {
|
||||
"selectAll": "Selecionar todos",
|
||||
@@ -1526,13 +1526,13 @@
|
||||
}
|
||||
},
|
||||
"timetable": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"title": "",
|
||||
"noStation": "",
|
||||
"name": "Calendário",
|
||||
"description": "Mostra os horários de partida de uma estação",
|
||||
"title": "Partidas {station}",
|
||||
"noStation": "Nenhuma estação selecionada",
|
||||
"option": {
|
||||
"station": {
|
||||
"label": ""
|
||||
"label": "Estação"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3525,7 +3525,7 @@
|
||||
"label": "Status do servidor Minecraft"
|
||||
},
|
||||
"timetable": {
|
||||
"label": ""
|
||||
"label": "Horários"
|
||||
},
|
||||
"iconsUpdater": {
|
||||
"label": "Atualizador de Ícones"
|
||||
|
||||
@@ -33,10 +33,10 @@
|
||||
"@mantine/core": "^8.3.18",
|
||||
"@mantine/dates": "^8.3.18",
|
||||
"@mantine/hooks": "^8.3.18",
|
||||
"@tabler/icons-react": "^3.41.0",
|
||||
"@tabler/icons-react": "^3.41.1",
|
||||
"crypto-js": "^4.2.0",
|
||||
"mantine-react-table": "2.0.0-beta.9",
|
||||
"next": "16.2.1",
|
||||
"next": "16.2.3",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"svgson": "^5.3.1"
|
||||
|
||||
@@ -51,31 +51,32 @@
|
||||
"@mantine/charts": "^8.3.18",
|
||||
"@mantine/core": "^8.3.18",
|
||||
"@mantine/hooks": "^8.3.18",
|
||||
"@tabler/icons-react": "^3.41.0",
|
||||
"@tiptap/extension-color": "3.21.0",
|
||||
"@tiptap/extension-highlight": "3.21.0",
|
||||
"@tiptap/extension-image": "3.21.0",
|
||||
"@tiptap/extension-link": "^3.21.0",
|
||||
"@tiptap/extension-placeholder": "^3.21.0",
|
||||
"@tiptap/extension-table": "3.21.0",
|
||||
"@tiptap/extension-table-cell": "3.21.0",
|
||||
"@tiptap/extension-table-header": "3.21.0",
|
||||
"@tiptap/extension-table-row": "3.21.0",
|
||||
"@tiptap/extension-task-item": "3.21.0",
|
||||
"@tiptap/extension-task-list": "3.21.0",
|
||||
"@tiptap/extension-text-align": "3.21.0",
|
||||
"@tiptap/extension-text-style": "3.21.0",
|
||||
"@tiptap/extension-underline": "3.21.0",
|
||||
"@tiptap/react": "^3.21.0",
|
||||
"@tiptap/starter-kit": "^3.21.0",
|
||||
"@tabler/icons-react": "^3.41.1",
|
||||
"@tiptap/extension-color": "3.22.2",
|
||||
"@tiptap/extension-highlight": "3.22.2",
|
||||
"@tiptap/extension-image": "3.22.2",
|
||||
"@tiptap/extension-link": "^3.22.2",
|
||||
"@tiptap/extension-placeholder": "^3.22.2",
|
||||
"@tiptap/extension-table": "3.22.2",
|
||||
"@tiptap/extension-table-cell": "3.22.2",
|
||||
"@tiptap/extension-table-header": "3.22.2",
|
||||
"@tiptap/extension-table-row": "3.22.2",
|
||||
"@tiptap/extension-task-item": "3.22.2",
|
||||
"@tiptap/extension-task-list": "3.22.2",
|
||||
"@tiptap/extension-text-align": "3.22.2",
|
||||
"@tiptap/extension-text-style": "3.22.2",
|
||||
"@tiptap/extension-underline": "3.22.2",
|
||||
"@tiptap/react": "^3.22.2",
|
||||
"@tiptap/starter-kit": "^3.22.2",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.20",
|
||||
"mantine-form-zod-resolver": "^1.3.0",
|
||||
"mantine-react-table": "2.0.0-beta.9",
|
||||
"next": "16.2.1",
|
||||
"next": "16.2.3",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-quill-new": "^3.8.3",
|
||||
"recharts": "^2.15.4",
|
||||
"video.js": "^8.23.7",
|
||||
"zod": "^4.3.6"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { WidgetOptionType } from "../options";
|
||||
import { WidgetAnchorNoteInput } from "./widget-anchor-note-input";
|
||||
import { WidgetAppInput } from "./widget-app-input";
|
||||
import { WidgetDynamicSelectInput } from "./widget-dynamic-select-input";
|
||||
import { WidgetLocationInput } from "./widget-location-input";
|
||||
@@ -13,6 +14,7 @@ import { WidgetSwitchInput } from "./widget-switch-input";
|
||||
import { WidgetTextInput } from "./widget-text-input";
|
||||
|
||||
const mapping = {
|
||||
anchorNote: WidgetAnchorNoteInput,
|
||||
text: WidgetTextInput,
|
||||
location: WidgetLocationInput,
|
||||
multiSelect: WidgetMultiSelectInput,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Loader, Select } from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { CommonWidgetInputProps } from "./common";
|
||||
import { useWidgetInputTranslation } from "./common";
|
||||
import { useFormContext } from "./form";
|
||||
|
||||
export const WidgetAnchorNoteInput = ({ property, kind, options }: CommonWidgetInputProps<"anchorNote">) => {
|
||||
const t = useI18n();
|
||||
const tWidget = useWidgetInputTranslation(kind, property);
|
||||
const tAnchor = useScopedI18n("widget.anchorNote");
|
||||
const form = useFormContext();
|
||||
|
||||
const integrationId = form.values.integrationIds[0];
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch] = useDebouncedValue(search, 250);
|
||||
|
||||
const { data: notes, isPending } = clientApi.widget.anchorNotes.listNotes.useQuery(
|
||||
{
|
||||
integrationId: integrationId ?? "",
|
||||
search: debouncedSearch.trim() ? debouncedSearch : undefined,
|
||||
limit: 200,
|
||||
},
|
||||
{
|
||||
enabled: Boolean(integrationId),
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
retry: false,
|
||||
},
|
||||
);
|
||||
|
||||
const noteOptions = useMemo(
|
||||
() =>
|
||||
notes?.map((note) => ({
|
||||
value: note.id,
|
||||
label: note.title || tAnchor("untitled"),
|
||||
})) ?? [],
|
||||
[notes, tAnchor],
|
||||
);
|
||||
|
||||
return (
|
||||
<Select
|
||||
label={tWidget("label")}
|
||||
description={options.withDescription ? tWidget("description") : undefined}
|
||||
placeholder={integrationId ? t("common.select.placeholder") : tAnchor("integrationRequired")}
|
||||
nothingFoundMessage={integrationId ? tAnchor("noNotes") : tAnchor("integrationRequired")}
|
||||
searchable
|
||||
searchValue={search}
|
||||
onSearchChange={setSearch}
|
||||
data={noteOptions}
|
||||
leftSection={isPending && integrationId ? <Loader size={16} /> : undefined}
|
||||
disabled={!integrationId}
|
||||
{...form.getInputProps(`options.${property}`)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
.homarr-anchor-quill {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.homarr-anchor-quill .ql-toolbar.ql-snow,
|
||||
.homarr-anchor-quill .ql-container.ql-snow {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.homarr-anchor-quill .ql-toolbar.ql-snow {
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.homarr-anchor-quill .ql-container.ql-snow {
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.homarr-anchor-quill .ql-editor {
|
||||
min-height: 160px;
|
||||
flex: 1;
|
||||
padding: 0;
|
||||
color: var(--mantine-color-text);
|
||||
font-size: var(--mantine-font-size-sm, 0.95rem);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.homarr-anchor-quill .ql-editor.ql-blank::before {
|
||||
color: var(--mantine-color-dimmed);
|
||||
}
|
||||
|
||||
.homarr-anchor-quill .ql-editor ol,
|
||||
.homarr-anchor-quill .ql-editor ul {
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.homarr-anchor-quill .ql-editor li {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.homarr-anchor-quill .ql-editor li[data-list="unchecked"],
|
||||
.homarr-anchor-quill .ql-editor li[data-list="checked"] {
|
||||
padding-left: 28px !important;
|
||||
list-style: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.homarr-anchor-quill .ql-editor li[data-list="checked"] > .ql-ui,
|
||||
.homarr-anchor-quill .ql-editor li[data-list="unchecked"] > .ql-ui {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0.35em;
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
border-radius: 5px;
|
||||
box-sizing: border-box;
|
||||
transition: all 150ms ease;
|
||||
background: transparent;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.homarr-anchor-quill .ql-editor li[data-list="checked"] > .ql-ui::before,
|
||||
.homarr-anchor-quill .ql-editor li[data-list="unchecked"] > .ql-ui::before {
|
||||
content: "" !important;
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.homarr-anchor-quill .ql-editor li[data-list="unchecked"] > .ql-ui {
|
||||
border: 2px solid var(--mantine-color-dimmed);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.homarr-anchor-quill .ql-editor li[data-list="unchecked"]:hover > .ql-ui {
|
||||
border-color: var(--mantine-primary-color-filled);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.homarr-anchor-quill .ql-editor li[data-list="checked"] > .ql-ui {
|
||||
border: 2px solid var(--mantine-primary-color-filled);
|
||||
background: var(--mantine-primary-color-filled);
|
||||
}
|
||||
|
||||
.homarr-anchor-quill .ql-editor li[data-list="checked"] > .ql-ui::after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 45%;
|
||||
width: 5px;
|
||||
height: 9px;
|
||||
border: solid var(--mantine-color-white, #fff);
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: translate(-50%, -50%) rotate(45deg);
|
||||
}
|
||||
|
||||
.homarr-anchor-quill .ql-editor li[data-list="checked"] {
|
||||
color: var(--mantine-color-dimmed);
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: var(--mantine-color-dimmed);
|
||||
}
|
||||
|
||||
.homarr-anchor-quill--readonly .ql-toolbar.ql-snow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.homarr-anchor-quill--readonly .ql-container.ql-snow {
|
||||
border-radius: 0;
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
"use client";
|
||||
|
||||
import { startTransition, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { ComponentProps } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Button, Center, Group, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { Quill } from "react-quill-new";
|
||||
import type { DeltaStatic } from "react-quill-new";
|
||||
import type ReactQuillComponent from "react-quill-new";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useTimeAgo } from "@homarr/common";
|
||||
import type { AnchorNotePermission } from "@homarr/integrations";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
|
||||
import "react-quill-new/dist/quill.snow.css";
|
||||
import "./anchor-note.css";
|
||||
|
||||
type ReactQuillProps = ComponentProps<typeof ReactQuillComponent>;
|
||||
type ReactQuillOnChange = NonNullable<ReactQuillProps["onChange"]>;
|
||||
|
||||
const ReactQuill = dynamic(() => import("react-quill-new"), { ssr: false });
|
||||
|
||||
const quillDeltaOperationSchema = z.object({
|
||||
insert: z.unknown().optional(),
|
||||
attributes: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
const quillDeltaSchema = z.object({
|
||||
ops: z.array(quillDeltaOperationSchema),
|
||||
});
|
||||
|
||||
type QuillDelta = z.infer<typeof quillDeltaSchema>;
|
||||
|
||||
const DeltaConstructor = Quill.import("delta") as new (
|
||||
ops?: QuillDelta["ops"] | { ops: QuillDelta["ops"] },
|
||||
) => DeltaStatic;
|
||||
|
||||
const quillModules = {
|
||||
toolbar: [
|
||||
["bold", "italic", "underline", "strike"],
|
||||
[{ header: [1, 2, 3, 4, false] }],
|
||||
[{ align: [] }],
|
||||
[{ list: "ordered" }, { list: "bullet" }, { list: "check" }],
|
||||
[{ indent: "-1" }, { indent: "+1" }],
|
||||
["blockquote", "code-block"],
|
||||
["link"],
|
||||
["clean"],
|
||||
],
|
||||
history: {
|
||||
delay: 1000,
|
||||
maxStack: 200,
|
||||
userOnly: true,
|
||||
},
|
||||
};
|
||||
|
||||
const readOnlyModules = {
|
||||
toolbar: false,
|
||||
};
|
||||
|
||||
const quillFormats = [
|
||||
"bold",
|
||||
"italic",
|
||||
"underline",
|
||||
"strike",
|
||||
"header",
|
||||
"align",
|
||||
"list",
|
||||
"indent",
|
||||
"blockquote",
|
||||
"code-block",
|
||||
"link",
|
||||
];
|
||||
|
||||
const emptyDelta = (): DeltaStatic => new DeltaConstructor([{ insert: "\n" }]);
|
||||
|
||||
const toPlainTextDelta = (value: string): DeltaStatic => {
|
||||
const normalized = value.endsWith("\n") ? value : `${value}\n`;
|
||||
return new DeltaConstructor([{ insert: normalized }]);
|
||||
};
|
||||
|
||||
const parseStoredContent = (content?: string | null): DeltaStatic => {
|
||||
if (!content) return emptyDelta();
|
||||
|
||||
try {
|
||||
const parsedDelta = quillDeltaSchema.safeParse(JSON.parse(content));
|
||||
if (parsedDelta.success) {
|
||||
return new DeltaConstructor(parsedDelta.data.ops);
|
||||
}
|
||||
} catch {
|
||||
return toPlainTextDelta(content);
|
||||
}
|
||||
|
||||
return toPlainTextDelta(content);
|
||||
};
|
||||
|
||||
const stringifyDelta = (delta: unknown): string => {
|
||||
const parsedDelta = quillDeltaSchema.safeParse(delta);
|
||||
if (parsedDelta.success) {
|
||||
return JSON.stringify(parsedDelta.data);
|
||||
}
|
||||
return JSON.stringify(emptyDelta());
|
||||
};
|
||||
|
||||
const canEditPermission = (permission: AnchorNotePermission) => {
|
||||
return permission === "owner" || permission === "editor";
|
||||
};
|
||||
|
||||
const isForbiddenError = (error: unknown): boolean => {
|
||||
if (typeof error !== "object" || error === null) return false;
|
||||
|
||||
const data = (error as { data?: unknown }).data;
|
||||
if (typeof data !== "object" || data === null) return false;
|
||||
|
||||
return (data as { code?: unknown }).code === "FORBIDDEN";
|
||||
};
|
||||
|
||||
export default function AnchorNoteWidget({ options, integrationIds }: WidgetComponentProps<"anchorNote">) {
|
||||
const t = useScopedI18n("widget.anchorNote");
|
||||
const noteId = options.noteId.trim();
|
||||
if (!noteId) {
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Text c="dimmed">{t("empty")}</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 integrationId = integrationIds[0]!;
|
||||
|
||||
return <AnchorNoteWidgetContent options={options} integrationId={integrationId} noteId={noteId} />;
|
||||
}
|
||||
|
||||
interface AnchorNoteWidgetContentProps {
|
||||
options: WidgetComponentProps<"anchorNote">["options"];
|
||||
integrationId: string;
|
||||
noteId: string;
|
||||
}
|
||||
|
||||
const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteWidgetContentProps) => {
|
||||
const t = useScopedI18n("widget.anchorNote");
|
||||
const [note, { refetch }] = clientApi.widget.anchorNotes.getNote.useSuspenseQuery(
|
||||
{
|
||||
integrationId,
|
||||
noteId,
|
||||
},
|
||||
{
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
retry: false,
|
||||
},
|
||||
);
|
||||
const { mutateAsync: updateNoteAsync, isPending: isUpdating } = clientApi.widget.anchorNotes.updateNote.useMutation();
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [draftTitle, setDraftTitle] = useState(note.title);
|
||||
const [draftContent, setDraftContent] = useState(note.content ?? "");
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing) return;
|
||||
|
||||
startTransition(() => {
|
||||
setDraftTitle(note.title);
|
||||
setDraftContent(note.content ?? "");
|
||||
});
|
||||
}, [isEditing, note.content, note.title]);
|
||||
|
||||
const canEdit = canEditPermission(note.permission);
|
||||
const isViewer = note.permission === "viewer";
|
||||
const updatedAt = useMemo(() => new Date(note.updatedAt), [note.updatedAt]);
|
||||
const updatedAtRelative = useTimeAgo(updatedAt, 30000);
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
const normalizedTitle = draftTitle.trim() || note.title;
|
||||
return normalizedTitle !== note.title || draftContent !== (note.content ?? "");
|
||||
}, [draftContent, draftTitle, note.content, note.title]);
|
||||
|
||||
const editorValue = useMemo(() => parseStoredContent(draftContent), [draftContent]);
|
||||
const readOnlyValue = useMemo(() => parseStoredContent(note.content), [note.content]);
|
||||
const handleEditorChange = useCallback<ReactQuillOnChange>(
|
||||
(_html, _delta, source, editor) => {
|
||||
if (!isEditing || source !== "user") return;
|
||||
const next = stringifyDelta(editor.getContents());
|
||||
setDraftContent(next);
|
||||
},
|
||||
[isEditing],
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
if (!canEdit) return;
|
||||
startTransition(() => {
|
||||
setDraftTitle(note.title);
|
||||
setDraftContent(note.content ?? "");
|
||||
setSaveError(null);
|
||||
setIsEditing(true);
|
||||
});
|
||||
}, [canEdit, note.content, note.title]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
startTransition(() => {
|
||||
setDraftTitle(note.title);
|
||||
setDraftContent(note.content ?? "");
|
||||
setSaveError(null);
|
||||
setIsEditing(false);
|
||||
});
|
||||
}, [note.content, note.title]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!canEdit) return;
|
||||
|
||||
const normalizedTitle = draftTitle.trim() || note.title || t("untitled");
|
||||
|
||||
if (!hasChanges) {
|
||||
startTransition(() => {
|
||||
setSaveError(null);
|
||||
setIsEditing(false);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await updateNoteAsync(
|
||||
{
|
||||
integrationId,
|
||||
noteId,
|
||||
title: normalizedTitle,
|
||||
content: draftContent,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
void (async () => {
|
||||
await refetch();
|
||||
startTransition(() => {
|
||||
setSaveError(null);
|
||||
setIsEditing(false);
|
||||
});
|
||||
})();
|
||||
},
|
||||
onError(error) {
|
||||
if (isForbiddenError(error)) {
|
||||
void (async () => {
|
||||
await refetch();
|
||||
startTransition(() => {
|
||||
setSaveError(t("saveForbidden"));
|
||||
setIsEditing(false);
|
||||
});
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
setSaveError(t("saveFailed"));
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}, [canEdit, draftContent, draftTitle, hasChanges, integrationId, note.title, noteId, refetch, t, updateNoteAsync]);
|
||||
|
||||
return (
|
||||
<Stack h="100%" gap="xs" p="sm">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<Stack gap={2} style={{ flex: 1 }}>
|
||||
{isEditing ? (
|
||||
<TextInput value={draftTitle} onChange={(event) => setDraftTitle(event.currentTarget.value)} size="sm" />
|
||||
) : (
|
||||
options.showTitle && <Text fw={600}>{note.title || t("untitled")}</Text>
|
||||
)}
|
||||
{!isEditing && options.showUpdatedAt && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("updatedAt", { date: updatedAtRelative })}
|
||||
</Text>
|
||||
)}
|
||||
{!isEditing && isViewer && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("readOnlyViewer")}
|
||||
</Text>
|
||||
)}
|
||||
{saveError && (
|
||||
<Text size="xs" c="red">
|
||||
{saveError}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<Group gap="xs">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button size="xs" onClick={handleSave} loading={isUpdating} disabled={!hasChanges || !canEdit}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
<Button size="xs" variant="subtle" onClick={handleCancel} disabled={isUpdating}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button size="xs" variant="light" onClick={handleEdit} disabled={!canEdit || isUpdating}>
|
||||
{t("edit")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
<div className={`homarr-anchor-quill${isEditing ? "" : " homarr-anchor-quill--readonly"}`} style={{ flex: 1 }}>
|
||||
<ReactQuill
|
||||
theme="snow"
|
||||
readOnly={!isEditing}
|
||||
value={isEditing ? editorValue : readOnlyValue}
|
||||
onChange={handleEditorChange}
|
||||
modules={isEditing ? quillModules : readOnlyModules}
|
||||
formats={quillFormats}
|
||||
placeholder={t("emptyContent")}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { IconNotes } from "@tabler/icons-react";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("anchorNote", {
|
||||
icon: IconNotes,
|
||||
supportedIntegrations: ["anchor"],
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
noteId: factory.anchorNote(),
|
||||
showTitle: factory.switch({
|
||||
defaultValue: true,
|
||||
}),
|
||||
showUpdatedAt: factory.switch({
|
||||
defaultValue: true,
|
||||
}),
|
||||
}));
|
||||
},
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
@@ -7,6 +7,7 @@ import { objectEntries } from "@homarr/common";
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
import type { SettingsContextProps } from "@homarr/settings/creator";
|
||||
|
||||
import * as anchorNote from "./anchor-note";
|
||||
import * as app from "./app";
|
||||
import * as bookmarks from "./bookmarks";
|
||||
import * as calendar from "./calendar";
|
||||
@@ -39,6 +40,7 @@ import * as releases from "./releases";
|
||||
import * as rssFeed from "./rssFeed";
|
||||
import * as smartHomeEntityState from "./smart-home/entity-state";
|
||||
import * as smartHomeExecuteAutomation from "./smart-home/execute-automation";
|
||||
import * as speedtestTracker from "./speedtest-tracker";
|
||||
import * as stockPrice from "./stocks";
|
||||
import * as systemDisks from "./system-disks";
|
||||
import * as systemResources from "./system-resources";
|
||||
@@ -54,6 +56,7 @@ export const widgetImports = {
|
||||
clock,
|
||||
weather,
|
||||
app,
|
||||
anchorNote,
|
||||
notebook,
|
||||
iframe,
|
||||
video,
|
||||
@@ -87,6 +90,7 @@ export const widgetImports = {
|
||||
"immich-serverStats": immichServerStats,
|
||||
"immich-albumCarousel": immichAlbumCarousel,
|
||||
tracearr,
|
||||
speedtestTracker,
|
||||
} satisfies WidgetImportRecord;
|
||||
|
||||
export type WidgetImports = typeof widgetImports;
|
||||
|
||||
@@ -19,6 +19,8 @@ interface TextInput extends CommonInput<string> {
|
||||
validate?: z.ZodType<string>;
|
||||
}
|
||||
|
||||
type AnchorNoteInput = CommonInput<string>;
|
||||
|
||||
interface MultiSelectInput<TOptions extends SelectOption[]> extends CommonInput<
|
||||
inferSelectOptionValue<TOptions[number]>[]
|
||||
> {
|
||||
@@ -86,6 +88,11 @@ const optionsFactory = {
|
||||
withDescription: input?.withDescription ?? false,
|
||||
validate: input?.validate,
|
||||
}),
|
||||
anchorNote: (input?: AnchorNoteInput) => ({
|
||||
type: "anchorNote" as const,
|
||||
defaultValue: input?.defaultValue ?? "",
|
||||
withDescription: input?.withDescription ?? false,
|
||||
}),
|
||||
multiSelect: <const TOptions extends SelectOption[]>(input: MultiSelectInput<TOptions>) => ({
|
||||
type: "multiSelect" as const,
|
||||
defaultValue: input.defaultValue ?? [],
|
||||
|
||||
@@ -4,29 +4,53 @@ import { Card, Flex, Group, Image, ScrollArea, Stack, Text } from "@mantine/core
|
||||
import { IconClock } from "@tabler/icons-react";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import type { RouterInputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import classes from "./component.module.scss";
|
||||
|
||||
export default function RssFeed({ options }: WidgetComponentProps<"rssFeed">) {
|
||||
const [feedEntries] = clientApi.widget.rssFeed.getFeeds.useSuspenseQuery(
|
||||
{
|
||||
urls: options.feedUrls,
|
||||
maximumAmountPosts: typeof options.maximumAmountPosts === "number" ? options.maximumAmountPosts : 100,
|
||||
const useLiveFeedEntries = (input: RouterInputs["widget"]["rssFeed"]["getFeeds"]) => {
|
||||
const [feedEntries] = clientApi.widget.rssFeed.getFeeds.useSuspenseQuery(input, {
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
retry: false,
|
||||
});
|
||||
const utils = clientApi.useUtils();
|
||||
|
||||
clientApi.widget.rssFeed.subscribeFeeds.useSubscription(input, {
|
||||
onData(updatedData) {
|
||||
utils.widget.rssFeed.getFeeds.setData(input, (oldData) => {
|
||||
return oldData
|
||||
?.filter((entry) => entry.feedUrl !== updatedData.url)
|
||||
.concat(updatedData.entries)
|
||||
.sort((entryA, entryB) => {
|
||||
return entryA.published && entryB.published
|
||||
? new Date(entryB.published).getTime() - new Date(entryA.published).getTime()
|
||||
: 0;
|
||||
})
|
||||
.slice(0, input.maximumAmountPosts);
|
||||
});
|
||||
},
|
||||
{
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
retry: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
return feedEntries;
|
||||
};
|
||||
|
||||
export default function RssFeed({ options, width }: WidgetComponentProps<"rssFeed">) {
|
||||
const feedEntries = useLiveFeedEntries({
|
||||
urls: options.feedUrls,
|
||||
maximumAmountPosts: typeof options.maximumAmountPosts === "number" ? options.maximumAmountPosts : 100,
|
||||
});
|
||||
|
||||
const board = useRequiredBoard();
|
||||
|
||||
const languageDir = options.enableRtl ? "RTL" : "LTR";
|
||||
|
||||
const isNarrow = width < 128 * 3;
|
||||
|
||||
return (
|
||||
<ScrollArea className="scroll-area-w100" w="100%" p="sm">
|
||||
<Stack w={"100%"} gap="sm">
|
||||
@@ -41,27 +65,33 @@ export default function RssFeed({ options }: WidgetComponentProps<"rssFeed">) {
|
||||
w="100%"
|
||||
p="sm"
|
||||
>
|
||||
{feedEntry.enclosure && (
|
||||
{feedEntry.enclosure !== undefined && (
|
||||
<Image className={classes.backgroundImage} src={feedEntry.enclosure} alt="backdrop" />
|
||||
)}
|
||||
|
||||
<Flex gap="sm" direction="column" w="100%">
|
||||
<Text dir={languageDir} fz="sm" lh="sm" lineClamp={2}>
|
||||
{feedEntry.title}
|
||||
</Text>
|
||||
{!options.hideDescription && feedEntry.description && (
|
||||
<Text
|
||||
className={feedEntry.description}
|
||||
dir={languageDir}
|
||||
c="dimmed"
|
||||
size="sm"
|
||||
lineClamp={options.textLinesClamp}
|
||||
dangerouslySetInnerHTML={{ __html: feedEntry.description }}
|
||||
/>
|
||||
<Group wrap="nowrap">
|
||||
{feedEntry.enclosure !== undefined && options.showPosterImage && !isNarrow && (
|
||||
<Image src={feedEntry.enclosure} alt={feedEntry.title} w={140} h={140} radius="sm" />
|
||||
)}
|
||||
|
||||
{feedEntry.published && <InfoDisplay date={dayjs(feedEntry.published).fromNow()} />}
|
||||
</Flex>
|
||||
<Flex gap="sm" direction="column" w="100%">
|
||||
<Text dir={languageDir} fz="sm" lh="sm" lineClamp={2}>
|
||||
{feedEntry.title}
|
||||
</Text>
|
||||
{!options.hideDescription && feedEntry.description && (
|
||||
<Text
|
||||
className={feedEntry.description}
|
||||
dir={languageDir}
|
||||
c="dimmed"
|
||||
size="sm"
|
||||
lineClamp={options.textLinesClamp}
|
||||
dangerouslySetInnerHTML={{ __html: feedEntry.description }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{feedEntry.published && <InfoDisplay date={dayjs(feedEntry.published).fromNow()} />}
|
||||
</Flex>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
@@ -32,6 +32,9 @@ export const { definition, componentLoader } = createWidgetDefinition("rssFeed",
|
||||
hideDescription: factory.switch({
|
||||
defaultValue: false,
|
||||
}),
|
||||
showPosterImage: factory.switch({
|
||||
defaultValue: false,
|
||||
}),
|
||||
}));
|
||||
},
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { SimpleGrid, Stack } from "@mantine/core";
|
||||
import { IconArrowDown, IconArrowUp, IconWaveSine } from "@tabler/icons-react";
|
||||
|
||||
import type { SpeedtestTrackerStats } from "@homarr/integrations/types";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { formatStatsSpeed } from "./helpers";
|
||||
import { SectionLabel } from "./section-label";
|
||||
import { SpeedStatCard } from "./speed-stat-card";
|
||||
|
||||
export function AveragesSection({ stats }: { stats: SpeedtestTrackerStats }) {
|
||||
const t = useScopedI18n("widget.speedtestTracker");
|
||||
|
||||
return (
|
||||
<Stack gap={6} h="100%">
|
||||
<SectionLabel>
|
||||
{t("averages")} · {t("tests", { count: stats.total_results.toString() })}
|
||||
</SectionLabel>
|
||||
<SimpleGrid cols={3} spacing="xs" style={{ flex: 1, gridTemplateRows: "1fr" }}>
|
||||
<SpeedStatCard
|
||||
icon={IconArrowDown}
|
||||
color="blue"
|
||||
value={formatStatsSpeed(stats.download)}
|
||||
label={t("avgDownload")}
|
||||
compact
|
||||
/>
|
||||
<SpeedStatCard
|
||||
icon={IconArrowUp}
|
||||
color="teal"
|
||||
value={formatStatsSpeed(stats.upload)}
|
||||
label={t("avgUpload")}
|
||||
compact
|
||||
/>
|
||||
<SpeedStatCard
|
||||
icon={IconWaveSine}
|
||||
color="orange"
|
||||
value={`${stats.ping.avg.toFixed(1)} ms`}
|
||||
label={t("avgPing")}
|
||||
compact
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Stack, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { SpeedtestTrackerDashboardData } from "@homarr/integrations/types";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { AveragesSection } from "./averages";
|
||||
import { mergeStats } from "./helpers";
|
||||
import { LatestResultSection } from "./latest-result";
|
||||
import { RecentResultsSection } from "./recent-results";
|
||||
|
||||
export default function SpeedtestTrackerWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
isEditMode,
|
||||
}: WidgetComponentProps<"speedtestTracker">) {
|
||||
const t = useScopedI18n("widget.speedtestTracker");
|
||||
const [dashboardData] = clientApi.widget.speedtestTracker.getDashboard.useSuspenseQuery({ integrationIds });
|
||||
|
||||
const utils = clientApi.useUtils();
|
||||
clientApi.widget.speedtestTracker.subscribeToDashboard.useSubscription(
|
||||
{ integrationIds },
|
||||
{
|
||||
enabled: !isEditMode,
|
||||
onData(newData) {
|
||||
utils.widget.speedtestTracker.getDashboard.setData({ integrationIds }, (prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
return prevData.map((instance) =>
|
||||
instance.integrationId === newData.integrationId
|
||||
? { ...instance, dashboard: newData.dashboard, updatedAt: newData.timestamp }
|
||||
: instance,
|
||||
);
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const combined = useMemo(
|
||||
() =>
|
||||
dashboardData.reduce<SpeedtestTrackerDashboardData>(
|
||||
(acc, item) => ({
|
||||
latestResult: item.dashboard.latestResult ?? acc.latestResult,
|
||||
stats: mergeStats(acc.stats, item.dashboard.stats),
|
||||
recentResults: [...acc.recentResults, ...item.dashboard.recentResults],
|
||||
}),
|
||||
{ latestResult: null, stats: null, recentResults: [] },
|
||||
),
|
||||
[dashboardData],
|
||||
);
|
||||
|
||||
const twelveHoursAgo = Date.now() - 12 * 60 * 60 * 1000;
|
||||
const recentFiltered = combined.recentResults.filter((result) => result.created_at.getTime() > twelveHoursAgo);
|
||||
|
||||
const hasStatSection =
|
||||
(options.showLatestResult && combined.latestResult !== null) || (options.showStats && combined.stats !== null);
|
||||
const hasChart = options.showRecentResults && recentFiltered.length > 0;
|
||||
const noSectionsEnabled = !options.showLatestResult && !options.showStats && !options.showRecentResults;
|
||||
|
||||
return (
|
||||
<Stack h="100%" gap="sm" p="xs" style={{ overflow: "hidden" }}>
|
||||
{hasStatSection && (
|
||||
<Stack gap="sm" style={{ flex: 1, minHeight: 0 }}>
|
||||
{options.showLatestResult && combined.latestResult && (
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<LatestResultSection result={combined.latestResult} />
|
||||
</div>
|
||||
)}
|
||||
{options.showStats && combined.stats && (
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<AveragesSection stats={combined.stats} />
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
{hasChart && (
|
||||
<div style={{ flex: 2, minHeight: 0 }}>
|
||||
<RecentResultsSection results={recentFiltered} showPingGraph={options.showPingGraph} />
|
||||
</div>
|
||||
)}
|
||||
{noSectionsEnabled && (
|
||||
<Text c="dimmed" ta="center" size="sm">
|
||||
{t("noSectionsEnabled")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { formatNumber } from "@homarr/common";
|
||||
import type {
|
||||
SpeedtestTrackerDashboardData,
|
||||
SpeedtestTrackerResult,
|
||||
SpeedtestTrackerStats,
|
||||
} from "@homarr/integrations/types";
|
||||
|
||||
export const mergeStats = (
|
||||
statsA: SpeedtestTrackerDashboardData["stats"],
|
||||
statsB: SpeedtestTrackerDashboardData["stats"],
|
||||
): SpeedtestTrackerDashboardData["stats"] => {
|
||||
if (!statsB) return statsA;
|
||||
if (!statsA) return statsB;
|
||||
return {
|
||||
ping: {
|
||||
avg: (statsA.ping.avg + statsB.ping.avg) / 2,
|
||||
min: Math.min(statsA.ping.min, statsB.ping.min),
|
||||
max: Math.max(statsA.ping.max, statsB.ping.max),
|
||||
},
|
||||
download: {
|
||||
avg: (statsA.download.avg + statsB.download.avg) / 2,
|
||||
avg_bits:
|
||||
statsA.download.avg_bits !== undefined && statsB.download.avg_bits !== undefined
|
||||
? (statsA.download.avg_bits + statsB.download.avg_bits) / 2
|
||||
: (statsA.download.avg_bits ?? statsB.download.avg_bits),
|
||||
min: Math.min(statsA.download.min, statsB.download.min),
|
||||
max: Math.max(statsA.download.max, statsB.download.max),
|
||||
},
|
||||
upload: {
|
||||
avg: (statsA.upload.avg + statsB.upload.avg) / 2,
|
||||
avg_bits:
|
||||
statsA.upload.avg_bits !== undefined && statsB.upload.avg_bits !== undefined
|
||||
? (statsA.upload.avg_bits + statsB.upload.avg_bits) / 2
|
||||
: (statsA.upload.avg_bits ?? statsB.upload.avg_bits),
|
||||
min: Math.min(statsA.upload.min, statsB.upload.min),
|
||||
max: Math.max(statsA.upload.max, statsB.upload.max),
|
||||
},
|
||||
total_results: statsA.total_results + statsB.total_results,
|
||||
};
|
||||
};
|
||||
|
||||
export const formatBitsPerSec = (bps: number): string => `${formatNumber(bps, 2)}bps`;
|
||||
|
||||
export const formatResultSpeed = (result: SpeedtestTrackerResult, dir: "download" | "upload"): string => {
|
||||
const bits = dir === "download" ? result.download_bits : result.upload_bits;
|
||||
if (bits != null) return formatBitsPerSec(bits);
|
||||
return "—";
|
||||
};
|
||||
|
||||
export const formatStatsSpeed = (band: SpeedtestTrackerStats["download"]): string => {
|
||||
if (band.avg_bits != null) return formatBitsPerSec(band.avg_bits);
|
||||
return formatBitsPerSec(band.avg * 8);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { IconServerOff, IconSpeedboat } from "@tabler/icons-react";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("speedtestTracker", {
|
||||
icon: IconSpeedboat,
|
||||
createOptions() {
|
||||
return optionsBuilder.from(
|
||||
(factory) => ({
|
||||
showLatestResult: factory.switch({
|
||||
defaultValue: true,
|
||||
}),
|
||||
showStats: factory.switch({
|
||||
defaultValue: true,
|
||||
}),
|
||||
showRecentResults: factory.switch({
|
||||
defaultValue: true,
|
||||
}),
|
||||
showPingGraph: factory.switch({
|
||||
defaultValue: true,
|
||||
}),
|
||||
}),
|
||||
{
|
||||
showPingGraph: {
|
||||
shouldHide: (options) => !options.showRecentResults,
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
supportedIntegrations: ["speedtestTracker"],
|
||||
errors: {
|
||||
INTERNAL_SERVER_ERROR: {
|
||||
icon: IconServerOff,
|
||||
message: (t) => t("widget.speedtestTracker.error.internalServerError"),
|
||||
},
|
||||
},
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { Group, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { IconArrowDown, IconArrowUp, IconCircleCheck, IconCircleX, IconWaveSine } from "@tabler/icons-react";
|
||||
|
||||
import type { SpeedtestTrackerResult } from "@homarr/integrations/types";
|
||||
import { useCurrentLocale, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { formatResultSpeed } from "./helpers";
|
||||
import { SectionLabel } from "./section-label";
|
||||
import { SpeedStatCard } from "./speed-stat-card";
|
||||
|
||||
export function LatestResultSection({ result }: { result: SpeedtestTrackerResult }) {
|
||||
const t = useScopedI18n("widget.speedtestTracker");
|
||||
const locale = useCurrentLocale();
|
||||
const timestamp = Intl.DateTimeFormat(locale, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(result.created_at);
|
||||
const cols = result.healthy !== null ? 4 : 3;
|
||||
|
||||
return (
|
||||
<Stack gap={6} h="100%">
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<SectionLabel>{t("latestResult")}</SectionLabel>
|
||||
<Text size="xs" c="dimmed">
|
||||
{timestamp}
|
||||
</Text>
|
||||
</Group>
|
||||
<SimpleGrid cols={cols} spacing="xs" style={{ flex: 1, gridTemplateRows: "1fr" }}>
|
||||
<SpeedStatCard
|
||||
icon={IconArrowDown}
|
||||
color="blue"
|
||||
value={formatResultSpeed(result, "download")}
|
||||
label={t("download")}
|
||||
/>
|
||||
<SpeedStatCard
|
||||
icon={IconArrowUp}
|
||||
color="teal"
|
||||
value={formatResultSpeed(result, "upload")}
|
||||
label={t("upload")}
|
||||
/>
|
||||
<SpeedStatCard
|
||||
icon={IconWaveSine}
|
||||
color="orange"
|
||||
value={result.ping !== null ? `${result.ping.toFixed(1)} ms` : "—"}
|
||||
label={t("ping")}
|
||||
/>
|
||||
{result.healthy !== null && (
|
||||
<SpeedStatCard
|
||||
icon={result.healthy ? IconCircleCheck : IconCircleX}
|
||||
color={result.healthy ? "green" : "red"}
|
||||
value={result.healthy ? t("healthy") : t("unhealthy")}
|
||||
label={t("status")}
|
||||
/>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { AreaChart, ChartTooltip } from "@mantine/charts";
|
||||
import { Stack, useMantineTheme } from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
import { ReferenceLine, XAxis } from "recharts";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import type { SpeedtestTrackerResult } from "@homarr/integrations/types";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { SectionLabel } from "./section-label";
|
||||
|
||||
interface XAxisTicks {
|
||||
midnightTs: number | null;
|
||||
xTicks: number[];
|
||||
topDateTicks: number[];
|
||||
}
|
||||
|
||||
function buildXAxisTicks(data: { ts: number }[]): XAxisTicks {
|
||||
if (data.length === 0) return { midnightTs: null, xTicks: [], topDateTicks: [] };
|
||||
|
||||
const first = data[0]?.ts ?? 0;
|
||||
const last = data.at(-1)?.ts ?? 0;
|
||||
|
||||
const twoHourMs = 2 * 60 * 60 * 1000;
|
||||
const startTick = Math.ceil(first / twoHourMs) * twoHourMs;
|
||||
const tickSet = new Set<number>();
|
||||
for (let t = startTick; t <= last; t += twoHourMs) tickSet.add(t);
|
||||
|
||||
const midnightDate = new Date(first);
|
||||
midnightDate.setHours(24, 0, 0, 0);
|
||||
const mTs = midnightDate.getTime();
|
||||
const foundMidnight = mTs > first && mTs < last ? mTs : null;
|
||||
if (foundMidnight) tickSet.add(foundMidnight);
|
||||
|
||||
const topTicks: number[] = [first];
|
||||
if (foundMidnight) topTicks.push(foundMidnight);
|
||||
|
||||
return {
|
||||
midnightTs: foundMidnight,
|
||||
xTicks: Array.from(tickSet).sort((tsA, tsB) => tsA - tsB),
|
||||
topDateTicks: topTicks,
|
||||
};
|
||||
}
|
||||
|
||||
function makeXTickRenderer(midnightTs: number | null) {
|
||||
return ({ x, y, payload }: { x: number; y: number; payload: { value: number } }) => {
|
||||
const date = new Date(payload.value);
|
||||
const timeStr = date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
const isMidnight = midnightTs !== null && payload.value === midnightTs;
|
||||
const dateStr = isMidnight ? date.toLocaleDateString([], { month: "short", day: "numeric" }) : null;
|
||||
|
||||
return (
|
||||
<g transform={`translate(${x},${y})`}>
|
||||
<text x={0} y={12} textAnchor="middle" fontSize={10} fill="var(--mantine-color-dimmed)">
|
||||
{timeStr}
|
||||
</text>
|
||||
{dateStr && (
|
||||
<text x={0} y={23} textAnchor="middle" fontSize={9} fill="var(--mantine-color-dimmed)">
|
||||
{dateStr}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function renderTopDateTick(props: { x: number; y: number; payload: { value: number }; index: number }) {
|
||||
const { x, y, payload, index } = props;
|
||||
const date = new Date(payload.value);
|
||||
const dateStr = date.toLocaleDateString([], { weekday: "short", month: "short", day: "numeric" });
|
||||
const anchor = index === 0 ? "start" : "middle";
|
||||
return (
|
||||
<g transform={`translate(${x},${y})`}>
|
||||
<text x={0} y={-4} textAnchor={anchor} fontSize={10} fill="var(--mantine-color-dimmed)">
|
||||
{dateStr}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function buildYAxisConfig(maxVal: number, stepOptions: { threshold: number; step: number }[]) {
|
||||
const step = (stepOptions.find((opt) => maxVal <= opt.threshold) ?? stepOptions.at(-1))?.step ?? 1;
|
||||
const roundedMax = Math.ceil(maxVal / step) * step || step;
|
||||
const ticks: number[] = [];
|
||||
for (let val = 0; val <= roundedMax; val += step) ticks.push(val);
|
||||
return { ticks, domain: [0, roundedMax] as [number, number] };
|
||||
}
|
||||
|
||||
function formatTooltipDate(label: number | string | undefined): string {
|
||||
const timestamp = typeof label === "number" ? label : Number(label);
|
||||
if (Number.isNaN(timestamp)) return "";
|
||||
return new Date(timestamp).toLocaleString([], {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export function RecentResultsSection({
|
||||
results,
|
||||
showPingGraph,
|
||||
}: {
|
||||
results: SpeedtestTrackerResult[];
|
||||
showPingGraph: boolean;
|
||||
}) {
|
||||
const t = useScopedI18n("widget.speedtestTracker");
|
||||
|
||||
return (
|
||||
<Stack gap={4} h="100%" style={{ minHeight: 0 }}>
|
||||
<SectionLabel>{t("recentResults")}</SectionLabel>
|
||||
<div style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<SpeedSubChart results={results} showLabel={showPingGraph} />
|
||||
{showPingGraph && <PingSubChart results={results} />}
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function SpeedSubChart({ results, showLabel }: { results: SpeedtestTrackerResult[]; showLabel: boolean }) {
|
||||
const t = useScopedI18n("widget.speedtestTracker");
|
||||
const { ref, height } = useElementSize<HTMLDivElement>();
|
||||
|
||||
return (
|
||||
<div style={{ flex: 2, minHeight: 0, display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
{showLabel && <SectionLabel>{t("recentResultsSpeed")}</SectionLabel>}
|
||||
<div ref={ref} style={{ flex: 1, minHeight: 0 }}>
|
||||
{height > 0 && <SpeedHistoryChart results={results} height={height} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PingSubChart({ results }: { results: SpeedtestTrackerResult[] }) {
|
||||
const t = useScopedI18n("widget.speedtestTracker");
|
||||
const { ref, height } = useElementSize<HTMLDivElement>();
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<SectionLabel>{t("recentResultsPing")}</SectionLabel>
|
||||
<div ref={ref} style={{ flex: 1, minHeight: 0 }}>
|
||||
{height > 0 && <PingHistoryChart results={results} height={height} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SpeedHistoryChart({ results, height }: { results: SpeedtestTrackerResult[]; height: number }) {
|
||||
const board = useRequiredBoard();
|
||||
const theme = useMantineTheme();
|
||||
const t = useScopedI18n("widget.speedtestTracker");
|
||||
|
||||
const data = useMemo(
|
||||
() =>
|
||||
[...results]
|
||||
.sort((resultA, resultB) => resultA.created_at.getTime() - resultB.created_at.getTime())
|
||||
.filter((result) => (result.download_bits ?? 0) > 0)
|
||||
.map((result) => ({
|
||||
ts: result.created_at.getTime(),
|
||||
Download: parseFloat(((result.download_bits ?? 0) / 1_000_000).toFixed(2)),
|
||||
Upload:
|
||||
result.upload_bits != null && result.upload_bits > 0
|
||||
? parseFloat((result.upload_bits / 1_000_000).toFixed(2))
|
||||
: 0,
|
||||
})),
|
||||
[results],
|
||||
);
|
||||
|
||||
const yConfig = useMemo(
|
||||
() =>
|
||||
buildYAxisConfig(Math.max(...data.map((item) => Math.max(item.Download, item.Upload)), 0), [
|
||||
{ threshold: 400, step: 100 },
|
||||
{ threshold: Infinity, step: 200 },
|
||||
]),
|
||||
[data],
|
||||
);
|
||||
|
||||
const { midnightTs, xTicks, topDateTicks } = useMemo(() => buildXAxisTicks(data), [data]);
|
||||
const renderXTick = useMemo(() => makeXTickRenderer(midnightTs), [midnightTs]);
|
||||
|
||||
if (data.length === 0) return null;
|
||||
|
||||
return (
|
||||
<AreaChart
|
||||
h={height}
|
||||
data={data}
|
||||
dataKey="ts"
|
||||
series={[
|
||||
{ name: "Download", label: t("download"), color: "blue" },
|
||||
{ name: "Upload", label: t("upload"), color: "teal" },
|
||||
]}
|
||||
curveType="monotone"
|
||||
gridAxis="y"
|
||||
tickLine="none"
|
||||
withLegend
|
||||
fillOpacity={0.2}
|
||||
styles={{ root: { padding: 5, borderRadius: theme.radius[board.itemRadius] } }}
|
||||
valueFormatter={(val: number) => `${val} Mbps`}
|
||||
xAxisProps={{
|
||||
type: "number",
|
||||
domain: ["dataMin", "dataMax"],
|
||||
ticks: xTicks,
|
||||
tick: renderXTick,
|
||||
interval: 0,
|
||||
height: midnightTs !== null ? 38 : 28,
|
||||
}}
|
||||
yAxisProps={{
|
||||
ticks: yConfig.ticks,
|
||||
domain: yConfig.domain,
|
||||
tickFormatter: (val: number) => `${val}`,
|
||||
width: 50,
|
||||
tick: { fontSize: 10 },
|
||||
}}
|
||||
tooltipProps={{
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
content: (props: any) => {
|
||||
const { label, payload, active } = props as {
|
||||
label?: number | string;
|
||||
payload?: Record<string, unknown>[];
|
||||
active?: boolean;
|
||||
};
|
||||
if (!active || !payload?.length) return null;
|
||||
return (
|
||||
<ChartTooltip
|
||||
label={formatTooltipDate(label)}
|
||||
payload={payload}
|
||||
valueFormatter={(val: number) => `${val} Mbps`}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
<XAxis
|
||||
xAxisId="topDates"
|
||||
orientation="top"
|
||||
type="number"
|
||||
dataKey="ts"
|
||||
domain={["dataMin", "dataMax"]}
|
||||
ticks={topDateTicks}
|
||||
tick={renderTopDateTick}
|
||||
height={20}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
interval={0}
|
||||
/>
|
||||
{xTicks.map((tickTs) => (
|
||||
<ReferenceLine
|
||||
key={tickTs}
|
||||
x={tickTs}
|
||||
yAxisId="left"
|
||||
stroke="var(--mantine-color-dimmed)"
|
||||
strokeDasharray="3 3"
|
||||
strokeOpacity={0.35}
|
||||
/>
|
||||
))}
|
||||
</AreaChart>
|
||||
);
|
||||
}
|
||||
|
||||
function PingHistoryChart({ results, height }: { results: SpeedtestTrackerResult[]; height: number }) {
|
||||
const board = useRequiredBoard();
|
||||
const theme = useMantineTheme();
|
||||
const t = useScopedI18n("widget.speedtestTracker");
|
||||
|
||||
const data = useMemo(
|
||||
() =>
|
||||
[...results]
|
||||
.sort((resultA, resultB) => resultA.created_at.getTime() - resultB.created_at.getTime())
|
||||
.map((result) =>
|
||||
result.ping === null
|
||||
? null
|
||||
: {
|
||||
ts: result.created_at.getTime(),
|
||||
Ping: result.ping,
|
||||
},
|
||||
)
|
||||
.filter((result) => result !== null),
|
||||
[results],
|
||||
);
|
||||
|
||||
const yConfig = useMemo(
|
||||
() =>
|
||||
buildYAxisConfig(Math.max(...data.map((item) => item.Ping), 0), [
|
||||
{ threshold: 10, step: 2 },
|
||||
{ threshold: 50, step: 10 },
|
||||
{ threshold: 200, step: 50 },
|
||||
{ threshold: Infinity, step: 100 },
|
||||
]),
|
||||
[data],
|
||||
);
|
||||
|
||||
const { midnightTs, xTicks } = useMemo(() => buildXAxisTicks(data), [data]);
|
||||
const renderXTick = useMemo(() => makeXTickRenderer(midnightTs), [midnightTs]);
|
||||
|
||||
if (data.length === 0) return null;
|
||||
|
||||
return (
|
||||
<AreaChart
|
||||
h={height}
|
||||
data={data}
|
||||
dataKey="ts"
|
||||
series={[{ name: "Ping", label: t("ping"), color: "orange" }]}
|
||||
curveType="monotone"
|
||||
gridAxis="y"
|
||||
tickLine="none"
|
||||
withLegend={false}
|
||||
fillOpacity={0.2}
|
||||
styles={{ root: { padding: 5, borderRadius: theme.radius[board.itemRadius] } }}
|
||||
valueFormatter={(val: number) => `${val} ms`}
|
||||
xAxisProps={{
|
||||
type: "number",
|
||||
domain: ["dataMin", "dataMax"],
|
||||
ticks: xTicks,
|
||||
tick: renderXTick,
|
||||
interval: 0,
|
||||
height: midnightTs !== null ? 38 : 28,
|
||||
}}
|
||||
yAxisProps={{
|
||||
ticks: yConfig.ticks,
|
||||
domain: yConfig.domain,
|
||||
tickFormatter: (val: number) => `${val}`,
|
||||
width: 50,
|
||||
tick: { fontSize: 10 },
|
||||
}}
|
||||
tooltipProps={{
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
content: (props: any) => {
|
||||
const { label, payload, active } = props as {
|
||||
label?: number | string;
|
||||
payload?: Record<string, unknown>[];
|
||||
active?: boolean;
|
||||
};
|
||||
if (!active || !payload?.length) return null;
|
||||
return (
|
||||
<ChartTooltip
|
||||
label={formatTooltipDate(label)}
|
||||
payload={payload}
|
||||
valueFormatter={(val: number) => `${val} ms`}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{xTicks.map((tickTs) => (
|
||||
<ReferenceLine
|
||||
key={tickTs}
|
||||
x={tickTs}
|
||||
yAxisId="left"
|
||||
stroke="var(--mantine-color-dimmed)"
|
||||
strokeDasharray="3 3"
|
||||
strokeOpacity={0.35}
|
||||
/>
|
||||
))}
|
||||
</AreaChart>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Text } from "@mantine/core";
|
||||
|
||||
export function SectionLabel({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Text size="xs" fw={600} c="dimmed" tt="uppercase" style={{ letterSpacing: "0.05em" }}>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { Card, Flex, Text } from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
export interface SpeedStatCardProps {
|
||||
icon: TablerIcon;
|
||||
color: string;
|
||||
value: string;
|
||||
label: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function SpeedStatCard({ icon: Icon, color, value, label, compact = false }: SpeedStatCardProps) {
|
||||
const { ref, height, width } = useElementSize<HTMLDivElement>();
|
||||
const board = useRequiredBoard();
|
||||
const isWide = width > height + 20;
|
||||
const hideLabel = height > 0 && height <= 38;
|
||||
|
||||
return (
|
||||
<Card
|
||||
ref={ref}
|
||||
p={compact ? "xs" : "sm"}
|
||||
radius={board.itemRadius}
|
||||
bg={`var(--mantine-color-${color}-light)`}
|
||||
h="100%"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<Flex
|
||||
h="100%"
|
||||
w="100%"
|
||||
align="center"
|
||||
justify="center"
|
||||
direction={isWide ? "row" : "column"}
|
||||
gap={isWide ? 8 : 4}
|
||||
>
|
||||
<Icon size={compact ? 16 : 20} color={`var(--mantine-color-${color}-5)`} style={{ flexShrink: 0 }} />
|
||||
<Flex direction="column" align={isWide ? "flex-start" : "center"} gap={0}>
|
||||
<Text size={compact ? "sm" : "md"} fw={700} ta="center" lh={1.1}>
|
||||
{value}
|
||||
</Text>
|
||||
{!hideLabel && (
|
||||
<Text size="xs" c="dimmed" ta="center" lh={1.3}>
|
||||
{label}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Generated
+1217
-885
File diff suppressed because it is too large.
Load diff
@@ -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/249b02e3368752cb175446082015737c_default.png"},{"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":"httpsLine truncated
|
||||
[{"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/249b02e3368752cb175446082015737c_default.png"},{"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":"httpsLine truncated
|
||||
@@ -17,14 +17,14 @@
|
||||
},
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@next/eslint-plugin-next": "16.2.1",
|
||||
"@next/eslint-plugin-next": "16.2.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-config-turbo": "^2.8.21",
|
||||
"eslint-config-turbo": "^2.9.3",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^6.1.1",
|
||||
"typescript-eslint": "^8.57.2"
|
||||
"typescript-eslint": "^8.58.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
|
||||
Reference in new issue
Block a user