feat: add calendar widget (#663)
* feat: add calendar widget * feat: add artifacts to gitignore
4
.gitignore
vendored
@@ -14,8 +14,8 @@ coverage
|
||||
out/
|
||||
next-env.d.ts
|
||||
|
||||
# nest.js
|
||||
apps/nestjs/dist
|
||||
# artifacts
|
||||
packages/db/migrations/*/migrate.cjs
|
||||
|
||||
# nitro
|
||||
.nitro/
|
||||
|
||||
9
.vscode/settings.json
vendored
@@ -8,7 +8,14 @@
|
||||
"typescript.tsdk": "node_modules\\typescript\\lib",
|
||||
"js/ts.implicitProjectConfig.experimentalDecorators": true,
|
||||
"prettier.configPath": "./tooling/prettier/index.mjs",
|
||||
"cSpell.words": ["cqmin", "homarr", "superjson", "trpc", "Umami"],
|
||||
"cSpell.words": [
|
||||
"cqmin",
|
||||
"homarr",
|
||||
"Sonarr",
|
||||
"superjson",
|
||||
"trpc",
|
||||
"Umami"
|
||||
],
|
||||
"i18n-ally.dirStructure": "auto",
|
||||
"i18n-ally.enabledFrameworks": ["next-international"],
|
||||
"i18n-ally.localesPaths": ["./packages/translation/src/lang/"],
|
||||
|
||||
BIN
apps/nextjs/public/images/apps/imdb.png
Normal file
|
After Width: | Height: | Size: 497 B |
25
apps/nextjs/public/images/apps/lidarr.svg
Normal file
|
After Width: | Height: | Size: 54 KiB |
1
apps/nextjs/public/images/apps/radarr.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1024" viewBox="0 0 1024 1024" width="1024" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><path id="a" d="m0 0h1024v1024h-1024z"/><clipPath id="b"><use clip-rule="evenodd" xlink:href="#a"/></clipPath></defs><g clip-path="url(#b)"><use fill="none" xlink:href="#a"/><g transform="translate(70 21.00012)"><path d="m105.302 154.943 7.522 714.549c-60.173 7.522-105.30242-22.565-105.30242-82.737l-7.52158-594.205c0-188.03894 172.996-233.1684 278.298-157.9526l534.032 308.3846c75.216 52.651 90.259 150.431 52.651 218.125-7.521-52.651-30.086-82.737-75.216-112.823l-601.726-338.471c-45.129-30.0862-82.737-22.5646-82.737 45.13z" fill="#24292e"/><path d="m0 376.079c45.1295 15.043 90.259 7.521 127.867-15.043l616.769-361.036c37.608 52.651 30.087 105.302-15.043 135.388l-518.989 300.863c-75.216 37.608-172.9961 0-210.604-60.172z" fill="#24292e" transform="translate(60.17249 531.0214)"/><path d="m0 413.687 368.557-210.604-361.03543-203.083z" fill="#ffc230" transform="translate(240.6902 282.8092)"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
1
apps/nextjs/public/images/apps/readarr.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg version="1.1" viewBox="0 0 1000 1000" xmlns="http://www.w3.org/2000/svg"><g transform="matrix(.97895 0 0 .97895 -2.2026 -2.2026)"><g stroke="#443c3c" stroke-width="1.5"><circle cx="513" cy="513" r="510" fill="#eee"/><circle cx="513" cy="513" r="440" fill="#443c3c"/><circle cx="513" cy="513" r="387" fill="#8e2222"/></g><g stroke-width="1.5"><circle cx="513" cy="513" r="378" fill="#eee" stroke="#8e2222"/><circle cx="511.67" cy="514.33" r="265" fill="#443c3c" stroke="#443c3c"/></g><g stroke="#8e2222"><path d="m176.71 682.24-5.71-356.67c0.634-53.106 17.5-47.829 30.454-49.405 198.58 10.83 270.91 71.252 275.35 73.499 13.323 5.018 20.937 31.782 20.302 31.123 0.634 0.658 4.441 420.6 3.807 419.94 3.172 22.455-13.323 21.002-13.958 20.343-124.99-98.152-297.56-122.85-298.19-123.51-12.055-0.795-12.055-15.326-12.055-15.326zm670.08 0.82 5.711-357.54c-0.635-53.236-17.501-47.946-30.456-49.526-198.6 10.857-270.93 71.426-275.38 73.679-13.325 5.03-20.939 31.859-20.304 31.199-0.635 0.66-4.442 421.63-3.807 420.97-3.173 22.51 13.325 21.053 13.959 20.393 125-98.392 297.58-123.15 298.22-123.81 12.056-0.797 12.056-15.363 12.056-15.363z" fill="#eee" stroke-width="10"/><path d="m174.14 739.57-5.802-356.67c0.645-53.106 17.782-47.829 30.945-49.405 201.79 10.83 275.28 71.252 279.8 73.499 13.539 5.018 21.275 31.782 20.63 31.123 0.645 0.658 4.513 420.6 3.868 419.94 3.224 22.455-13.539 21.002-14.183 20.343-127-98.152-302.36-122.85-303.01-123.51-12.249-0.795-12.249-15.326-12.249-15.326zm675.22 0.49 5.803-357.54c-0.645-53.236-17.784-47.946-30.948-49.526-201.81 10.857-275.31 71.426-279.82 73.679-13.54 5.03-21.277 31.859-20.632 31.199-0.645 0.66-4.513 421.63-3.869 420.97-3.224 22.51 13.54 21.053 14.184 20.393 127.02-98.392 302.39-123.15 303.03-123.81 12.25-0.797 12.25-15.363 12.25-15.363z" fill="#8e2222" stroke-width="5"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
1
apps/nextjs/public/images/apps/sonarr.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><g clip-rule="evenodd"><path fill="#eee" fill-rule="evenodd" d="M47.978 24c0 6.602-2.331 12.26-6.993 16.974a3.773 3.773 0 0 1-.52.509 20.53 20.53 0 0 1-2.435 2.047C33.988 46.51 29.318 48 24.022 48c-5.304 0-9.966-1.49-13.986-4.47a21.726 21.726 0 0 1-2.988-2.556c-3.622-3.6-5.846-7.783-6.672-12.548-.162-.93-.27-1.874-.32-2.833a38.27 38.27 0 0 1 0-3.197c0-.052.014-.104.044-.155.346-5.887 2.662-10.973 6.948-15.259C11.762 2.327 17.42 0 24.022 0c6.624 0 12.279 2.327 16.963 6.982 4.662 4.743 6.993 10.416 6.993 17.018z"/><path fill="#3a3f51" fill-rule="evenodd" d="m43.098 9.405-4.957 4.957c-2.899 2.899-3.153 5.422-3.153 9.87 0 3.97.63 7.602 3.585 10.556 2.156 2.157 4.204 4.194 4.204 4.194a27.962 27.962 0 0 1-1.792 1.992 3.773 3.773 0 0 1-.52.509 20.05 20.05 0 0 1-1.749 1.538l-3.883-3.884c-3.452-3.452-6.196-3.784-10.756-3.784-4.375 0-7.352.403-10.556 3.607a2715.831 2715.831 0 0 0-4.105 4.116 21.196 21.196 0 0 1-2.368-2.102 27.739 27.739 0 0 1-1.737-1.903s2.168-2.18 4.238-4.25c3.066-3.065 3.563-6.62 3.563-10.589 0-3.872-.636-7.485-3.452-10.301C7.705 11.975 5 9.284 5 9.284a25.954 25.954 0 0 1 2.047-2.302A29.761 29.761 0 0 1 9.04 5.201l4.504 4.503c2.877 2.877 6.565 3.618 10.533 3.618 4.087 0 7.763-.791 10.756-3.784 1.84-1.841 4.27-4.26 4.27-4.26a25.168 25.168 0 0 1 1.882 1.704c.767.782 1.471 1.59 2.113 2.423z"/><path fill="#0cf" fill-rule="evenodd" d="M17.438 25.228a6.986 6.986 0 0 1-.1-1.228c0-.155.005-.303.012-.443 0-.014.004-.029.011-.044.096-1.63.738-3.039 1.925-4.227 1.306-1.29 2.874-1.936 4.703-1.936 1.837 0 3.404.645 4.703 1.936 1.29 1.313 1.936 2.884 1.936 4.714s-.645 3.397-1.936 4.703c-.045.051-.093.1-.144.143a6.056 6.056 0 0 1-.675.565c-1.121.826-2.416 1.239-3.884 1.239s-2.759-.413-3.873-1.24a5.818 5.818 0 0 1-.83-.707c-1.003-.996-1.619-2.155-1.848-3.475z"/><path fill="none" stroke="#0cf" stroke-miterlimit="1" stroke-width=".4426" d="m34.943 13.223-3.32 3.242M6.834 7.198l9.044 9.012M34.6 34.855l6.154 6.369m.41-34.056-6.22 6.056M7.18 41.107l6.053-6.063"/><path fill="none" stroke="#0cf" stroke-miterlimit="1" stroke-width="1.5491" d="m34.943 13.223-3.75 3.806m-18.12-3.617 3.806 3.795m-3.662 17.854 3.706-3.851m13.705-.309 3.99 3.971"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
9
apps/nextjs/public/images/apps/the-tvdb.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="100px" height="54px" viewBox="0 0 100 54" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>Logo tvdb</title>
|
||||
<g id="Logo-tvdb" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<path d="M0,5.09590006 C0,1.81024006 2.9636,-0.441498938 6.46228,0.0733078623 L6.46228,0.0733078623 L52.10124,6.03470006 C54.15254,6.33652006 55.78724,8.54666006 55.78724,10.9536001 L55.78724,10.9536001 L55.78654,17.1835001 C51.94104,19.7605001 49.42044,24.0737001 49.42044,28.9596001 C49.42044,33.8924001 51.87974,38.1680001 55.78724,40.7361001 L55.78724,40.7361001 L55.78724,43.4756001 C55.78724,45.8825001 54.15254,48.0927001 52.10124,48.3945001 L52.10124,48.3945001 L11.60314,53.9266001 C8.10444,54.4417001 5.14084,52.1897001 5.14084,48.9040001 L5.14084,48.9040001 Z M19.68044,10.8218001 L13.66114,10.8218001 L13.66114,18.7064001 L9.84244,18.7064001 L9.84244,23.2621001 L13.66114,23.2621001 L13.66114,32.0227001 C13.4846091,37.5274601 15.6467584,39.9923503 20.6149401,40.0386142 L25.25134,40.0387001 L25.25134,35.4830001 L22.87064,35.4830001 C20.17484,35.3516001 19.59134,34.5631001 19.68074,31.0149001 L19.68074,23.2617001 L27.08014,23.2617001 L33.93424,40.0384001 L40.40294,40.0384001 L49.83694,18.7061001 L43.45734,18.7061001 L37.34794,33.3806001 L31.77694,18.7064001 L19.68044,18.7064001 L19.68044,10.8218001 Z" id="Combined-Shape" fill="#6CD591" fill-rule="nonzero"></path>
|
||||
<path d="M88.60974,18.2771001 C92.51784,18.2771001 95.12314,19.2407001 97.09994,21.4310001 C98.71734,23.1831001 99.57074,25.7677001 99.57074,28.6584001 C99.57074,32.8634001 97.86394,36.1487001 94.76414,38.0323001 C92.74234,39.2590001 90.99054,39.6094001 87.03734,39.6094001 L77.24404,39.6094001 L77.24404,10.3925001 L83.26404,10.3925001 L83.26404,18.2771001 L88.60974,18.2771001 Z M83.26404,35.0537001 L87.71094,35.0537001 C91.26004,35.0537001 93.41634,32.6884001 93.41634,28.8334001 C93.41634,24.8035001 91.52964,22.8324001 87.71094,22.8324001 L83.26404,22.8324001 L83.26404,35.0537001 Z" id="Shape" fill="#FFFFFF" fill-rule="nonzero"></path>
|
||||
<path d="M68.01354,10.3925001 L74.03354,10.3925001 L74.03354,39.6094001 L63.65594,39.6094001 C59.43354,39.6094001 57.41174,38.9962001 55.25524,37.1126001 C53.05394,35.1416001 51.93124,32.3384001 51.93124,28.7898001 C51.93124,25.1102001 53.14404,22.3070001 55.70494,20.2481001 C57.32204,18.9342001 59.52364,18.2771001 62.35354,18.2771001 L68.01384,18.2771001 L68.01384,10.3925001 L68.01354,10.3925001 Z M68.01354,22.8327001 L63.65594,22.8327001 C60.15224,22.8327001 58.04064,25.0667001 58.04064,28.7898001 C58.04064,32.6884001 60.19654,35.0537001 63.65594,35.0537001 L68.01354,35.0537001 L68.01354,22.8327001 Z" id="Shape" fill="#FFFFFF" fill-rule="nonzero"></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
BIN
apps/nextjs/public/images/apps/tmdb.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
1
apps/nextjs/public/images/apps/truenas.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 90 90" xmlns="http://www.w3.org/2000/svg"><g fill="none"><path fill="#31BEEC" d="M90 38.197v19.137L48.942 80.999V61.864z"/><path d="M41.086 61.863V81L0 57.333V38.197l18.566 10.687c.02.016.043.03.067.04l22.453 12.94Z" fill="#0095D5"/><path fill="#AEADAE" d="m61.621 45.506-16.607 9.576-16.622-9.576 16.622-9.575z"/><path fill="#0095D5" d="M86.086 31.416 69.464 40.99 48.942 29.15V10z"/><path fill="#31BEEC" d="M41.086 10v19.15l-20.55 11.827-16.621-9.561z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 484 B |
1
apps/nextjs/public/images/apps/unraid-alt.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><linearGradient id="a" x1="100%" x2="0" y1="0" y2="100%" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ff8d30"/><stop offset="1" stop-color="#e32929"/></linearGradient></defs><circle cx="50%" cy="50%" r="50%" fill="url(#a)"/><path fill="#fff" d="M246.6 200.8h18.7v110.6h-18.7zm-182.3 0H83v110.7H64.3zm91.1 123.9h18.7V367h-18.7zm-45.7-47.5h18.7v68.5h-18.7zm91.2 0h18.6v68.4h-18.6zm228.2-76.5h18.7v110.7h-18.7zM338 145.5h18.7v42.3H338zm45.7 21.2h18.7v68.2h-18.7zm-91.5 0h18.7v68.1h-18.7z"/></svg>
|
||||
|
After Width: | Height: | Size: 577 B |
@@ -19,19 +19,20 @@
|
||||
"with-env": "dotenv -e ../../.env --"
|
||||
},
|
||||
"dependencies": {
|
||||
"@homarr/analytics": "workspace:^0.1.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@homarr/icons": "workspace:^0.1.0",
|
||||
"@homarr/integrations": "workspace:^0.1.0",
|
||||
"@homarr/log": "workspace:^",
|
||||
"@homarr/ping": "workspace:^0.1.0",
|
||||
"@homarr/redis": "workspace:^0.1.0",
|
||||
"@homarr/server-settings": "workspace:^0.1.0",
|
||||
"@homarr/integrations": "workspace:^0.1.0",
|
||||
"@homarr/widgets": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@homarr/analytics": "workspace:^0.1.0",
|
||||
"@homarr/cron-jobs-core": "workspace:^0.1.0",
|
||||
"@homarr/widgets": "workspace:^0.1.0",
|
||||
"dayjs": "^1.11.11",
|
||||
"@homarr/cron-jobs": "workspace:^0.1.0",
|
||||
"@homarr/cron-job-runner": "workspace:^0.1.0",
|
||||
"dotenv": "^16.4.5",
|
||||
|
||||
@@ -49,6 +49,7 @@ export const createManyIntegrationMiddleware = <TKind extends IntegrationKind>(.
|
||||
where: and(inArray(integrations.id, input.integrationIds), inArray(integrations.kind, kinds)),
|
||||
with: {
|
||||
secrets: true,
|
||||
items: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -74,3 +75,49 @@ export const createManyIntegrationMiddleware = <TKind extends IntegrationKind>(.
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const createManyIntegrationOfOneItemMiddleware = <TKind extends IntegrationKind>(...kinds: TKind[]) => {
|
||||
return publicProcedure
|
||||
.input(z.object({ integrationIds: z.array(z.string()).min(1), itemId: z.string() }))
|
||||
.use(async ({ ctx, input, next }) => {
|
||||
const dbIntegrations = await ctx.db.query.integrations.findMany({
|
||||
where: and(inArray(integrations.id, input.integrationIds), inArray(integrations.kind, kinds)),
|
||||
with: {
|
||||
secrets: true,
|
||||
items: true,
|
||||
},
|
||||
});
|
||||
|
||||
const offset = input.integrationIds.length - dbIntegrations.length;
|
||||
if (offset !== 0) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: `${offset} of the specified integrations not found or not of kinds ${kinds.join(",")}`,
|
||||
});
|
||||
}
|
||||
|
||||
const dbIntegrationWithItem = dbIntegrations.filter((integration) =>
|
||||
integration.items.some((item) => item.itemId === input.itemId),
|
||||
);
|
||||
|
||||
if (dbIntegrationWithItem.length === 0) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Integration for item was not found",
|
||||
});
|
||||
}
|
||||
|
||||
return next({
|
||||
ctx: {
|
||||
integrations: dbIntegrationWithItem.map(({ secrets, kind, ...rest }) => ({
|
||||
...rest,
|
||||
kind: kind as TKind,
|
||||
decryptedSecrets: secrets.map((secret) => ({
|
||||
...secret,
|
||||
value: decryptSecret(secret.value),
|
||||
})),
|
||||
})),
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
20
packages/api/src/router/widgets/calendar.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { CalendarEvent } from "@homarr/integrations/types";
|
||||
import { createItemWithIntegrationChannel } from "@homarr/redis";
|
||||
|
||||
import { createManyIntegrationOfOneItemMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const calendarRouter = createTRPCRouter({
|
||||
findAllEvents: publicProcedure
|
||||
.unstable_concat(createManyIntegrationOfOneItemMiddleware("sonarr", "radarr", "readarr", "lidarr"))
|
||||
.query(async ({ ctx }) => {
|
||||
return await Promise.all(
|
||||
ctx.integrations.flatMap(async (integration) => {
|
||||
for (const item of integration.items) {
|
||||
const cache = createItemWithIntegrationChannel<CalendarEvent[]>(item.itemId, integration.id);
|
||||
return await cache.getAsync();
|
||||
}
|
||||
}),
|
||||
);
|
||||
}),
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createTRPCRouter } from "../../trpc";
|
||||
import { appRouter } from "./app";
|
||||
import { calendarRouter } from "./calendar";
|
||||
import { dnsHoleRouter } from "./dns-hole";
|
||||
import { notebookRouter } from "./notebook";
|
||||
import { smartHomeRouter } from "./smart-home";
|
||||
@@ -11,4 +12,5 @@ export const widgetRouter = createTRPCRouter({
|
||||
app: appRouter,
|
||||
dnsHole: dnsHoleRouter,
|
||||
smartHome: smartHomeRouter,
|
||||
calendar: calendarRouter,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { analyticsJob } from "./jobs/analytics";
|
||||
import { iconsUpdaterJob } from "./jobs/icons-updater";
|
||||
import { smartHomeEntityStateJob } from "./jobs/integrations/home-assistant";
|
||||
import { mediaOrganizerJob } from "./jobs/integrations/media-organizer";
|
||||
import { pingJob } from "./jobs/ping";
|
||||
import { createCronJobGroup } from "./lib";
|
||||
|
||||
@@ -9,6 +10,7 @@ export const jobGroup = createCronJobGroup({
|
||||
iconsUpdater: iconsUpdaterJob,
|
||||
ping: pingJob,
|
||||
smartHomeEntityState: smartHomeEntityStateJob,
|
||||
mediaOrganizer: mediaOrganizerJob,
|
||||
});
|
||||
|
||||
export type JobGroupKeys = ReturnType<(typeof jobGroup)["getKeys"]>[number];
|
||||
|
||||
57
packages/cron-jobs/src/jobs/integrations/media-organizer.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import dayjs from "dayjs";
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import { decryptSecret } from "@homarr/common";
|
||||
import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
|
||||
import { db, eq } from "@homarr/db";
|
||||
import { items } from "@homarr/db/schema/sqlite";
|
||||
import { SonarrIntegration } from "@homarr/integrations";
|
||||
import type { CalendarEvent } from "@homarr/integrations/types";
|
||||
import { createItemWithIntegrationChannel } from "@homarr/redis";
|
||||
|
||||
// This import is done that way to avoid circular dependencies.
|
||||
import type { WidgetComponentProps } from "../../../../widgets";
|
||||
import { createCronJob } from "../../lib";
|
||||
|
||||
export const mediaOrganizerJob = createCronJob("mediaOrganizer", EVERY_MINUTE).withCallback(async () => {
|
||||
const itemsForIntegration = await db.query.items.findMany({
|
||||
where: eq(items.kind, "calendar"),
|
||||
with: {
|
||||
integrations: {
|
||||
with: {
|
||||
integration: {
|
||||
with: {
|
||||
secrets: {
|
||||
columns: {
|
||||
kind: true,
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const itemForIntegration of itemsForIntegration) {
|
||||
for (const integration of itemForIntegration.integrations) {
|
||||
const options = SuperJSON.parse<WidgetComponentProps<"calendar">["options"]>(itemForIntegration.options);
|
||||
|
||||
const start = dayjs().subtract(Number(options.filterPastMonths), "months").toDate();
|
||||
const end = dayjs().add(Number(options.filterFutureMonths), "months").toDate();
|
||||
|
||||
const sonarr = new SonarrIntegration({
|
||||
...integration.integration,
|
||||
decryptedSecrets: integration.integration.secrets.map((secret) => ({
|
||||
...secret,
|
||||
value: decryptSecret(secret.value),
|
||||
})),
|
||||
});
|
||||
const events = await sonarr.getCalendarEventsAsync(start, end);
|
||||
|
||||
const cache = createItemWithIntegrationChannel<CalendarEvent[]>(itemForIntegration.id, integration.integrationId);
|
||||
await cache.setAsync(events);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -8,5 +8,6 @@ export const widgetKinds = [
|
||||
"dnsHoleSummary",
|
||||
"smartHome-entityState",
|
||||
"smartHome-executeAutomation",
|
||||
"calendar",
|
||||
] as const;
|
||||
export type WidgetKind = (typeof widgetKinds)[number];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
|
||||
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
|
||||
import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration";
|
||||
import { PiHoleIntegration } from "../pi-hole/pi-hole-integration";
|
||||
import type { IntegrationInput } from "./integration";
|
||||
|
||||
@@ -10,6 +11,8 @@ export const integrationCreatorByKind = (kind: IntegrationKind, integration: Int
|
||||
return new PiHoleIntegration(integration);
|
||||
case "homeAssistant":
|
||||
return new HomeAssistantIntegration(integration);
|
||||
case "sonarr":
|
||||
return new SonarrIntegration(integration);
|
||||
default:
|
||||
throw new Error(`Unknown integration kind ${kind}. Did you forget to add it to the integration creator?`);
|
||||
}
|
||||
|
||||
20
packages/integrations/src/calendar-types.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export interface CalendarEvent {
|
||||
name: string;
|
||||
subName: string;
|
||||
date: Date;
|
||||
description?: string;
|
||||
thumbnail?: string;
|
||||
mediaInformation?: {
|
||||
type: "audio" | "video" | "tv" | "movie";
|
||||
seasonNumber?: number;
|
||||
episodeNumber?: number;
|
||||
};
|
||||
links: {
|
||||
href: string;
|
||||
name: string;
|
||||
color: string | undefined;
|
||||
notificationColor?: string | undefined;
|
||||
isDark: boolean | undefined;
|
||||
logo: string;
|
||||
}[];
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// General integrations
|
||||
export { PiHoleIntegration } from "./pi-hole/pi-hole-integration";
|
||||
export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration";
|
||||
export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration";
|
||||
|
||||
// Helpers
|
||||
export { IntegrationTestConnectionError } from "./base/test-connection-error";
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { appendPath } from "@homarr/common";
|
||||
import { logger } from "@homarr/log";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import { Integration } from "../../base/integration";
|
||||
import type { CalendarEvent } from "../../calendar-types";
|
||||
|
||||
export class SonarrIntegration extends Integration {
|
||||
/**
|
||||
* Priority list that determines the quality of images using their order.
|
||||
* Types at the start of the list are better than those at the end.
|
||||
* We do this to attempt to find the best quality image for the show.
|
||||
*/
|
||||
private readonly priorities: z.infer<typeof sonarCalendarEventSchema>["images"][number]["coverType"][] = [
|
||||
"poster", // Official, perfect aspect ratio
|
||||
"banner", // Official, bad aspect ratio
|
||||
"fanart", // Unofficial, possibly bad quality
|
||||
"screenshot", // Bad aspect ratio, possibly bad quality
|
||||
"clearlogo", // Without background, bad aspect ratio
|
||||
];
|
||||
|
||||
/**
|
||||
* Gets the events in the Sonarr calendar between two dates.
|
||||
* @param start The start date
|
||||
* @param end The end date
|
||||
* @param includeUnmonitored When true results will include unmonitored items of the Sonarr library.
|
||||
*/
|
||||
async getCalendarEventsAsync(start: Date, end: Date, includeUnmonitored = true): Promise<CalendarEvent[]> {
|
||||
const url = new URL(this.integration.url);
|
||||
url.pathname = "/api/v3/calendar";
|
||||
url.searchParams.append("start", start.toISOString());
|
||||
url.searchParams.append("end", end.toISOString());
|
||||
url.searchParams.append("includeSeries", "true");
|
||||
url.searchParams.append("includeEpisodeFile", "true");
|
||||
url.searchParams.append("includeEpisodeImages", "true");
|
||||
url.searchParams.append("unmonitored", includeUnmonitored ? "true" : "false");
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"X-Api-Key": super.getSecretValue("apiKey"),
|
||||
},
|
||||
});
|
||||
const sonarCalendarEvents = await z.array(sonarCalendarEventSchema).parseAsync(await response.json());
|
||||
|
||||
return sonarCalendarEvents.map(
|
||||
(sonarCalendarEvent): CalendarEvent => ({
|
||||
name: sonarCalendarEvent.title,
|
||||
subName: sonarCalendarEvent.series.title,
|
||||
description: sonarCalendarEvent.series.overview,
|
||||
thumbnail: this.chooseBestImageAsURL(sonarCalendarEvent),
|
||||
date: sonarCalendarEvent.airDateUtc,
|
||||
mediaInformation: {
|
||||
type: "tv",
|
||||
episodeNumber: sonarCalendarEvent.episodeNumber,
|
||||
seasonNumber: sonarCalendarEvent.seasonNumber,
|
||||
},
|
||||
links: this.getLinksForSonarCalendarEvent(sonarCalendarEvent),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private getLinksForSonarCalendarEvent = (event: z.infer<typeof sonarCalendarEventSchema>) => {
|
||||
const links: CalendarEvent["links"] = [
|
||||
{
|
||||
href: `${this.integration.url}/series/${event.series.titleSlug}`,
|
||||
name: "Sonarr",
|
||||
logo: "/images/apps/sonarr.svg",
|
||||
color: undefined,
|
||||
notificationColor: "blue",
|
||||
isDark: true,
|
||||
},
|
||||
];
|
||||
|
||||
if (event.series.imdbId) {
|
||||
links.push({
|
||||
href: `https://www.imdb.com/title/${event.series.imdbId}/`,
|
||||
name: "IMDb",
|
||||
color: "#f5c518",
|
||||
isDark: false,
|
||||
logo: "/images/apps/imdb.png",
|
||||
});
|
||||
}
|
||||
|
||||
return links;
|
||||
};
|
||||
|
||||
private chooseBestImage = (
|
||||
event: z.infer<typeof sonarCalendarEventSchema>,
|
||||
): z.infer<typeof sonarCalendarEventSchema>["images"][number] | undefined => {
|
||||
const flatImages = [...event.images, ...event.series.images];
|
||||
|
||||
const sortedImages = flatImages.sort(
|
||||
(imageA, imageB) => this.priorities.indexOf(imageA.coverType) - this.priorities.indexOf(imageB.coverType),
|
||||
);
|
||||
logger.debug(`Sorted images to [${sortedImages.map((image) => image.coverType).join(",")}]`);
|
||||
return sortedImages[0];
|
||||
};
|
||||
|
||||
private chooseBestImageAsURL = (event: z.infer<typeof sonarCalendarEventSchema>): string | undefined => {
|
||||
const bestImage = this.chooseBestImage(event);
|
||||
if (!bestImage) {
|
||||
return undefined;
|
||||
}
|
||||
return bestImage.remoteUrl;
|
||||
};
|
||||
|
||||
public async testConnectionAsync(): Promise<void> {
|
||||
await super.handleTestConnectionResponseAsync({
|
||||
queryFunctionAsync: async () => {
|
||||
return await fetch(appendPath(this.integration.url, "/api/ping"), {
|
||||
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sonarCalendarEventImageSchema = z.array(
|
||||
z.object({
|
||||
coverType: z.enum(["screenshot", "poster", "banner", "fanart", "clearlogo"]),
|
||||
remoteUrl: z.string().url(),
|
||||
}),
|
||||
);
|
||||
|
||||
const sonarCalendarEventSchema = z.object({
|
||||
title: z.string(),
|
||||
airDateUtc: z.string().transform((value) => new Date(value)),
|
||||
seasonNumber: z.number().min(0),
|
||||
episodeNumber: z.number().min(0),
|
||||
series: z.object({
|
||||
overview: z.string(),
|
||||
title: z.string(),
|
||||
titleSlug: z.string(),
|
||||
images: sonarCalendarEventImageSchema,
|
||||
imdbId: z.string().optional(),
|
||||
}),
|
||||
images: sonarCalendarEventImageSchema,
|
||||
});
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./interfaces/dns-hole-summary/dns-hole-summary-types";
|
||||
export * from "./calendar-types";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createListChannel, createQueueChannel, createSubPubChannel } from "./lib/channel";
|
||||
|
||||
export { createCacheChannel } from "./lib/channel";
|
||||
export { createCacheChannel, createItemWithIntegrationChannel } from "./lib/channel";
|
||||
|
||||
export const exampleChannel = createSubPubChannel<{ message: string }>("example");
|
||||
export const pingChannel = createSubPubChannel<{ url: string; statusCode: number } | { url: string; error: string }>(
|
||||
|
||||
@@ -168,6 +168,9 @@ export const createCacheChannel = <TData>(name: string, cacheDurationMs: number
|
||||
};
|
||||
};
|
||||
|
||||
export const createItemWithIntegrationChannel = <T>(itemId: string, integrationId: string) =>
|
||||
createCacheChannel<T>(`item:${itemId}:integration:${integrationId}`);
|
||||
|
||||
const queueClient = createRedisConnection();
|
||||
|
||||
type WithId<TItem> = TItem & { _id: string };
|
||||
|
||||
@@ -16,7 +16,7 @@ export default {
|
||||
},
|
||||
init: {
|
||||
title: "New Homarr installation",
|
||||
subtitle: "Please create the initial administator user",
|
||||
subtitle: "Please create the initial administrator user",
|
||||
},
|
||||
},
|
||||
field: {
|
||||
@@ -907,6 +907,18 @@ export default {
|
||||
},
|
||||
},
|
||||
},
|
||||
calendar: {
|
||||
name: "Calendar",
|
||||
description: "Display events from your integrations in a calendar view within a certain relative time period",
|
||||
option: {
|
||||
filterPastMonths: {
|
||||
label: "Start from",
|
||||
},
|
||||
filterFutureMonths: {
|
||||
label: "End at",
|
||||
},
|
||||
},
|
||||
},
|
||||
weather: {
|
||||
name: "Weather",
|
||||
description: "Displays the current weather information of a set location.",
|
||||
@@ -1473,6 +1485,9 @@ export default {
|
||||
ping: {
|
||||
label: "Pings",
|
||||
},
|
||||
mediaOrganizer: {
|
||||
label: "Media Organizers",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.badge {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
109
packages/widgets/src/calendar/calendar-event-list.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
darken,
|
||||
Group,
|
||||
Image,
|
||||
lighten,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import { IconClock } from "@tabler/icons-react";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import type { CalendarEvent } from "@homarr/integrations/types";
|
||||
|
||||
import classes from "./calendar-event-list.module.css";
|
||||
|
||||
interface CalendarEventListProps {
|
||||
events: CalendarEvent[];
|
||||
}
|
||||
|
||||
export const CalendarEventList = ({ events }: CalendarEventListProps) => {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
return (
|
||||
<ScrollArea
|
||||
offsetScrollbars
|
||||
pt={5}
|
||||
w={400}
|
||||
styles={{
|
||||
viewport: {
|
||||
maxHeight: 450,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack>
|
||||
{events.map((event, eventIndex) => (
|
||||
<Group key={`event-${eventIndex}`} align={"stretch"} wrap="nowrap">
|
||||
<Box pos={"relative"} w={70} h={120}>
|
||||
<Image src={event.thumbnail} w={70} h={120} radius={"sm"} />
|
||||
{event.mediaInformation?.type === "tv" && (
|
||||
<Badge
|
||||
pos={"absolute"}
|
||||
bottom={-6}
|
||||
left={"50%"}
|
||||
className={classes.badge}
|
||||
>{`S${event.mediaInformation.seasonNumber} / E${event.mediaInformation.episodeNumber}`}</Badge>
|
||||
)}
|
||||
</Box>
|
||||
<Stack style={{ flexGrow: 1 }} gap={0}>
|
||||
<Group justify={"space-between"} align={"start"} mb={"xs"} wrap="nowrap">
|
||||
<Stack gap={0}>
|
||||
{event.subName && (
|
||||
<Text lineClamp={1} size="sm">
|
||||
{event.subName}
|
||||
</Text>
|
||||
)}
|
||||
<Text fw={"bold"} lineClamp={1}>
|
||||
{event.name}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group gap={3} wrap="nowrap">
|
||||
<IconClock opacity={0.7} size={"1rem"} />
|
||||
<Text c={"dimmed"}>{dayjs(event.date.toString()).format("HH:mm")}</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
{event.description && (
|
||||
<Text size={"xs"} c={"dimmed"} lineClamp={2}>
|
||||
{event.description}
|
||||
</Text>
|
||||
)}
|
||||
{event.links.length > 0 && (
|
||||
<Group pt={5} gap={5} mt={"auto"} wrap="nowrap">
|
||||
{event.links.map((link) => (
|
||||
<Button
|
||||
key={link.href}
|
||||
component={"a"}
|
||||
href={link.href.toString()}
|
||||
target={"_blank"}
|
||||
size={"xs"}
|
||||
radius={"xl"}
|
||||
variant={link.color ? undefined : "default"}
|
||||
styles={{
|
||||
root: {
|
||||
backgroundColor: link.color,
|
||||
color: link.isDark && colorScheme === "dark" ? "white" : "black",
|
||||
"&:hover": link.color
|
||||
? {
|
||||
backgroundColor: link.isDark ? lighten(link.color, 0.1) : darken(link.color, 0.1),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
leftSection={link.logo ? <Image src={link.logo} w={20} h={20} /> : undefined}
|
||||
>
|
||||
<Text>{link.name}</Text>
|
||||
</Button>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
89
packages/widgets/src/calendar/calender-day.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { Container, Popover, useMantineTheme } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
|
||||
import type { CalendarEvent } from "@homarr/integrations/types";
|
||||
|
||||
import { CalendarEventList } from "./calendar-event-list";
|
||||
|
||||
interface CalendarDayProps {
|
||||
date: Date;
|
||||
events: CalendarEvent[];
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export const CalendarDay = ({ date, events, disabled }: CalendarDayProps) => {
|
||||
const [opened, { close, open }] = useDisclosure(false);
|
||||
const { primaryColor } = useMantineTheme();
|
||||
|
||||
return (
|
||||
<Popover
|
||||
position="bottom"
|
||||
withArrow
|
||||
withinPortal
|
||||
radius="lg"
|
||||
shadow="sm"
|
||||
transitionProps={{
|
||||
transition: "pop",
|
||||
}}
|
||||
onClose={close}
|
||||
opened={opened}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Popover.Target>
|
||||
<Container
|
||||
onClick={events.length > 0 && !opened ? open : close}
|
||||
h="100%"
|
||||
w="100%"
|
||||
p={0}
|
||||
m={0}
|
||||
bd={`1cqmin solid ${opened && !disabled ? primaryColor : "transparent"}`}
|
||||
style={{
|
||||
alignContent: "center",
|
||||
borderRadius: "3.5cqmin",
|
||||
cursor: events.length === 0 || disabled ? "default" : "pointer",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
whiteSpace: "nowrap",
|
||||
fontSize: "5cqmin",
|
||||
lineHeight: "5cqmin",
|
||||
paddingTop: "1.25cqmin",
|
||||
}}
|
||||
>
|
||||
{date.getDate()}
|
||||
</div>
|
||||
<NotificationIndicator events={events} />
|
||||
</Container>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<CalendarEventList events={events} />
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
interface NotificationIndicatorProps {
|
||||
events: CalendarEvent[];
|
||||
}
|
||||
|
||||
const NotificationIndicator = ({ events }: NotificationIndicatorProps) => {
|
||||
const notificationEvents = [...new Set(events.map((event) => event.links[0]?.notificationColor))].filter(String);
|
||||
return (
|
||||
<Container h="0.7cqmin" w="80%" display="flex" p={0} style={{ flexDirection: "row", justifyContent: "center" }}>
|
||||
{notificationEvents.map((notificationEvent) => {
|
||||
return (
|
||||
<Container
|
||||
key={notificationEvent}
|
||||
bg={notificationEvent}
|
||||
h="100%"
|
||||
mx="0.25cqmin"
|
||||
p={0}
|
||||
style={{ flex: 1, borderRadius: "1000px" }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
5
packages/widgets/src/calendar/component.module.css
Normal file
@@ -0,0 +1,5 @@
|
||||
.calendar div[data-month-level] {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
72
packages/widgets/src/calendar/component.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Calendar } from "@mantine/dates";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { CalendarDay } from "./calender-day";
|
||||
import classes from "./component.module.css";
|
||||
|
||||
export default function CalendarWidget({ isEditMode, serverData }: WidgetComponentProps<"calendar">) {
|
||||
const [month, setMonth] = useState(new Date());
|
||||
const params = useParams();
|
||||
const locale = params.locale as string;
|
||||
|
||||
return (
|
||||
<Calendar
|
||||
defaultDate={new Date()}
|
||||
onPreviousMonth={setMonth}
|
||||
onNextMonth={setMonth}
|
||||
locale={locale}
|
||||
hideWeekdays={false}
|
||||
date={month}
|
||||
maxLevel="month"
|
||||
w="100%"
|
||||
h="100%"
|
||||
static={isEditMode}
|
||||
className={classes.calendar}
|
||||
styles={{
|
||||
calendarHeaderControl: {
|
||||
pointerEvents: isEditMode ? "none" : undefined,
|
||||
height: "12cqmin",
|
||||
width: "12cqmin",
|
||||
borderRadius: "3.5cqmin",
|
||||
},
|
||||
calendarHeaderLevel: {
|
||||
height: "12cqmin",
|
||||
fontSize: "6cqmin",
|
||||
pointerEvents: "none",
|
||||
},
|
||||
levelsGroup: {
|
||||
height: "100%",
|
||||
padding: "2.5cqmin",
|
||||
},
|
||||
calendarHeader: {
|
||||
maxWidth: "unset",
|
||||
marginBottom: 0,
|
||||
},
|
||||
day: {
|
||||
width: "12cqmin",
|
||||
height: "12cqmin",
|
||||
borderRadius: "3.5cqmin",
|
||||
},
|
||||
monthCell: {
|
||||
textAlign: "center",
|
||||
},
|
||||
month: {
|
||||
height: "100%",
|
||||
},
|
||||
weekday: {
|
||||
fontSize: "5.5cqmin",
|
||||
padding: 0,
|
||||
},
|
||||
}}
|
||||
renderDay={(date) => {
|
||||
const eventsForDate = (serverData?.initialData ?? []).filter((event) => dayjs(event.date).isSame(date, "day"));
|
||||
return <CalendarDay date={date} events={eventsForDate} disabled={isEditMode} />;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
23
packages/widgets/src/calendar/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { IconCalendar } from "@tabler/icons-react";
|
||||
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("calendar", {
|
||||
icon: IconCalendar,
|
||||
options: optionsBuilder.from((factory) => ({
|
||||
filterPastMonths: factory.number({
|
||||
validate: z.number().min(2).max(9999),
|
||||
defaultValue: 2,
|
||||
}),
|
||||
filterFutureMonths: factory.number({
|
||||
validate: z.number().min(2).max(9999),
|
||||
defaultValue: 2,
|
||||
}),
|
||||
})),
|
||||
supportedIntegrations: ["sonarr", "radarr", "lidarr", "readarr"],
|
||||
})
|
||||
.withServerData(() => import("./serverData"))
|
||||
.withDynamicImport(() => import("./component"));
|
||||
35
packages/widgets/src/calendar/serverData.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
"use server";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
|
||||
import type { WidgetProps } from "../definition";
|
||||
|
||||
export default async function getServerDataAsync({ integrationIds, itemId }: WidgetProps<"calendar">) {
|
||||
if (!itemId) {
|
||||
return {
|
||||
initialData: [],
|
||||
};
|
||||
}
|
||||
try {
|
||||
const data = await api.widget.calendar.findAllEvents({
|
||||
integrationIds,
|
||||
itemId,
|
||||
});
|
||||
|
||||
return {
|
||||
initialData: data
|
||||
.filter(
|
||||
(
|
||||
item,
|
||||
): item is Exclude<Exclude<RouterOutputs["widget"]["calendar"]["findAllEvents"][number], null>, undefined> =>
|
||||
item !== null && item !== undefined,
|
||||
)
|
||||
.flatMap((item) => item.data),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
initialData: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,7 @@ export interface WidgetDefinition {
|
||||
export interface WidgetProps<TKind extends WidgetKind> {
|
||||
options: inferOptionsFromDefinition<WidgetOptionsRecordOf<TKind>>;
|
||||
integrationIds: string[];
|
||||
itemId: string | undefined; // undefined when in preview mode
|
||||
}
|
||||
|
||||
type inferServerDataForKind<TKind extends WidgetKind> = WidgetImports[TKind] extends {
|
||||
@@ -90,7 +91,6 @@ type inferServerDataForKind<TKind extends WidgetKind> = WidgetImports[TKind] ext
|
||||
export type WidgetComponentProps<TKind extends WidgetKind> = WidgetProps<TKind> & {
|
||||
serverData?: inferServerDataForKind<TKind>;
|
||||
} & {
|
||||
itemId: string | undefined; // undefined when in preview mode
|
||||
boardId: string | undefined; // undefined when in preview mode
|
||||
isEditMode: boolean;
|
||||
width: number;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Loader as UiLoader } from "@mantine/core";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
|
||||
import * as app from "./app";
|
||||
import * as calendar from "./calendar";
|
||||
import * as clock from "./clock";
|
||||
import type { WidgetComponentProps } from "./definition";
|
||||
import * as dnsHoleSummary from "./dns-hole/summary";
|
||||
@@ -33,6 +34,7 @@ export const widgetImports = {
|
||||
dnsHoleSummary,
|
||||
"smartHome-entityState": smartHomeEntityState,
|
||||
"smartHome-executeAutomation": smartHomeExecuteAutomation,
|
||||
calendar,
|
||||
} satisfies WidgetImportRecord;
|
||||
|
||||
export type WidgetImports = typeof widgetImports;
|
||||
|
||||
@@ -45,6 +45,7 @@ const ItemDataLoader = async ({ item }: ItemDataLoaderProps) => {
|
||||
const data = await loader.default({
|
||||
...item,
|
||||
options: optionsWithDefault as never,
|
||||
itemId: item.id,
|
||||
});
|
||||
return <ClientServerDataInitalizer id={item.id} serverData={data} />;
|
||||
};
|
||||
|
||||
3
pnpm-lock.yaml
generated
@@ -300,6 +300,9 @@ importers:
|
||||
'@homarr/widgets':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../../packages/widgets
|
||||
dayjs:
|
||||
specifier: ^1.11.11
|
||||
version: 1.11.11
|
||||
dotenv:
|
||||
specifier: ^16.4.5
|
||||
version: 16.4.5
|
||||
|
||||