@@ -9,7 +9,8 @@ import { useI18n } from "@homarr/translation/client";
|
||||
import type { z } from "@homarr/validation";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
// TODO: add icon picker
|
||||
import { IconPicker } from "~/components/icons/picker/icon-picker";
|
||||
|
||||
type FormType = z.infer<typeof validation.app.manage>;
|
||||
|
||||
interface AppFormProps {
|
||||
@@ -38,10 +39,11 @@ export const AppForm = (props: AppFormProps) => {
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack>
|
||||
<TextInput {...form.getInputProps("name")} withAsterisk label="Name" />
|
||||
<TextInput
|
||||
{...form.getInputProps("iconUrl")}
|
||||
withAsterisk
|
||||
label="Icon URL"
|
||||
<IconPicker
|
||||
initialValue={initialValues?.iconUrl}
|
||||
onChange={(iconUrl) => {
|
||||
form.setFieldValue("iconUrl", iconUrl);
|
||||
}}
|
||||
/>
|
||||
<Textarea {...form.getInputProps("description")} label="Description" />
|
||||
<TextInput {...form.getInputProps("href")} label="URL" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { ChangeEvent, useMemo, useState } from "react";
|
||||
import type { ChangeEvent } from "react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Group, Menu, ScrollArea, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { IconSearch } from "@tabler/icons-react";
|
||||
|
||||
118
apps/nextjs/src/components/icons/picker/icon-picker.tsx
Normal file
118
apps/nextjs/src/components/icons/picker/icon-picker.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Combobox,
|
||||
Group,
|
||||
Image,
|
||||
InputBase,
|
||||
Skeleton,
|
||||
Text,
|
||||
useCombobox,
|
||||
} from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
interface IconPickerProps {
|
||||
initialValue?: string;
|
||||
onChange: (iconUrl: string) => void;
|
||||
}
|
||||
|
||||
export const IconPicker = ({ initialValue, onChange }: IconPickerProps) => {
|
||||
const [value, setValue] = useState<string>(initialValue ?? "");
|
||||
const [search, setSearch] = useState(initialValue ?? "");
|
||||
|
||||
const t = useScopedI18n("common");
|
||||
|
||||
const { data, isFetching } = clientApi.icon.findIcons.useQuery({
|
||||
searchText: search,
|
||||
});
|
||||
|
||||
const combobox = useCombobox({
|
||||
onDropdownClose: () => combobox.resetSelectedOption(),
|
||||
});
|
||||
|
||||
const notNullableData = data?.icons ?? [];
|
||||
|
||||
const totalOptions = notNullableData.reduce(
|
||||
(acc, group) => acc + group.icons.length,
|
||||
0,
|
||||
);
|
||||
|
||||
const groups = notNullableData.map((group) => {
|
||||
const options = group.icons.map((item) => (
|
||||
<Combobox.Option value={item.url} key={item.id}>
|
||||
<Group>
|
||||
<Image src={item.url} w={20} h={20} />
|
||||
<Text>{item.name}</Text>
|
||||
</Group>
|
||||
</Combobox.Option>
|
||||
));
|
||||
|
||||
return (
|
||||
<Combobox.Group label={group.slug} key={group.id}>
|
||||
{options}
|
||||
</Combobox.Group>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
onOptionSubmit={(value) => {
|
||||
setValue(value);
|
||||
setSearch(value);
|
||||
onChange(value);
|
||||
combobox.closeDropdown();
|
||||
}}
|
||||
store={combobox}
|
||||
withinPortal
|
||||
>
|
||||
<Combobox.Target>
|
||||
<InputBase
|
||||
rightSection={<Combobox.Chevron />}
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
combobox.openDropdown();
|
||||
combobox.updateSelectedOptionIndex();
|
||||
setSearch(event.currentTarget.value);
|
||||
}}
|
||||
onClick={() => combobox.openDropdown()}
|
||||
onFocus={() => combobox.openDropdown()}
|
||||
onBlur={() => {
|
||||
combobox.closeDropdown();
|
||||
setSearch(value || "");
|
||||
}}
|
||||
rightSectionPointerEvents="none"
|
||||
withAsterisk
|
||||
label="Icon URL"
|
||||
/>
|
||||
</Combobox.Target>
|
||||
|
||||
<Combobox.Dropdown>
|
||||
<Combobox.Header>
|
||||
<Text c="dimmed">
|
||||
{t("iconPicker.header", { countIcons: data?.countIcons })}
|
||||
</Text>
|
||||
</Combobox.Header>
|
||||
<Combobox.Options mah={350} style={{ overflowY: "auto" }}>
|
||||
{totalOptions > 0 ? (
|
||||
groups
|
||||
) : !isFetching ? (
|
||||
<Combobox.Empty>{t("search.nothingFound")}</Combobox.Empty>
|
||||
) : (
|
||||
Array(15)
|
||||
.fill(0)
|
||||
.map((_, index: number) => (
|
||||
<Combobox.Option
|
||||
value={`skeleton-${index}`}
|
||||
key={index}
|
||||
disabled
|
||||
>
|
||||
<Skeleton height={25} visible />
|
||||
</Combobox.Option>
|
||||
))
|
||||
)}
|
||||
</Combobox.Options>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
);
|
||||
};
|
||||
@@ -26,7 +26,8 @@
|
||||
"@homarr/redis": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"node-cron": "^3.0.3"
|
||||
"node-cron": "^3.0.3",
|
||||
"@homarr/icons": "workspace:^0.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { iconsUpdaterJob } from "~/jobs/icons-updater";
|
||||
import { queuesJob } from "./jobs/queue";
|
||||
import { createJobGroup } from "./lib/cron-job/group";
|
||||
|
||||
@@ -6,4 +7,5 @@ export const jobs = createJobGroup({
|
||||
|
||||
// This job is used to process queues.
|
||||
queues: queuesJob,
|
||||
iconsUpdater: iconsUpdaterJob,
|
||||
});
|
||||
|
||||
90
apps/tasks/src/jobs/icons-updater.ts
Normal file
90
apps/tasks/src/jobs/icons-updater.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Stopwatch } from "@homarr/common";
|
||||
import { db, eq } from "@homarr/db";
|
||||
import { createId } from "@homarr/db/client";
|
||||
import { iconRepositories, icons } from "@homarr/db/schema/sqlite";
|
||||
import { fetchIconsAsync } from "@homarr/icons";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import { EVERY_WEEK } from "~/lib/cron-job/constants";
|
||||
import { createCronJob } from "~/lib/cron-job/creator";
|
||||
|
||||
export const iconsUpdaterJob = createCronJob(EVERY_WEEK).withCallback(
|
||||
async () => {
|
||||
logger.info(`Updating icon repository cache...`);
|
||||
const stopWatch = new Stopwatch();
|
||||
const repositoryIconGroups = await fetchIconsAsync();
|
||||
const countIcons = repositoryIconGroups
|
||||
.map((group) => group.icons.length)
|
||||
.reduce((partialSum, arrayLength) => partialSum + arrayLength, 0);
|
||||
logger.info(
|
||||
`Successfully fetched ${countIcons} icons from ${repositoryIconGroups.length} repositories within ${stopWatch.getElapsedInHumanWords()}`,
|
||||
);
|
||||
|
||||
const databaseIconGroups = await db.query.iconRepositories.findMany({
|
||||
with: {
|
||||
icons: true,
|
||||
},
|
||||
});
|
||||
|
||||
const skippedChecksums: string[] = [];
|
||||
let countDeleted = 0;
|
||||
let countInserted = 0;
|
||||
|
||||
logger.info(`Updating icons in database...`);
|
||||
stopWatch.reset();
|
||||
|
||||
await db.transaction(async (transaction) => {
|
||||
for (const repositoryIconGroup of repositoryIconGroups) {
|
||||
if (!repositoryIconGroup.success) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const repositoryInDb = databaseIconGroups.find(
|
||||
(dbIconGroup) => dbIconGroup.slug === repositoryIconGroup.slug,
|
||||
);
|
||||
const repositoryIconGroupId: string = repositoryInDb?.id ?? createId();
|
||||
if (!repositoryInDb?.id) {
|
||||
await transaction.insert(iconRepositories).values({
|
||||
id: repositoryIconGroupId,
|
||||
slug: repositoryIconGroup.slug,
|
||||
});
|
||||
}
|
||||
|
||||
for (const icon of repositoryIconGroup.icons) {
|
||||
if (
|
||||
databaseIconGroups
|
||||
.flatMap((group) => group.icons)
|
||||
.some((dbIcon) => dbIcon.checksum == icon.checksum)
|
||||
) {
|
||||
skippedChecksums.push(icon.checksum);
|
||||
continue;
|
||||
}
|
||||
|
||||
await transaction.insert(icons).values({
|
||||
id: createId(),
|
||||
checksum: icon.checksum,
|
||||
name: icon.fileNameWithExtension,
|
||||
url: icon.imageUrl.href,
|
||||
iconRepositoryId: repositoryIconGroupId,
|
||||
});
|
||||
countInserted++;
|
||||
}
|
||||
}
|
||||
|
||||
const deadIcons = databaseIconGroups
|
||||
.flatMap((group) => group.icons)
|
||||
.filter((icon) => !skippedChecksums.includes(icon.checksum));
|
||||
|
||||
for (const icon of deadIcons) {
|
||||
await transaction
|
||||
.delete(icons)
|
||||
.where(eq(icons.checksum, icon.checksum));
|
||||
countDeleted++;
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`Updated database within ${stopWatch.getElapsedInHumanWords()} (-${countDeleted}, +${countInserted})`,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -3,3 +3,5 @@ export const EVERY_MINUTE = "* * * * *";
|
||||
export const EVERY_5_MINUTES = "*/5 * * * *";
|
||||
export const EVERY_10_MINUTES = "*/10 * * * *";
|
||||
export const EVERY_HOUR = "0 * * * *";
|
||||
export const EVERY_DAY = "0 0 * * */1";
|
||||
export const EVERY_WEEK = "0 0 * * 1";
|
||||
|
||||
Reference in New Issue
Block a user