mirror of
https://github.com/ajnart/homarr.git
synced 2026-02-26 08:20:56 +01:00
feat: add releases widget (#2497)
Co-authored-by: Andre Silva <asilva01@acuitysso.com> Co-authored-by: Meier Lukas <meierschlumpf@gmail.com> Co-authored-by: Manuel <30572287+manuel-rw@users.noreply.github.com>
This commit is contained in:
@@ -12,6 +12,7 @@ import { minecraftRouter } from "./minecraft";
|
||||
import { networkControllerRouter } from "./network-controller";
|
||||
import { notebookRouter } from "./notebook";
|
||||
import { optionsRouter } from "./options";
|
||||
import { releasesRouter } from "./releases";
|
||||
import { rssFeedRouter } from "./rssFeed";
|
||||
import { smartHomeRouter } from "./smart-home";
|
||||
import { stockPriceRouter } from "./stocks";
|
||||
@@ -34,5 +35,6 @@ export const widgetRouter = createTRPCRouter({
|
||||
mediaTranscoding: mediaTranscodingRouter,
|
||||
minecraft: minecraftRouter,
|
||||
options: optionsRouter,
|
||||
releases: releasesRouter,
|
||||
networkController: networkControllerRouter,
|
||||
});
|
||||
|
||||
53
packages/api/src/router/widgets/releases.ts
Normal file
53
packages/api/src/router/widgets/releases.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { escapeForRegEx } from "@tiptap/react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { releasesRequestHandler } from "@homarr/request-handler/releases";
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const formatVersionFilterRegex = (versionFilter: z.infer<typeof _releaseVersionFilterSchema> | undefined) => {
|
||||
if (!versionFilter) return undefined;
|
||||
|
||||
const escapedPrefix = versionFilter.prefix ? escapeForRegEx(versionFilter.prefix) : "";
|
||||
const precision = "[0-9]+\\.".repeat(versionFilter.precision).slice(0, -2);
|
||||
const escapedSuffix = versionFilter.suffix ? escapeForRegEx(versionFilter.suffix) : "";
|
||||
|
||||
return `^${escapedPrefix}${precision}${escapedSuffix}$`;
|
||||
};
|
||||
|
||||
const _releaseVersionFilterSchema = z.object({
|
||||
prefix: z.string().optional(),
|
||||
precision: z.number(),
|
||||
suffix: z.string().optional(),
|
||||
});
|
||||
|
||||
export const releasesRouter = createTRPCRouter({
|
||||
getLatest: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
repositories: z.array(
|
||||
z.object({
|
||||
providerKey: z.string(),
|
||||
identifier: z.string(),
|
||||
versionFilter: _releaseVersionFilterSchema.optional(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const result = await Promise.all(
|
||||
input.repositories.map(async (repository) => {
|
||||
const innerHandler = releasesRequestHandler.handler({
|
||||
providerKey: repository.providerKey,
|
||||
identifier: repository.identifier,
|
||||
versionRegex: formatVersionFilterRegex(repository.versionFilter),
|
||||
});
|
||||
return await innerHandler.getCachedOrUpdatedDataAsync({
|
||||
forceUpdate: false,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
return result;
|
||||
}),
|
||||
});
|
||||
@@ -23,5 +23,6 @@ export const widgetKinds = [
|
||||
"bookmarks",
|
||||
"indexerManager",
|
||||
"healthMonitoring",
|
||||
"releases",
|
||||
] as const;
|
||||
export type WidgetKind = (typeof widgetKinds)[number];
|
||||
|
||||
306
packages/request-handler/src/releases-providers.ts
Normal file
306
packages/request-handler/src/releases-providers.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export interface ReleasesProvider {
|
||||
getDetailsUrl: (identifier: string) => string | undefined;
|
||||
parseDetailsResponse: (response: unknown) => z.SafeParseReturnType<unknown, DetailsResponse> | undefined;
|
||||
getReleasesUrl: (identifier: string) => string;
|
||||
parseReleasesResponse: (response: unknown) => z.SafeParseReturnType<unknown, ReleasesResponse[]>;
|
||||
}
|
||||
|
||||
interface ProvidersProps {
|
||||
[key: string]: ReleasesProvider;
|
||||
DockerHub: ReleasesProvider;
|
||||
Github: ReleasesProvider;
|
||||
Gitlab: ReleasesProvider;
|
||||
Npm: ReleasesProvider;
|
||||
Codeberg: ReleasesProvider;
|
||||
}
|
||||
|
||||
export const Providers: ProvidersProps = {
|
||||
DockerHub: {
|
||||
getDetailsUrl(identifier) {
|
||||
if (identifier.indexOf("/") > 0) {
|
||||
const [owner, name] = identifier.split("/");
|
||||
if (!owner || !name) {
|
||||
return "";
|
||||
}
|
||||
return `https://hub.docker.com/v2/namespaces/${encodeURIComponent(owner)}/repositories/${encodeURIComponent(name)}`;
|
||||
} else {
|
||||
return `https://hub.docker.com/v2/repositories/library/${encodeURIComponent(identifier)}`;
|
||||
}
|
||||
},
|
||||
parseDetailsResponse(response) {
|
||||
return z
|
||||
.object({
|
||||
name: z.string(),
|
||||
namespace: z.string(),
|
||||
description: z.string(),
|
||||
star_count: z.number(),
|
||||
date_registered: z.string().transform((value) => new Date(value)),
|
||||
})
|
||||
.transform((resp) => ({
|
||||
projectUrl: `https://hub.docker.com/r/${resp.namespace === "library" ? "_" : resp.namespace}/${resp.name}`,
|
||||
projectDescription: resp.description,
|
||||
isFork: false,
|
||||
isArchived: false,
|
||||
createdAt: resp.date_registered,
|
||||
starsCount: resp.star_count,
|
||||
openIssues: 0,
|
||||
forksCount: 0,
|
||||
}))
|
||||
.safeParse(response);
|
||||
},
|
||||
getReleasesUrl(identifier) {
|
||||
return `${this.getDetailsUrl(identifier)}/tags?page_size=200`;
|
||||
},
|
||||
parseReleasesResponse(response) {
|
||||
return z
|
||||
.object({
|
||||
results: z.array(
|
||||
z
|
||||
.object({ name: z.string(), last_updated: z.string().transform((value) => new Date(value)) })
|
||||
.transform((tag) => ({
|
||||
identifier: "",
|
||||
latestRelease: tag.name,
|
||||
latestReleaseAt: tag.last_updated,
|
||||
})),
|
||||
),
|
||||
})
|
||||
.transform((resp) => {
|
||||
return resp.results.map((release) => ({
|
||||
...release,
|
||||
releaseUrl: "",
|
||||
releaseDescription: "",
|
||||
isPreRelease: false,
|
||||
}));
|
||||
})
|
||||
.safeParse(response);
|
||||
},
|
||||
},
|
||||
Github: {
|
||||
getDetailsUrl(identifier) {
|
||||
const [owner, name] = identifier.split("/");
|
||||
if (!owner || !name) {
|
||||
return "";
|
||||
}
|
||||
return `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`;
|
||||
},
|
||||
parseDetailsResponse(response) {
|
||||
return z
|
||||
.object({
|
||||
html_url: z.string(),
|
||||
description: z.string(),
|
||||
fork: z.boolean(),
|
||||
archived: z.boolean(),
|
||||
created_at: z.string().transform((value) => new Date(value)),
|
||||
stargazers_count: z.number(),
|
||||
open_issues_count: z.number(),
|
||||
forks_count: z.number(),
|
||||
})
|
||||
.transform((resp) => ({
|
||||
projectUrl: resp.html_url,
|
||||
projectDescription: resp.description,
|
||||
isFork: resp.fork,
|
||||
isArchived: resp.archived,
|
||||
createdAt: resp.created_at,
|
||||
starsCount: resp.stargazers_count,
|
||||
openIssues: resp.open_issues_count,
|
||||
forksCount: resp.forks_count,
|
||||
}))
|
||||
.safeParse(response);
|
||||
},
|
||||
getReleasesUrl(identifier) {
|
||||
return `${this.getDetailsUrl(identifier)}/releases`;
|
||||
},
|
||||
parseReleasesResponse(response) {
|
||||
return z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
tag_name: z.string(),
|
||||
published_at: z.string().transform((value) => new Date(value)),
|
||||
html_url: z.string(),
|
||||
body: z.string(),
|
||||
prerelease: z.boolean(),
|
||||
})
|
||||
.transform((tag) => ({
|
||||
identifier: "",
|
||||
latestRelease: tag.tag_name,
|
||||
latestReleaseAt: tag.published_at,
|
||||
releaseUrl: tag.html_url,
|
||||
releaseDescription: tag.body,
|
||||
isPreRelease: tag.prerelease,
|
||||
})),
|
||||
)
|
||||
.safeParse(response);
|
||||
},
|
||||
},
|
||||
Gitlab: {
|
||||
getDetailsUrl(identifier) {
|
||||
return `https://gitlab.com/api/v4/projects/${encodeURIComponent(identifier)}`;
|
||||
},
|
||||
parseDetailsResponse(response) {
|
||||
return z
|
||||
.object({
|
||||
web_url: z.string(),
|
||||
description: z.string(),
|
||||
forked_from_project: z.object({ id: z.number() }).nullable(),
|
||||
archived: z.boolean(),
|
||||
created_at: z.string().transform((value) => new Date(value)),
|
||||
star_count: z.number(),
|
||||
open_issues_count: z.number(),
|
||||
forks_count: z.number(),
|
||||
})
|
||||
.transform((resp) => ({
|
||||
projectUrl: resp.web_url,
|
||||
projectDescription: resp.description,
|
||||
isFork: resp.forked_from_project !== null,
|
||||
isArchived: resp.archived,
|
||||
createdAt: resp.created_at,
|
||||
starsCount: resp.star_count,
|
||||
openIssues: resp.open_issues_count,
|
||||
forksCount: resp.forks_count,
|
||||
}))
|
||||
.safeParse(response);
|
||||
},
|
||||
getReleasesUrl(identifier) {
|
||||
return `${this.getDetailsUrl(identifier)}/releases`;
|
||||
},
|
||||
parseReleasesResponse(response) {
|
||||
return z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
name: z.string(),
|
||||
released_at: z.string().transform((value) => new Date(value)),
|
||||
description: z.string(),
|
||||
_links: z.object({ self: z.string() }),
|
||||
upcoming_release: z.boolean(),
|
||||
})
|
||||
.transform((tag) => ({
|
||||
identifier: "",
|
||||
latestRelease: tag.name,
|
||||
latestReleaseAt: tag.released_at,
|
||||
releaseUrl: tag._links.self,
|
||||
releaseDescription: tag.description,
|
||||
isPreRelease: tag.upcoming_release,
|
||||
})),
|
||||
)
|
||||
.safeParse(response);
|
||||
},
|
||||
},
|
||||
Npm: {
|
||||
getDetailsUrl(_) {
|
||||
return undefined;
|
||||
},
|
||||
parseDetailsResponse(_) {
|
||||
return undefined;
|
||||
},
|
||||
getReleasesUrl(identifier) {
|
||||
return `https://registry.npmjs.org/${encodeURIComponent(identifier)}`;
|
||||
},
|
||||
parseReleasesResponse(response) {
|
||||
return z
|
||||
.object({
|
||||
time: z.record(z.string().transform((value) => new Date(value))).transform((version) =>
|
||||
Object.entries(version).map(([key, value]) => ({
|
||||
identifier: "",
|
||||
latestRelease: key,
|
||||
latestReleaseAt: value,
|
||||
})),
|
||||
),
|
||||
versions: z.record(z.object({ description: z.string() })),
|
||||
name: z.string(),
|
||||
})
|
||||
.transform((resp) => {
|
||||
return resp.time.map((release) => ({
|
||||
...release,
|
||||
releaseUrl: `https://www.npmjs.com/package/${resp.name}/v/${release.latestRelease}`,
|
||||
releaseDescription: resp.versions[release.latestRelease]?.description ?? "",
|
||||
isPreRelease: false,
|
||||
}));
|
||||
})
|
||||
.safeParse(response);
|
||||
},
|
||||
},
|
||||
Codeberg: {
|
||||
getDetailsUrl(identifier) {
|
||||
const [owner, name] = identifier.split("/");
|
||||
if (!owner || !name) {
|
||||
return "";
|
||||
}
|
||||
return `https://codeberg.org/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`;
|
||||
},
|
||||
parseDetailsResponse(response) {
|
||||
return z
|
||||
.object({
|
||||
html_url: z.string(),
|
||||
description: z.string(),
|
||||
fork: z.boolean(),
|
||||
archived: z.boolean(),
|
||||
created_at: z.string().transform((value) => new Date(value)),
|
||||
stars_count: z.number(),
|
||||
open_issues_count: z.number(),
|
||||
forks_count: z.number(),
|
||||
})
|
||||
.transform((resp) => ({
|
||||
projectUrl: resp.html_url,
|
||||
projectDescription: resp.description,
|
||||
isFork: resp.fork,
|
||||
isArchived: resp.archived,
|
||||
createdAt: resp.created_at,
|
||||
starsCount: resp.stars_count,
|
||||
openIssues: resp.open_issues_count,
|
||||
forksCount: resp.forks_count,
|
||||
}))
|
||||
.safeParse(response);
|
||||
},
|
||||
getReleasesUrl(identifier) {
|
||||
return `${this.getDetailsUrl(identifier)}/releases`;
|
||||
},
|
||||
parseReleasesResponse(response) {
|
||||
return z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
tag_name: z.string(),
|
||||
published_at: z.string().transform((value) => new Date(value)),
|
||||
url: z.string(),
|
||||
body: z.string(),
|
||||
prerelease: z.boolean(),
|
||||
})
|
||||
.transform((tag) => ({
|
||||
latestRelease: tag.tag_name,
|
||||
latestReleaseAt: tag.published_at,
|
||||
releaseUrl: tag.url,
|
||||
releaseDescription: tag.body,
|
||||
isPreRelease: tag.prerelease,
|
||||
})),
|
||||
)
|
||||
.safeParse(response);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const _detailsSchema = z.object({
|
||||
projectUrl: z.string(),
|
||||
projectDescription: z.string(),
|
||||
isFork: z.boolean(),
|
||||
isArchived: z.boolean(),
|
||||
createdAt: z.date(),
|
||||
starsCount: z.number(),
|
||||
openIssues: z.number(),
|
||||
forksCount: z.number(),
|
||||
});
|
||||
|
||||
const _releasesSchema = z.object({
|
||||
latestRelease: z.string(),
|
||||
latestReleaseAt: z.date(),
|
||||
releaseUrl: z.string(),
|
||||
releaseDescription: z.string(),
|
||||
isPreRelease: z.boolean(),
|
||||
});
|
||||
|
||||
export type DetailsResponse = z.infer<typeof _detailsSchema>;
|
||||
|
||||
export type ReleasesResponse = z.infer<typeof _releasesSchema>;
|
||||
105
packages/request-handler/src/releases.ts
Normal file
105
packages/request-handler/src/releases.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import dayjs from "dayjs";
|
||||
import { z } from "zod";
|
||||
|
||||
import { fetchWithTimeout } from "@homarr/common";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler";
|
||||
import { Providers } from "./releases-providers";
|
||||
import type { DetailsResponse } from "./releases-providers";
|
||||
|
||||
const _reponseSchema = z.object({
|
||||
identifier: z.string(),
|
||||
providerKey: z.string(),
|
||||
latestRelease: z.string(),
|
||||
latestReleaseAt: z.date(),
|
||||
releaseUrl: z.string(),
|
||||
releaseDescription: z.string(),
|
||||
isPreRelease: z.boolean(),
|
||||
projectUrl: z.string(),
|
||||
projectDescription: z.string(),
|
||||
isFork: z.boolean(),
|
||||
isArchived: z.boolean(),
|
||||
createdAt: z.date(),
|
||||
starsCount: z.number(),
|
||||
openIssues: z.number(),
|
||||
forksCount: z.number(),
|
||||
});
|
||||
|
||||
export const releasesRequestHandler = createCachedWidgetRequestHandler({
|
||||
queryKey: "releasesApiResult",
|
||||
widgetKind: "releases",
|
||||
async requestAsync(input: { providerKey: string; identifier: string; versionRegex: string | undefined }) {
|
||||
const provider = Providers[input.providerKey];
|
||||
|
||||
if (!provider) return undefined;
|
||||
|
||||
let detailsResult: DetailsResponse = {
|
||||
projectUrl: "",
|
||||
projectDescription: "",
|
||||
isFork: false,
|
||||
isArchived: false,
|
||||
createdAt: new Date(0),
|
||||
starsCount: 0,
|
||||
openIssues: 0,
|
||||
forksCount: 0,
|
||||
};
|
||||
|
||||
const detailsUrl = provider.getDetailsUrl(input.identifier);
|
||||
if (detailsUrl !== undefined) {
|
||||
const detailsResponse = await fetchWithTimeout(detailsUrl);
|
||||
const parsedDetails = provider.parseDetailsResponse(await detailsResponse.json());
|
||||
|
||||
if (parsedDetails?.success) {
|
||||
detailsResult = parsedDetails.data;
|
||||
} else {
|
||||
logger.warn("Failed to parse details response", {
|
||||
provider: input.providerKey,
|
||||
identifier: input.identifier,
|
||||
detailsUrl,
|
||||
error: parsedDetails?.error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const releasesResponse = await fetchWithTimeout(provider.getReleasesUrl(input.identifier));
|
||||
const releasesResult = provider.parseReleasesResponse(await releasesResponse.json());
|
||||
|
||||
if (!releasesResult.success) return undefined;
|
||||
|
||||
const latest: ResponseResponse = releasesResult.data
|
||||
.filter((result) => (input.versionRegex ? new RegExp(input.versionRegex).test(result.latestRelease) : true))
|
||||
.reduce(
|
||||
(latest, result) => {
|
||||
return {
|
||||
...detailsResult,
|
||||
...(result.latestReleaseAt > latest.latestReleaseAt ? result : latest),
|
||||
identifier: input.identifier,
|
||||
providerKey: input.providerKey,
|
||||
};
|
||||
},
|
||||
{
|
||||
identifier: "",
|
||||
providerKey: "",
|
||||
latestRelease: "",
|
||||
latestReleaseAt: new Date(0),
|
||||
releaseUrl: "",
|
||||
releaseDescription: "",
|
||||
isPreRelease: false,
|
||||
projectUrl: "",
|
||||
projectDescription: "",
|
||||
isFork: false,
|
||||
isArchived: false,
|
||||
createdAt: new Date(0),
|
||||
starsCount: 0,
|
||||
openIssues: 0,
|
||||
forksCount: 0,
|
||||
},
|
||||
);
|
||||
|
||||
return latest;
|
||||
},
|
||||
cacheDuration: dayjs.duration(5, "minutes"),
|
||||
});
|
||||
|
||||
export type ResponseResponse = z.infer<typeof _reponseSchema>;
|
||||
@@ -2052,6 +2052,85 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"releases": {
|
||||
"name": "Releases",
|
||||
"description": "Displays a list of the current version of the given repositories with the given version regex.",
|
||||
"option": {
|
||||
"newReleaseWithin": {
|
||||
"label": "New Release Within",
|
||||
"description": "Usage example: 1w (1 week), 10m (10 months). Accepted unit types h (hours), d (days), w (weeks), m (months), y (years). Leave empty for no highlighting of new releases."
|
||||
},
|
||||
"staleReleaseWithin": {
|
||||
"label": "Stale Release Within",
|
||||
"description": "Usage example: 1w (1 week), 10m (10 months). Accepted unit types h (hours), d (days), w (weeks), m (months), y (years). Leave empty for no highlighting of stale releases."
|
||||
},
|
||||
"showOnlyHighlighted": {
|
||||
"label": "Show Only Highlighted",
|
||||
"description": "Show only new or stale releases. As per the above."
|
||||
},
|
||||
"showDetails": {
|
||||
"label": "Show Details"
|
||||
},
|
||||
"repositories": {
|
||||
"label": "Repositories",
|
||||
"addRRepository": {
|
||||
"label": "Add repository"
|
||||
},
|
||||
"provider": {
|
||||
"label": "Provider"
|
||||
},
|
||||
"identifier": {
|
||||
"label": "Identifier",
|
||||
"placeholder": "Name or Owner/Name"
|
||||
},
|
||||
"versionFilter": {
|
||||
"label": "Version Filter",
|
||||
"prefix": {
|
||||
"label": "Prefix"
|
||||
},
|
||||
"precision": {
|
||||
"label": "Precision",
|
||||
"options": {
|
||||
"none": "None"
|
||||
}
|
||||
},
|
||||
"suffix": {
|
||||
"label": "Suffix"
|
||||
},
|
||||
"regex": {
|
||||
"label": "Regular Expression"
|
||||
}
|
||||
},
|
||||
"edit": {
|
||||
"label": "Edit"
|
||||
},
|
||||
"editForm": {
|
||||
"title": "Edit Repository",
|
||||
"cancel": {
|
||||
"label": "Cancel"
|
||||
},
|
||||
"confirm": {
|
||||
"label": "Confirm"
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"label": "Example"
|
||||
},
|
||||
"invalid": "Invalid repository definition, please check the values"
|
||||
}
|
||||
},
|
||||
"not-found": "Not Found",
|
||||
"pre-release": "Pre-Release",
|
||||
"archived": "Archived",
|
||||
"forked": "Forked",
|
||||
"starsCount": "Stars",
|
||||
"forksCount": "Forks",
|
||||
"issuesCount": "Open Issues",
|
||||
"openProjectPage": "Open Project Page",
|
||||
"openReleasePage": "Open Release Page",
|
||||
"releaseDescription": "Release Description",
|
||||
"created": "Created"
|
||||
},
|
||||
"networkControllerSummary": {
|
||||
"option": {},
|
||||
"card": {
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { Property } from "csstype";
|
||||
import classes from "./masked-image.module.css";
|
||||
|
||||
interface MaskedImageProps {
|
||||
imageUrl: string;
|
||||
imageUrl?: string;
|
||||
color: MantineColor;
|
||||
alt?: string;
|
||||
style?: React.CSSProperties;
|
||||
@@ -41,7 +41,7 @@ export const MaskedImage = ({
|
||||
maskSize,
|
||||
maskRepeat,
|
||||
maskPosition,
|
||||
maskImage: `url(${imageUrl})`,
|
||||
maskImage: imageUrl ? `url(${imageUrl})` : undefined,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { Property } from "csstype";
|
||||
import { MaskedImage } from "./masked-image";
|
||||
|
||||
interface MaskedOrNormalImageProps {
|
||||
imageUrl: string;
|
||||
imageUrl?: string;
|
||||
hasColor?: boolean;
|
||||
color?: MantineColor;
|
||||
alt?: string;
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@homarr/form": "workspace:^0.1.0",
|
||||
"@homarr/forms-collection": "workspace:^0.1.0",
|
||||
"@homarr/integrations": "workspace:^0.1.0",
|
||||
"@homarr/modals": "workspace:^0.1.0",
|
||||
"@homarr/modals-collection": "workspace:^0.1.0",
|
||||
@@ -69,6 +70,7 @@
|
||||
"next": "15.3.1",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"recharts": "^2.15.3",
|
||||
"video.js": "^8.22.0",
|
||||
"zod": "^3.24.3"
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { WidgetOptionType } from "../options";
|
||||
import { WidgetAppInput } from "./widget-app-input";
|
||||
import { WidgetLocationInput } from "./widget-location-input";
|
||||
import { WidgetMultiTextInput } from "./widget-multi-text-input";
|
||||
import { WidgetMultiReleasesRepositoriesInput } from "./widget-multiReleasesRepositories-input";
|
||||
import { WidgetMultiSelectInput } from "./widget-multiselect-input";
|
||||
import { WidgetNumberInput } from "./widget-number-input";
|
||||
import { WidgetSelectInput } from "./widget-select-input";
|
||||
@@ -21,6 +22,7 @@ const mapping = {
|
||||
switch: WidgetSwitchInput,
|
||||
app: WidgetAppInput,
|
||||
sortableItemList: WidgetSortedItemListInput,
|
||||
multiReleasesRepositories: WidgetMultiReleasesRepositoriesInput,
|
||||
} satisfies Record<WidgetOptionType, unknown>;
|
||||
|
||||
export const getInputForType = <TType extends WidgetOptionType>(type: TType) => {
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { ActionIcon, Button, Divider, Fieldset, Group, Select, Stack, Text, TextInput } from "@mantine/core";
|
||||
import type { FormErrors } from "@mantine/form";
|
||||
import { IconEdit, IconTrash, IconTriangleFilled } from "@tabler/icons-react";
|
||||
import { escapeForRegEx } from "@tiptap/react";
|
||||
|
||||
import { IconPicker } from "@homarr/forms-collection";
|
||||
import { createModal, useModalAction } from "@homarr/modals";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { MaskedOrNormalImage } from "@homarr/ui";
|
||||
|
||||
import { Providers } from "../releases/releases-providers";
|
||||
import type { ReleasesRepository, ReleasesVersionFilter } from "../releases/releases-repository";
|
||||
import type { CommonWidgetInputProps } from "./common";
|
||||
import { useWidgetInputTranslation } from "./common";
|
||||
import { useFormContext } from "./form";
|
||||
|
||||
interface FormValidation {
|
||||
hasErrors: boolean;
|
||||
errors: FormErrors;
|
||||
}
|
||||
|
||||
export const WidgetMultiReleasesRepositoriesInput = ({
|
||||
property,
|
||||
kind,
|
||||
}: CommonWidgetInputProps<"multiReleasesRepositories">) => {
|
||||
const t = useWidgetInputTranslation(kind, property);
|
||||
const tRepository = useScopedI18n("widget.releases.option.repositories");
|
||||
const form = useFormContext();
|
||||
const repositories = form.values.options[property] as ReleasesRepository[];
|
||||
const { openModal } = useModalAction(ReleaseEditModal);
|
||||
const versionFilterPrecisionOptions = useMemo(
|
||||
() => [tRepository("versionFilter.precision.options.none"), "#", "#.#", "#.#.#", "#.#.#.#", "#.#.#.#.#"],
|
||||
[tRepository],
|
||||
);
|
||||
|
||||
const onRepositorySave = useCallback(
|
||||
(repository: ReleasesRepository, index: number): FormValidation => {
|
||||
form.setFieldValue(`options.${property}.${index}.providerKey`, repository.providerKey);
|
||||
form.setFieldValue(`options.${property}.${index}.identifier`, repository.identifier);
|
||||
form.setFieldValue(`options.${property}.${index}.versionFilter`, repository.versionFilter);
|
||||
form.setFieldValue(`options.${property}.${index}.iconUrl`, repository.iconUrl);
|
||||
|
||||
const formValidation = form.validate();
|
||||
const fieldErrors: FormErrors = Object.entries(formValidation.errors).reduce((acc, [key, value]) => {
|
||||
if (key.startsWith(`options.${property}.${index}.`)) {
|
||||
acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
}, {} as FormErrors);
|
||||
|
||||
return {
|
||||
hasErrors: Object.keys(fieldErrors).length > 0,
|
||||
errors: fieldErrors,
|
||||
};
|
||||
},
|
||||
[form, property],
|
||||
);
|
||||
|
||||
const addNewItem = () => {
|
||||
const item = {
|
||||
providerKey: "DockerHub",
|
||||
identifier: "",
|
||||
} as ReleasesRepository;
|
||||
|
||||
form.setValues((previous) => {
|
||||
const previousValues = previous.options?.[property] as ReleasesRepository[];
|
||||
return {
|
||||
...previous,
|
||||
options: {
|
||||
...previous.options,
|
||||
[property]: [...previousValues, item],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const index = repositories.length;
|
||||
|
||||
openModal({
|
||||
fieldPath: `options.${property}.${index}`,
|
||||
repository: item,
|
||||
onRepositorySave: (saved) => onRepositorySave(saved, index),
|
||||
versionFilterPrecisionOptions,
|
||||
});
|
||||
};
|
||||
|
||||
const onReleaseRemove = (index: number) => {
|
||||
form.setValues((previous) => {
|
||||
const previousValues = previous.options?.[property] as ReleasesRepository[];
|
||||
return {
|
||||
...previous,
|
||||
options: {
|
||||
...previous.options,
|
||||
[property]: previousValues.filter((_, i) => i !== index),
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Fieldset legend={t("label")}>
|
||||
<Stack gap="5">
|
||||
<Button onClick={addNewItem}>{tRepository("addRRepository.label")}</Button>
|
||||
<Divider my="sm" />
|
||||
|
||||
{repositories.map((repository, index) => {
|
||||
return (
|
||||
<Stack key={`${repository.providerKey}.${repository.identifier}`} gap={5}>
|
||||
<Group align="center" gap="xs">
|
||||
<MaskedOrNormalImage
|
||||
hasColor={false}
|
||||
imageUrl={repository.iconUrl ?? Providers[repository.providerKey]?.iconUrl}
|
||||
style={{
|
||||
height: "1em",
|
||||
width: "1em",
|
||||
}}
|
||||
/>
|
||||
|
||||
<Text c="dimmed" fw={100} size="xs">
|
||||
{Providers[repository.providerKey]?.name}
|
||||
</Text>
|
||||
|
||||
<Group justify="space-between" align="center" style={{ flex: 1 }} gap={5}>
|
||||
<Text size="sm" style={{ flex: 1, whiteSpace: "nowrap" }}>
|
||||
{repository.identifier}
|
||||
</Text>
|
||||
|
||||
<Text c="dimmed" size="xs" ta="end" style={{ flex: 1, whiteSpace: "nowrap" }}>
|
||||
{formatVersionFilterRegex(repository.versionFilter) ?? ""}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Button
|
||||
onClick={() =>
|
||||
openModal({
|
||||
fieldPath: `options.${property}.${index}`,
|
||||
repository,
|
||||
onRepositorySave: (saved) => onRepositorySave(saved, index),
|
||||
versionFilterPrecisionOptions,
|
||||
})
|
||||
}
|
||||
variant="light"
|
||||
leftSection={<IconEdit size={15} />}
|
||||
size="xs"
|
||||
>
|
||||
{tRepository("edit.label")}
|
||||
</Button>
|
||||
|
||||
<ActionIcon variant="transparent" color="red" onClick={() => onReleaseRemove(index)}>
|
||||
<IconTrash size={15} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
{Object.keys(form.errors).filter((key) => key.startsWith(`options.${property}.${index}.`)).length > 0 && (
|
||||
<Group align="center" justify="center" gap="xs" bg="red.1">
|
||||
<IconTriangleFilled size={15} color="var(--mantine-color-red-filled)" />
|
||||
<Text size="sm" c="red">
|
||||
{tRepository("invalid")}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
<Divider my="sm" size="xs" mt={5} mb={5} />
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
);
|
||||
};
|
||||
|
||||
const formatVersionFilterRegex = (versionFilter: ReleasesVersionFilter | undefined) => {
|
||||
if (!versionFilter) return undefined;
|
||||
|
||||
const escapedPrefix = versionFilter.prefix ? escapeForRegEx(versionFilter.prefix) : "";
|
||||
const precision = "[0-9]+\\.".repeat(versionFilter.precision).slice(0, -2);
|
||||
const escapedSuffix = versionFilter.suffix ? escapeForRegEx(versionFilter.suffix) : "";
|
||||
|
||||
return `^${escapedPrefix}${precision}${escapedSuffix}$`;
|
||||
};
|
||||
|
||||
interface ReleaseEditProps {
|
||||
fieldPath: string;
|
||||
repository: ReleasesRepository;
|
||||
onRepositorySave: (repository: ReleasesRepository) => FormValidation;
|
||||
versionFilterPrecisionOptions: string[];
|
||||
}
|
||||
|
||||
const ReleaseEditModal = createModal<ReleaseEditProps>(({ innerProps, actions }) => {
|
||||
const tRepository = useScopedI18n("widget.releases.option.repositories");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [tempRepository, setTempRepository] = useState(() => ({ ...innerProps.repository }));
|
||||
const [formErrors, setFormErrors] = useState<FormErrors>({});
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
setLoading(true);
|
||||
|
||||
const validation = innerProps.onRepositorySave(tempRepository);
|
||||
setFormErrors(validation.errors);
|
||||
if (!validation.hasErrors) {
|
||||
actions.closeModal();
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}, [innerProps, tempRepository, actions]);
|
||||
|
||||
const handleChange = useCallback((changedValue: Partial<ReleasesRepository>) => {
|
||||
setTempRepository((prev) => ({ ...prev, ...changedValue }));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Group align="center">
|
||||
<Select
|
||||
withAsterisk
|
||||
label={tRepository("provider.label")}
|
||||
data={Object.entries(Providers).map(([key, provider]) => ({
|
||||
value: key,
|
||||
label: provider.name,
|
||||
}))}
|
||||
value={tempRepository.providerKey}
|
||||
error={formErrors[`${innerProps.fieldPath}.providerKey`]}
|
||||
onChange={(value) => {
|
||||
if (value && Providers[value]) {
|
||||
handleChange({ providerKey: value });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
withAsterisk
|
||||
label={tRepository("identifier.label")}
|
||||
value={tempRepository.identifier}
|
||||
onChange={(event) => {
|
||||
handleChange({ identifier: event.currentTarget.value });
|
||||
}}
|
||||
error={formErrors[`${innerProps.fieldPath}.identifier`]}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Fieldset legend={tRepository("versionFilter.label")}>
|
||||
<Group justify="stretch" align="center" grow>
|
||||
<TextInput
|
||||
label={tRepository("versionFilter.prefix.label")}
|
||||
value={tempRepository.versionFilter?.prefix ?? ""}
|
||||
onChange={(event) => {
|
||||
handleChange({
|
||||
versionFilter: {
|
||||
...(tempRepository.versionFilter ?? { precision: 0 }),
|
||||
prefix: event.currentTarget.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
error={formErrors[`${innerProps.fieldPath}.versionFilter.prefix`]}
|
||||
disabled={!tempRepository.versionFilter}
|
||||
/>
|
||||
<Select
|
||||
label={tRepository("versionFilter.precision.label")}
|
||||
data={Object.entries(innerProps.versionFilterPrecisionOptions).map(([key, value]) => ({
|
||||
value: key,
|
||||
label: value,
|
||||
}))}
|
||||
value={tempRepository.versionFilter?.precision.toString() ?? "0"}
|
||||
onChange={(value) => {
|
||||
const precision = value ? parseInt(value) : 0;
|
||||
handleChange({
|
||||
versionFilter:
|
||||
isNaN(precision) || precision <= 0
|
||||
? undefined
|
||||
: {
|
||||
...(tempRepository.versionFilter ?? {}),
|
||||
precision,
|
||||
},
|
||||
});
|
||||
}}
|
||||
error={formErrors[`${innerProps.fieldPath}.versionFilter.precision`]}
|
||||
/>
|
||||
<TextInput
|
||||
label={tRepository("versionFilter.suffix.label")}
|
||||
value={tempRepository.versionFilter?.suffix ?? ""}
|
||||
onChange={(event) => {
|
||||
handleChange({
|
||||
versionFilter: {
|
||||
...(tempRepository.versionFilter ?? { precision: 0 }),
|
||||
suffix: event.currentTarget.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
error={formErrors[`${innerProps.fieldPath}.versionFilter.suffix`]}
|
||||
disabled={!tempRepository.versionFilter}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Text size="xs" c="dimmed">
|
||||
{tRepository("versionFilter.regex.label")}:{" "}
|
||||
{formatVersionFilterRegex(tempRepository.versionFilter) ??
|
||||
tRepository("versionFilter.precision.options.none")}
|
||||
</Text>
|
||||
</Fieldset>
|
||||
|
||||
<IconPicker
|
||||
withAsterisk={false}
|
||||
value={tempRepository.iconUrl}
|
||||
onChange={(url) => handleChange({ iconUrl: url })}
|
||||
error={formErrors[`${innerProps.fieldPath}.iconUrl`] as string}
|
||||
/>
|
||||
|
||||
<Divider my={"sm"} />
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={actions.closeModal} color="gray.5">
|
||||
{tRepository("editForm.cancel.label")}
|
||||
</Button>
|
||||
|
||||
<Button data-autofocus onClick={handleConfirm} color="red.9" loading={loading}>
|
||||
{tRepository("editForm.confirm.label")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle(t) {
|
||||
return t("widget.releases.option.repositories.editForm.title");
|
||||
},
|
||||
size: "xl",
|
||||
});
|
||||
@@ -28,6 +28,7 @@ import * as networkControllerStatus from "./network-controller/network-status";
|
||||
import * as networkControllerSummary from "./network-controller/summary";
|
||||
import * as notebook from "./notebook";
|
||||
import type { WidgetOptionDefinition } from "./options";
|
||||
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";
|
||||
@@ -63,6 +64,7 @@ export const widgetImports = {
|
||||
healthMonitoring,
|
||||
mediaTranscoding,
|
||||
minecraftServerStatus,
|
||||
releases,
|
||||
} satisfies WidgetImportRecord;
|
||||
|
||||
export type WidgetImports = typeof widgetImports;
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { ZodType } from "zod";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
|
||||
import type { inferSelectOptionValue, SelectOption } from "./_inputs/widget-select-input";
|
||||
import type { ReleasesRepository } from "./releases/releases-repository";
|
||||
|
||||
interface CommonInput<TType> {
|
||||
defaultValue?: TType;
|
||||
@@ -119,6 +120,13 @@ const optionsFactory = {
|
||||
values: [] as string[],
|
||||
validate: input?.validate,
|
||||
}),
|
||||
multiReleasesRepositories: (input?: CommonInput<ReleasesRepository[]> & { validate?: ZodType }) => ({
|
||||
type: "multiReleasesRepositories" as const,
|
||||
defaultValue: input?.defaultValue ?? [],
|
||||
withDescription: input?.withDescription ?? false,
|
||||
values: [] as ReleasesRepository[],
|
||||
validate: input?.validate,
|
||||
}),
|
||||
app: () => ({
|
||||
type: "app" as const,
|
||||
defaultValue: "",
|
||||
|
||||
30
packages/widgets/src/releases/component.module.scss
Normal file
30
packages/widgets/src/releases/component.module.scss
Normal file
@@ -0,0 +1,30 @@
|
||||
.releasesRepository {
|
||||
border-left: 2px solid transparent;
|
||||
|
||||
&:has(.releasesRepositoryHeader:hover),
|
||||
&:has(.releasesRepositoryHeader.active),
|
||||
&:has(.releasesRepositoryDetails:hover) {
|
||||
border-left-color: var(--mantine-color-secondaryColor-text);
|
||||
}
|
||||
}
|
||||
|
||||
.releasesRepositoryHeader {
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover,
|
||||
&.active,
|
||||
&:has(~ .releasesRepositoryDetails:hover) {
|
||||
background-color: var(--mantine-color-secondaryColor-light);
|
||||
}
|
||||
}
|
||||
|
||||
.releasesRepositoryDetails {
|
||||
background-color: var(--mantine-color-default-hover);
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.releasesRepositoryExpanded {
|
||||
background-color: var(--mantine-color-default-hover);
|
||||
}
|
||||
366
packages/widgets/src/releases/component.tsx
Normal file
366
packages/widgets/src/releases/component.tsx
Normal file
@@ -0,0 +1,366 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Button, Divider, Group, Stack, Text, Title, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconArchive,
|
||||
IconCircleDot,
|
||||
IconCircleFilled,
|
||||
IconExternalLink,
|
||||
IconGitFork,
|
||||
IconProgressCheck,
|
||||
IconStar,
|
||||
} from "@tabler/icons-react";
|
||||
import combineClasses from "clsx";
|
||||
import { useFormatter, useNow } from "next-intl";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { MaskedOrNormalImage } from "@homarr/ui";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import classes from "./component.module.scss";
|
||||
import { Providers } from "./releases-providers";
|
||||
import type { ReleasesRepository } from "./releases-repository";
|
||||
|
||||
function isDateWithin(date: Date, relativeDate: string): boolean {
|
||||
const amount = parseInt(relativeDate.slice(0, -1), 10);
|
||||
const unit = relativeDate.slice(-1);
|
||||
|
||||
const startTime = new Date().getTime();
|
||||
const endTime = new Date(date).getTime();
|
||||
const diffTime = Math.abs(endTime - startTime);
|
||||
const diffHours = Math.ceil(diffTime / (1000 * 60 * 60));
|
||||
|
||||
switch (unit) {
|
||||
case "h":
|
||||
return diffHours < amount;
|
||||
|
||||
case "d":
|
||||
return diffHours / 24 < amount;
|
||||
|
||||
case "w":
|
||||
return diffHours / (24 * 7) < amount;
|
||||
|
||||
case "m":
|
||||
return diffHours / (24 * 30) < amount;
|
||||
|
||||
case "y":
|
||||
return diffHours / (24 * 365) < amount;
|
||||
|
||||
default:
|
||||
throw new Error("Invalid unit");
|
||||
}
|
||||
}
|
||||
|
||||
export default function ReleasesWidget({ options }: WidgetComponentProps<"releases">) {
|
||||
const t = useScopedI18n("widget.releases");
|
||||
const now = useNow();
|
||||
const formatter = useFormatter();
|
||||
const board = useRequiredBoard();
|
||||
const [expandedRepository, setExpandedRepository] = useState("");
|
||||
const hasIconColor = useMemo(() => board.iconColor !== null, [board.iconColor]);
|
||||
|
||||
const [results] = clientApi.widget.releases.getLatest.useSuspenseQuery(
|
||||
{
|
||||
repositories: options.repositories.map((repository) => ({
|
||||
providerKey: repository.providerKey,
|
||||
identifier: repository.identifier,
|
||||
versionFilter: repository.versionFilter,
|
||||
})),
|
||||
},
|
||||
{
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
retry: false,
|
||||
},
|
||||
);
|
||||
|
||||
const repositories = useMemo(() => {
|
||||
return results
|
||||
.map(({ data }) => {
|
||||
if (data === undefined) return undefined;
|
||||
|
||||
const repository = options.repositories.find(
|
||||
(repository: ReleasesRepository) =>
|
||||
repository.providerKey === data.providerKey && repository.identifier === data.identifier,
|
||||
);
|
||||
|
||||
if (repository === undefined) return undefined;
|
||||
|
||||
return {
|
||||
...repository,
|
||||
...data,
|
||||
isNewRelease:
|
||||
options.newReleaseWithin !== "" ? isDateWithin(data.latestReleaseAt, options.newReleaseWithin) : false,
|
||||
isStaleRelease:
|
||||
options.staleReleaseWithin !== "" ? !isDateWithin(data.latestReleaseAt, options.staleReleaseWithin) : false,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(repository) =>
|
||||
repository !== undefined &&
|
||||
(!options.showOnlyHighlighted || repository.isNewRelease || repository.isStaleRelease),
|
||||
)
|
||||
.sort((repoA, repoB) => {
|
||||
if (repoA?.latestReleaseAt === undefined) return 1;
|
||||
if (repoB?.latestReleaseAt === undefined) return -1;
|
||||
return repoA.latestReleaseAt > repoB.latestReleaseAt ? -1 : 1;
|
||||
}) as ReleasesRepository[];
|
||||
}, [
|
||||
results,
|
||||
options.repositories,
|
||||
options.showOnlyHighlighted,
|
||||
options.newReleaseWithin,
|
||||
options.staleReleaseWithin,
|
||||
]);
|
||||
|
||||
const toggleExpandedRepository = useCallback(
|
||||
(identifier: string) => {
|
||||
setExpandedRepository(expandedRepository === identifier ? "" : identifier);
|
||||
},
|
||||
[expandedRepository],
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
{repositories.map((repository: ReleasesRepository) => {
|
||||
const isActive = expandedRepository === repository.identifier;
|
||||
|
||||
return (
|
||||
<Stack
|
||||
key={`${repository.providerKey}.${repository.identifier}`}
|
||||
className={classes.releasesRepository}
|
||||
gap={0}
|
||||
>
|
||||
<Group
|
||||
className={combineClasses(classes.releasesRepositoryHeader, {
|
||||
[classes.active ?? ""]: isActive,
|
||||
})}
|
||||
p="xs"
|
||||
wrap="nowrap"
|
||||
onClick={() => toggleExpandedRepository(repository.identifier)}
|
||||
>
|
||||
<MaskedOrNormalImage
|
||||
imageUrl={repository.iconUrl ?? Providers[repository.providerKey]?.iconUrl}
|
||||
hasColor={hasIconColor}
|
||||
style={{
|
||||
width: "1em",
|
||||
aspectRatio: "1/1",
|
||||
}}
|
||||
/>
|
||||
|
||||
<Group gap={5} justify="space-between" style={{ flex: 1, minWidth: 0 }} wrap="nowrap">
|
||||
<Text size="xs">{repository.identifier}</Text>
|
||||
|
||||
<Tooltip label={repository.latestRelease ?? t("not-found")}>
|
||||
<Text size="xs" fw={700} truncate="end" style={{ flexShrink: 1 }}>
|
||||
{repository.latestRelease ?? t("not-found")}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<Text
|
||||
size="xs"
|
||||
c={repository.isNewRelease ? "primaryColor" : repository.isStaleRelease ? "secondaryColor" : "dimmed"}
|
||||
>
|
||||
{repository.latestReleaseAt &&
|
||||
formatter.relativeTime(repository.latestReleaseAt, {
|
||||
now,
|
||||
style: "narrow",
|
||||
})}
|
||||
</Text>
|
||||
{(repository.isNewRelease || repository.isStaleRelease) && (
|
||||
<IconCircleFilled
|
||||
size={10}
|
||||
color={
|
||||
repository.isNewRelease
|
||||
? "var(--mantine-color-primaryColor-filled)"
|
||||
: "var(--mantine-color-secondaryColor-filled)"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
{options.showDetails && (
|
||||
<DetailsDisplay repository={repository} toggleExpandedRepository={toggleExpandedRepository} />
|
||||
)}
|
||||
{isActive && <ExpandedDisplay repository={repository} hasIconColor={hasIconColor} />}
|
||||
<Divider />
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
interface DetailsDisplayProps {
|
||||
repository: ReleasesRepository;
|
||||
toggleExpandedRepository: (identifier: string) => void;
|
||||
}
|
||||
|
||||
const DetailsDisplay = ({ repository, toggleExpandedRepository }: DetailsDisplayProps) => {
|
||||
const t = useScopedI18n("widget.releases");
|
||||
const formatter = useFormatter();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Divider onClick={() => toggleExpandedRepository(repository.identifier)} />
|
||||
<Group
|
||||
className={classes.releasesRepositoryDetails}
|
||||
justify="space-between"
|
||||
p={5}
|
||||
onClick={() => toggleExpandedRepository(repository.identifier)}
|
||||
>
|
||||
<Group>
|
||||
<Tooltip label={t("pre-release")}>
|
||||
<IconProgressCheck
|
||||
size={13}
|
||||
color={
|
||||
repository.isPreRelease ? "var(--mantine-color-secondaryColor-text)" : "var(--mantine-color-dimmed)"
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label={t("archived")}>
|
||||
<IconArchive
|
||||
size={13}
|
||||
color={repository.isArchived ? "var(--mantine-color-secondaryColor-text)" : "var(--mantine-color-dimmed)"}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label={t("forked")}>
|
||||
<IconGitFork
|
||||
size={13}
|
||||
color={repository.isFork ? "var(--mantine-color-secondaryColor-text)" : "var(--mantine-color-dimmed)"}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
<Group>
|
||||
<Tooltip label={t("starsCount")}>
|
||||
<Group gap={5}>
|
||||
<IconStar
|
||||
size={12}
|
||||
color={repository.starsCount === 0 ? "var(--mantine-color-dimmed)" : "var(--mantine-color-text)"}
|
||||
/>
|
||||
<Text size="xs" c={repository.starsCount === 0 ? "dimmed" : ""}>
|
||||
{repository.starsCount === 0
|
||||
? "-"
|
||||
: formatter.number(repository.starsCount ?? 0, {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
})}
|
||||
</Text>
|
||||
</Group>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label={t("forksCount")}>
|
||||
<Group gap={5}>
|
||||
<IconGitFork
|
||||
size={12}
|
||||
color={repository.forksCount === 0 ? "var(--mantine-color-dimmed)" : "var(--mantine-color-text)"}
|
||||
/>
|
||||
<Text size="xs" c={repository.forksCount === 0 ? "dimmed" : ""}>
|
||||
{repository.forksCount === 0
|
||||
? "-"
|
||||
: formatter.number(repository.forksCount ?? 0, {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
})}
|
||||
</Text>
|
||||
</Group>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label={t("issuesCount")}>
|
||||
<Group gap={5}>
|
||||
<IconCircleDot
|
||||
size={12}
|
||||
color={repository.openIssues === 0 ? "var(--mantine-color-dimmed)" : "var(--mantine-color-text)"}
|
||||
/>
|
||||
<Text size="xs" c={repository.openIssues === 0 ? "dimmed" : ""}>
|
||||
{repository.openIssues === 0
|
||||
? "-"
|
||||
: formatter.number(repository.openIssues ?? 0, {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
})}
|
||||
</Text>
|
||||
</Group>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface ExtendedDisplayProps {
|
||||
repository: ReleasesRepository;
|
||||
hasIconColor: boolean;
|
||||
}
|
||||
|
||||
const ExpandedDisplay = ({ repository, hasIconColor }: ExtendedDisplayProps) => {
|
||||
const t = useScopedI18n("widget.releases");
|
||||
const now = useNow();
|
||||
const formatter = useFormatter();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Divider mx={5} />
|
||||
<Stack className={classes.releasesRepositoryExpanded} gap={0} p={10}>
|
||||
<Group justify="space-between" align="center">
|
||||
<Group gap={5} align="center">
|
||||
<MaskedOrNormalImage
|
||||
imageUrl={Providers[repository.providerKey]?.iconUrl}
|
||||
hasColor={hasIconColor}
|
||||
style={{
|
||||
width: "1em",
|
||||
aspectRatio: "1/1",
|
||||
}}
|
||||
/>
|
||||
<Text size="xs" c="iconColor" ff="monospace">
|
||||
{Providers[repository.providerKey]?.name}
|
||||
</Text>
|
||||
</Group>
|
||||
{repository.createdAt && (
|
||||
<Text size="xs" c="dimmed" ff="monospace">
|
||||
<Text span>{t("created")}</Text>
|
||||
<Text span> | </Text>
|
||||
<Text span fw={700}>
|
||||
{formatter.relativeTime(repository.createdAt, {
|
||||
now,
|
||||
style: "narrow",
|
||||
})}
|
||||
</Text>
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
<Divider my={10} mx="30%" />
|
||||
<Button
|
||||
variant="light"
|
||||
component="a"
|
||||
href={repository.releaseUrl ?? repository.projectUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<IconExternalLink />
|
||||
{repository.releaseUrl ? t("openReleasePage") : t("openProjectPage")}
|
||||
</Button>
|
||||
{repository.releaseDescription && (
|
||||
<>
|
||||
<Divider my={10} mx="30%" />
|
||||
<Title order={4} ta="center">
|
||||
{t("releaseDescription")}
|
||||
</Title>
|
||||
<Text component="div" size="xs" ff="monospace">
|
||||
<ReactMarkdown skipHtml>{repository.releaseDescription}</ReactMarkdown>
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
};
|
||||
53
packages/widgets/src/releases/index.ts
Normal file
53
packages/widgets/src/releases/index.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { IconRocket } from "@tabler/icons-react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("releases", {
|
||||
icon: IconRocket,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
newReleaseWithin: factory.text({
|
||||
defaultValue: "1w",
|
||||
withDescription: true,
|
||||
validate: z
|
||||
.string()
|
||||
.regex(/^\d+[hdwmy]$/)
|
||||
.or(z.literal("")),
|
||||
}),
|
||||
staleReleaseWithin: factory.text({
|
||||
defaultValue: "6m",
|
||||
withDescription: true,
|
||||
validate: z
|
||||
.string()
|
||||
.regex(/^\d+[hdwmy]$/)
|
||||
.or(z.literal("")),
|
||||
}),
|
||||
showOnlyHighlighted: factory.switch({
|
||||
withDescription: true,
|
||||
defaultValue: true,
|
||||
}),
|
||||
showDetails: factory.switch({
|
||||
defaultValue: true,
|
||||
}),
|
||||
repositories: factory.multiReleasesRepositories({
|
||||
defaultValue: [],
|
||||
validate: z.array(
|
||||
z.object({
|
||||
providerKey: z.string().min(1),
|
||||
identifier: z.string().min(1),
|
||||
versionFilter: z
|
||||
.object({
|
||||
prefix: z.string().optional(),
|
||||
precision: z.number(),
|
||||
suffix: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
iconUrl: z.string().url().optional(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
}));
|
||||
},
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
36
packages/widgets/src/releases/releases-providers.ts
Normal file
36
packages/widgets/src/releases/releases-providers.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export interface ReleasesProvider {
|
||||
name: string;
|
||||
iconUrl: string;
|
||||
}
|
||||
|
||||
interface ProvidersProps {
|
||||
[key: string]: ReleasesProvider;
|
||||
DockerHub: ReleasesProvider;
|
||||
Github: ReleasesProvider;
|
||||
Gitlab: ReleasesProvider;
|
||||
Npm: ReleasesProvider;
|
||||
Codeberg: ReleasesProvider;
|
||||
}
|
||||
|
||||
export const Providers: ProvidersProps = {
|
||||
DockerHub: {
|
||||
name: "Docker Hub",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/docker.svg",
|
||||
},
|
||||
Github: {
|
||||
name: "Github",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/github-dark.svg",
|
||||
},
|
||||
Gitlab: {
|
||||
name: "Gitlab",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/gitlab.svg",
|
||||
},
|
||||
Npm: {
|
||||
name: "Npm",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets//assets/npm.svg",
|
||||
},
|
||||
Codeberg: {
|
||||
name: "Codeberg",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/codeberg.svg",
|
||||
},
|
||||
};
|
||||
30
packages/widgets/src/releases/releases-repository.ts
Normal file
30
packages/widgets/src/releases/releases-repository.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export interface ReleasesVersionFilter {
|
||||
prefix?: string;
|
||||
precision: number;
|
||||
suffix?: string;
|
||||
}
|
||||
|
||||
export interface ReleasesRepository {
|
||||
providerKey: string;
|
||||
identifier: string;
|
||||
versionFilter?: ReleasesVersionFilter;
|
||||
iconUrl?: string;
|
||||
|
||||
latestRelease?: string;
|
||||
latestReleaseAt?: Date;
|
||||
isNewRelease: boolean;
|
||||
isStaleRelease: boolean;
|
||||
|
||||
releaseUrl?: string;
|
||||
releaseDescription?: string;
|
||||
isPreRelease?: boolean;
|
||||
|
||||
projectUrl?: string;
|
||||
projectDescription?: string;
|
||||
isFork?: boolean;
|
||||
isArchived?: boolean;
|
||||
createdAt?: Date;
|
||||
starsCount?: number;
|
||||
forksCount?: number;
|
||||
openIssues?: number;
|
||||
}
|
||||
668
pnpm-lock.yaml
generated
668
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user