feat: #420 reimplement icon picker (#421)

This commit is contained in:
Manuel
2024-05-04 23:00:15 +02:00
committed by GitHub
parent 51aaab2f23
commit 60a35e2583
37 changed files with 2974 additions and 10 deletions

View File

@@ -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" />

View File

@@ -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";

View 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>
);
};

View File

@@ -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",

View File

@@ -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,
});

View 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})`,
);
},
);

View File

@@ -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";