@@ -9,7 +9,8 @@ import { useI18n } from "@homarr/translation/client";
|
|||||||
import type { z } from "@homarr/validation";
|
import type { z } from "@homarr/validation";
|
||||||
import { validation } 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>;
|
type FormType = z.infer<typeof validation.app.manage>;
|
||||||
|
|
||||||
interface AppFormProps {
|
interface AppFormProps {
|
||||||
@@ -38,10 +39,11 @@ export const AppForm = (props: AppFormProps) => {
|
|||||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<TextInput {...form.getInputProps("name")} withAsterisk label="Name" />
|
<TextInput {...form.getInputProps("name")} withAsterisk label="Name" />
|
||||||
<TextInput
|
<IconPicker
|
||||||
{...form.getInputProps("iconUrl")}
|
initialValue={initialValues?.iconUrl}
|
||||||
withAsterisk
|
onChange={(iconUrl) => {
|
||||||
label="Icon URL"
|
form.setFieldValue("iconUrl", iconUrl);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Textarea {...form.getInputProps("description")} label="Description" />
|
<Textarea {...form.getInputProps("description")} label="Description" />
|
||||||
<TextInput {...form.getInputProps("href")} label="URL" />
|
<TextInput {...form.getInputProps("href")} label="URL" />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"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 Link from "next/link";
|
||||||
import { Group, Menu, ScrollArea, Stack, Text, TextInput } from "@mantine/core";
|
import { Group, Menu, ScrollArea, Stack, Text, TextInput } from "@mantine/core";
|
||||||
import { IconSearch } from "@tabler/icons-react";
|
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/redis": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"node-cron": "^3.0.3"
|
"node-cron": "^3.0.3",
|
||||||
|
"@homarr/icons": "workspace:^0.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { iconsUpdaterJob } from "~/jobs/icons-updater";
|
||||||
import { queuesJob } from "./jobs/queue";
|
import { queuesJob } from "./jobs/queue";
|
||||||
import { createJobGroup } from "./lib/cron-job/group";
|
import { createJobGroup } from "./lib/cron-job/group";
|
||||||
|
|
||||||
@@ -6,4 +7,5 @@ export const jobs = createJobGroup({
|
|||||||
|
|
||||||
// This job is used to process queues.
|
// This job is used to process queues.
|
||||||
queues: queuesJob,
|
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_5_MINUTES = "*/5 * * * *";
|
||||||
export const EVERY_10_MINUTES = "*/10 * * * *";
|
export const EVERY_10_MINUTES = "*/10 * * * *";
|
||||||
export const EVERY_HOUR = "0 * * * *";
|
export const EVERY_HOUR = "0 * * * *";
|
||||||
|
export const EVERY_DAY = "0 0 * * */1";
|
||||||
|
export const EVERY_WEEK = "0 0 * * 1";
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.12.2"
|
"node": ">=20.12.2"
|
||||||
},
|
},
|
||||||
"type": "module",
|
|
||||||
"packageManager": "pnpm@9.0.6",
|
"packageManager": "pnpm@9.0.6",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "turbo build",
|
"build": "turbo build",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { appRouter as innerAppRouter } from "./router/app";
|
import { appRouter as innerAppRouter } from "./router/app";
|
||||||
import { boardRouter } from "./router/board";
|
import { boardRouter } from "./router/board";
|
||||||
import { groupRouter } from "./router/group";
|
import { groupRouter } from "./router/group";
|
||||||
|
import { iconsRouter } from "./router/icons";
|
||||||
import { integrationRouter } from "./router/integration";
|
import { integrationRouter } from "./router/integration";
|
||||||
import { inviteRouter } from "./router/invite";
|
import { inviteRouter } from "./router/invite";
|
||||||
import { locationRouter } from "./router/location";
|
import { locationRouter } from "./router/location";
|
||||||
@@ -19,6 +20,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
widget: widgetRouter,
|
widget: widgetRouter,
|
||||||
location: locationRouter,
|
location: locationRouter,
|
||||||
log: logRouter,
|
log: logRouter,
|
||||||
|
icon: iconsRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
|
|||||||
32
packages/api/src/router/icons.ts
Normal file
32
packages/api/src/router/icons.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { count, like } from "@homarr/db";
|
||||||
|
import { icons } from "@homarr/db/schema/sqlite";
|
||||||
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
|
import { createTRPCRouter, publicProcedure } from "../trpc";
|
||||||
|
|
||||||
|
export const iconsRouter = createTRPCRouter({
|
||||||
|
findIcons: publicProcedure
|
||||||
|
.input(validation.icons.findIcons)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
return {
|
||||||
|
icons: await ctx.db.query.iconRepositories.findMany({
|
||||||
|
with: {
|
||||||
|
icons: {
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
url: true,
|
||||||
|
},
|
||||||
|
where:
|
||||||
|
input.searchText?.length ?? 0 > 0
|
||||||
|
? like(icons.name, `%${input.searchText}%`)
|
||||||
|
: undefined,
|
||||||
|
limit: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
countIcons:
|
||||||
|
(await ctx.db.select({ count: count() }).from(icons))[0]?.count ?? 0,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from "./object";
|
export * from "./object";
|
||||||
export * from "./string";
|
export * from "./string";
|
||||||
export * from "./cookie";
|
export * from "./cookie";
|
||||||
|
export * from "./stopwatch";
|
||||||
|
|||||||
44
packages/common/src/stopwatch.ts
Normal file
44
packages/common/src/stopwatch.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import dayjs from "dayjs";
|
||||||
|
import relativeTime from "dayjs/plugin/relativeTime";
|
||||||
|
import updateLocale from "dayjs/plugin/updateLocale";
|
||||||
|
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
dayjs.extend(updateLocale);
|
||||||
|
dayjs.updateLocale("en", {
|
||||||
|
relativeTime: {
|
||||||
|
future: "in %s",
|
||||||
|
past: "%s ago",
|
||||||
|
s: "one second",
|
||||||
|
ss: "%d seconds",
|
||||||
|
m: "a minute",
|
||||||
|
mm: "%d minutes",
|
||||||
|
h: "an hour",
|
||||||
|
hh: "%d hours",
|
||||||
|
d: "a day",
|
||||||
|
dd: "%d days",
|
||||||
|
M: "a month",
|
||||||
|
MM: "%d months",
|
||||||
|
y: "a year",
|
||||||
|
yy: "%d years",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export class Stopwatch {
|
||||||
|
private startTime: number;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.startTime = performance.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
getElapsedInHumanWords() {
|
||||||
|
const difference = performance.now() - this.startTime;
|
||||||
|
if (difference < 1000) {
|
||||||
|
return `${Math.floor(difference)} ms`;
|
||||||
|
}
|
||||||
|
return dayjs().millisecond(this.startTime).fromNow(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.startTime = performance.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
16
packages/db/migrations/mysql/0001_fluffy_overlord.sql
Normal file
16
packages/db/migrations/mysql/0001_fluffy_overlord.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
CREATE TABLE `iconRepository` (
|
||||||
|
`iconRepository_id` varchar(256) NOT NULL,
|
||||||
|
`iconRepository_slug` varchar(150) NOT NULL,
|
||||||
|
CONSTRAINT `iconRepository_iconRepository_id` PRIMARY KEY(`iconRepository_id`)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `icon` (
|
||||||
|
`icon_id` varchar(256) NOT NULL,
|
||||||
|
`icon_name` varchar(250) NOT NULL,
|
||||||
|
`icon_url` text NOT NULL,
|
||||||
|
`icon_checksum` text NOT NULL,
|
||||||
|
`iconRepository_id` varchar(256) NOT NULL,
|
||||||
|
CONSTRAINT `icon_icon_id` PRIMARY KEY(`icon_id`)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `icon` ADD CONSTRAINT `icon_iconRepository_id_iconRepository_iconRepository_id_fk` FOREIGN KEY (`iconRepository_id`) REFERENCES `iconRepository`(`iconRepository_id`) ON DELETE cascade ON UPDATE no action;
|
||||||
1150
packages/db/migrations/mysql/meta/0001_snapshot.json
Normal file
1150
packages/db/migrations/mysql/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,13 @@
|
|||||||
"when": 1714817536714,
|
"when": 1714817536714,
|
||||||
"tag": "0000_hot_mandrill",
|
"tag": "0000_hot_mandrill",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1714854892785,
|
||||||
|
"tag": "0001_fluffy_overlord",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
13
packages/db/migrations/sqlite/0001_unusual_rage.sql
Normal file
13
packages/db/migrations/sqlite/0001_unusual_rage.sql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
CREATE TABLE `iconRepository` (
|
||||||
|
`iconRepository_id` text PRIMARY KEY NOT NULL,
|
||||||
|
`iconRepository_slug` text NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `icon` (
|
||||||
|
`icon_id` text PRIMARY KEY NOT NULL,
|
||||||
|
`icon_name` text NOT NULL,
|
||||||
|
`icon_url` text NOT NULL,
|
||||||
|
`icon_checksum` text NOT NULL,
|
||||||
|
`iconRepository_id` text NOT NULL,
|
||||||
|
FOREIGN KEY (`iconRepository_id`) REFERENCES `iconRepository`(`iconRepository_id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
1097
packages/db/migrations/sqlite/meta/0001_snapshot.json
Normal file
1097
packages/db/migrations/sqlite/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,13 @@
|
|||||||
"when": 1714817544524,
|
"when": 1714817544524,
|
||||||
"tag": "0000_premium_forgotten_one",
|
"tag": "0000_premium_forgotten_one",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1714854863811,
|
||||||
|
"tag": "0001_unusual_rage",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"migration:run": "tsx ./migrate.ts",
|
"migration:run": "tsx ./migrate.ts",
|
||||||
"migration:mysql:generate": "drizzle-kit generate:mysql --config ./mysql.config.ts",
|
"migration:mysql:generate": "drizzle-kit generate:mysql --config ./mysql.config.ts",
|
||||||
"push": "drizzle-kit push:sqlite --config ./sqlite.config.ts",
|
"push": "drizzle-kit push:sqlite --config ./sqlite.config.ts",
|
||||||
"studio": "drizzle-kit studio --config ./sqlite.config.ts",
|
"studio": "drizzle-kit studio",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -285,6 +285,21 @@ export const integrationItems = mysqlTable(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const icons = mysqlTable("icon", {
|
||||||
|
id: varchar("icon_id", { length: 256 }).notNull().primaryKey(),
|
||||||
|
name: varchar("icon_name", { length: 250 }).notNull(),
|
||||||
|
url: text("icon_url").notNull(),
|
||||||
|
checksum: text("icon_checksum").notNull(),
|
||||||
|
iconRepositoryId: varchar("iconRepository_id", { length: 256 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => iconRepositories.id, { onDelete: "cascade" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const iconRepositories = mysqlTable("iconRepository", {
|
||||||
|
id: varchar("iconRepository_id", { length: 256 }).notNull().primaryKey(),
|
||||||
|
slug: varchar("iconRepository_slug", { length: 150 }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
export const accountRelations = relations(accounts, ({ one }) => ({
|
export const accountRelations = relations(accounts, ({ one }) => ({
|
||||||
user: one(users, {
|
user: one(users, {
|
||||||
fields: [accounts.userId],
|
fields: [accounts.userId],
|
||||||
@@ -301,6 +316,20 @@ export const userRelations = relations(users, ({ many }) => ({
|
|||||||
invites: many(invites),
|
invites: many(invites),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const iconRelations = relations(icons, ({ one }) => ({
|
||||||
|
repository: one(iconRepositories, {
|
||||||
|
fields: [icons.iconRepositoryId],
|
||||||
|
references: [iconRepositories.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const iconRepositoryRelations = relations(
|
||||||
|
iconRepositories,
|
||||||
|
({ many }) => ({
|
||||||
|
icons: many(icons),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
export const inviteRelations = relations(invites, ({ one }) => ({
|
export const inviteRelations = relations(invites, ({ one }) => ({
|
||||||
creator: one(users, {
|
creator: one(users, {
|
||||||
fields: [invites.creatorId],
|
fields: [invites.creatorId],
|
||||||
|
|||||||
@@ -282,6 +282,21 @@ export const integrationItems = sqliteTable(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const icons = sqliteTable("icon", {
|
||||||
|
id: text("icon_id").notNull().primaryKey(),
|
||||||
|
name: text("icon_name").notNull(),
|
||||||
|
url: text("icon_url").notNull(),
|
||||||
|
checksum: text("icon_checksum").notNull(),
|
||||||
|
iconRepositoryId: text("iconRepository_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => iconRepositories.id, { onDelete: "cascade" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const iconRepositories = sqliteTable("iconRepository", {
|
||||||
|
id: text("iconRepository_id").notNull().primaryKey(),
|
||||||
|
slug: text("iconRepository_slug").notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
export const accountRelations = relations(accounts, ({ one }) => ({
|
export const accountRelations = relations(accounts, ({ one }) => ({
|
||||||
user: one(users, {
|
user: one(users, {
|
||||||
fields: [accounts.userId],
|
fields: [accounts.userId],
|
||||||
@@ -298,6 +313,20 @@ export const userRelations = relations(users, ({ many }) => ({
|
|||||||
invites: many(invites),
|
invites: many(invites),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const iconRelations = relations(icons, ({ one }) => ({
|
||||||
|
repository: one(iconRepositories, {
|
||||||
|
fields: [icons.iconRepositoryId],
|
||||||
|
references: [iconRepositories.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const iconRepositoryRelations = relations(
|
||||||
|
iconRepositories,
|
||||||
|
({ many }) => ({
|
||||||
|
icons: many(icons),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
export const inviteRelations = relations(invites, ({ one }) => ({
|
export const inviteRelations = relations(invites, ({ one }) => ({
|
||||||
creator: one(users, {
|
creator: one(users, {
|
||||||
fields: [invites.creatorId],
|
fields: [invites.creatorId],
|
||||||
|
|||||||
2
packages/icons/index.ts
Normal file
2
packages/icons/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./src/icons-fetcher";
|
||||||
|
export * from "./src/types";
|
||||||
39
packages/icons/package.json
Normal file
39
packages/icons/package.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "@homarr/icons",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./index.ts"
|
||||||
|
},
|
||||||
|
"typesVersions": {
|
||||||
|
"*": {
|
||||||
|
"*": [
|
||||||
|
"src/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": {
|
||||||
|
"clean": "rm -rf .turbo node_modules",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@homarr/log": "workspace:^0.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
|
"typescript": "^5.4.5"
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": [
|
||||||
|
"@homarr/eslint-config/base"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"prettier": "@homarr/prettier-config"
|
||||||
|
}
|
||||||
42
packages/icons/src/icons-fetcher.ts
Normal file
42
packages/icons/src/icons-fetcher.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { GitHubIconRepository } from "./repositories/github.icon-repository";
|
||||||
|
import { JsdelivrIconRepository } from "./repositories/jsdelivr.icon-repository";
|
||||||
|
import type { RepositoryIconGroup } from "./types";
|
||||||
|
|
||||||
|
const repositories = [
|
||||||
|
new GitHubIconRepository(
|
||||||
|
"Walkxcode",
|
||||||
|
"walkxcode/dashboard-icons",
|
||||||
|
undefined,
|
||||||
|
new URL("https://github.com/walkxcode/dashboard-icons"),
|
||||||
|
new URL(
|
||||||
|
"https://api.github.com/repos/walkxcode/dashboard-icons/git/trees/main?recursive=true",
|
||||||
|
),
|
||||||
|
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/{0}",
|
||||||
|
),
|
||||||
|
new JsdelivrIconRepository(
|
||||||
|
"Papirus",
|
||||||
|
"PapirusDevelopmentTeam/papirus-icon-theme",
|
||||||
|
"GPL-3.0",
|
||||||
|
new URL("https://github.com/PapirusDevelopmentTeam/papirus-icon-theme"),
|
||||||
|
new URL(
|
||||||
|
"https://data.jsdelivr.com/v1/packages/gh/PapirusDevelopmentTeam/papirus_icons@master?structure=flat",
|
||||||
|
),
|
||||||
|
"https://cdn.jsdelivr.net/gh/PapirusDevelopmentTeam/papirus_icons/{0}",
|
||||||
|
),
|
||||||
|
new JsdelivrIconRepository(
|
||||||
|
"Homelab SVG assets",
|
||||||
|
"loganmarchione/homelab-svg-assets",
|
||||||
|
"MIT",
|
||||||
|
new URL("https://github.com/loganmarchione/homelab-svg-assets"),
|
||||||
|
new URL(
|
||||||
|
"https://data.jsdelivr.com/v1/packages/gh/loganmarchione/homelab-svg-assets@main?structure=flat",
|
||||||
|
),
|
||||||
|
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/{0}",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
export const fetchIconsAsync = async (): Promise<RepositoryIconGroup[]> => {
|
||||||
|
return await Promise.all(
|
||||||
|
repositories.map(async (repository) => await repository.getAllIconsAsync()),
|
||||||
|
);
|
||||||
|
};
|
||||||
72
packages/icons/src/repositories/github.icon-repository.ts
Normal file
72
packages/icons/src/repositories/github.icon-repository.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import type { IconRepositoryLicense } from "../types/icon-repository-license";
|
||||||
|
import type { RepositoryIconGroup } from "../types/repository-icon-group";
|
||||||
|
import { IconRepository } from "./icon-repository";
|
||||||
|
|
||||||
|
export class GitHubIconRepository extends IconRepository {
|
||||||
|
constructor(
|
||||||
|
public readonly name: string,
|
||||||
|
public readonly slug: string,
|
||||||
|
public readonly license: IconRepositoryLicense,
|
||||||
|
public readonly repositoryUrl?: URL,
|
||||||
|
public readonly repositoryIndexingUrl?: URL,
|
||||||
|
public readonly repositoryBlobUrlTemplate?: string,
|
||||||
|
) {
|
||||||
|
super(
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
license,
|
||||||
|
repositoryUrl,
|
||||||
|
repositoryIndexingUrl,
|
||||||
|
repositoryBlobUrlTemplate,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async getAllIconsInternalAsync(): Promise<RepositoryIconGroup> {
|
||||||
|
if (!this.repositoryIndexingUrl || !this.repositoryBlobUrlTemplate) {
|
||||||
|
throw new Error("Repository URLs are required for this repository");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(this.repositoryIndexingUrl);
|
||||||
|
const listOfFiles = (await response.json()) as GitHubApiResponse;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
icons: listOfFiles.tree
|
||||||
|
.filter((treeItem) =>
|
||||||
|
this.allowedImageFileTypes.some((allowedExtension) =>
|
||||||
|
treeItem.path.includes(allowedExtension),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map((treeItem) => {
|
||||||
|
const fileNameWithExtension =
|
||||||
|
this.getFileNameWithoutExtensionFromPath(treeItem.path);
|
||||||
|
|
||||||
|
return {
|
||||||
|
imageUrl: new URL(
|
||||||
|
this.repositoryBlobUrlTemplate!.replace("{0}", treeItem.path),
|
||||||
|
),
|
||||||
|
fileNameWithExtension: fileNameWithExtension,
|
||||||
|
local: false,
|
||||||
|
sizeInBytes: treeItem.size,
|
||||||
|
checksum: treeItem.sha,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
slug: this.slug,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GitHubApiResponse {
|
||||||
|
sha: string;
|
||||||
|
url: string;
|
||||||
|
tree: TreeItem[];
|
||||||
|
truncated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TreeItem {
|
||||||
|
path: string;
|
||||||
|
mode: string;
|
||||||
|
sha: string;
|
||||||
|
url: string;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
38
packages/icons/src/repositories/icon-repository.ts
Normal file
38
packages/icons/src/repositories/icon-repository.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { logger } from "@homarr/log";
|
||||||
|
|
||||||
|
import type { IconRepositoryLicense } from "../types/icon-repository-license";
|
||||||
|
import type { RepositoryIconGroup } from "../types/repository-icon-group";
|
||||||
|
|
||||||
|
export abstract class IconRepository {
|
||||||
|
protected readonly allowedImageFileTypes = [".png", ".svg", ".jpeg"];
|
||||||
|
|
||||||
|
protected constructor(
|
||||||
|
public readonly name: string,
|
||||||
|
public readonly slug: string,
|
||||||
|
public readonly license: IconRepositoryLicense,
|
||||||
|
public readonly repositoryUrl?: URL,
|
||||||
|
public readonly repositoryIndexingUrl?: URL,
|
||||||
|
public readonly repositoryBlobUrlTemplate?: string,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async getAllIconsAsync(): Promise<RepositoryIconGroup> {
|
||||||
|
try {
|
||||||
|
return await this.getAllIconsInternalAsync();
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(
|
||||||
|
`Unable to request icons from repository "${this.slug}": ${JSON.stringify(err)}`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
icons: [],
|
||||||
|
slug: this.slug,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract getAllIconsInternalAsync(): Promise<RepositoryIconGroup>;
|
||||||
|
|
||||||
|
protected getFileNameWithoutExtensionFromPath(path: string) {
|
||||||
|
return path.replace(/^.*[\\/]/, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
63
packages/icons/src/repositories/jsdelivr.icon-repository.ts
Normal file
63
packages/icons/src/repositories/jsdelivr.icon-repository.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import type { IconRepositoryLicense } from "../types/icon-repository-license";
|
||||||
|
import type { RepositoryIconGroup } from "../types/repository-icon-group";
|
||||||
|
import { IconRepository } from "./icon-repository";
|
||||||
|
|
||||||
|
export class JsdelivrIconRepository extends IconRepository {
|
||||||
|
constructor(
|
||||||
|
public readonly name: string,
|
||||||
|
public readonly slug: string,
|
||||||
|
public readonly license: IconRepositoryLicense,
|
||||||
|
public readonly repositoryUrl: URL,
|
||||||
|
public readonly repositoryIndexingUrl: URL,
|
||||||
|
public readonly repositoryBlobUrlTemplate: string,
|
||||||
|
) {
|
||||||
|
super(
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
license,
|
||||||
|
repositoryUrl,
|
||||||
|
repositoryIndexingUrl,
|
||||||
|
repositoryBlobUrlTemplate,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async getAllIconsInternalAsync(): Promise<RepositoryIconGroup> {
|
||||||
|
const response = await fetch(this.repositoryIndexingUrl);
|
||||||
|
const listOfFiles = (await response.json()) as JsdelivrApiResponse;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
icons: listOfFiles.files
|
||||||
|
.filter((file) =>
|
||||||
|
this.allowedImageFileTypes.some((allowedImageFileType) =>
|
||||||
|
file.name.includes(allowedImageFileType),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map((file) => {
|
||||||
|
const fileNameWithExtension =
|
||||||
|
this.getFileNameWithoutExtensionFromPath(file.name);
|
||||||
|
|
||||||
|
return {
|
||||||
|
imageUrl: new URL(
|
||||||
|
this.repositoryBlobUrlTemplate.replace("{0}", file.name),
|
||||||
|
),
|
||||||
|
fileNameWithExtension: fileNameWithExtension,
|
||||||
|
local: false,
|
||||||
|
sizeInBytes: file.size,
|
||||||
|
checksum: file.hash,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
slug: this.slug,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JsdelivrApiResponse {
|
||||||
|
files: JsdelivrFile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JsdelivrFile {
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
hash: string;
|
||||||
|
}
|
||||||
1
packages/icons/src/types/icon-repository-license.ts
Normal file
1
packages/icons/src/types/icon-repository-license.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export type IconRepositoryLicense = "MIT" | "GPL-3.0" | undefined;
|
||||||
3
packages/icons/src/types/index.ts
Normal file
3
packages/icons/src/types/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./icon-repository-license";
|
||||||
|
export * from "./repository-icon-group";
|
||||||
|
export * from "./repository-icon";
|
||||||
7
packages/icons/src/types/repository-icon-group.ts
Normal file
7
packages/icons/src/types/repository-icon-group.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { RepositoryIcon } from "./repository-icon";
|
||||||
|
|
||||||
|
export interface RepositoryIconGroup {
|
||||||
|
icons: RepositoryIcon[];
|
||||||
|
success: boolean;
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
7
packages/icons/src/types/repository-icon.ts
Normal file
7
packages/icons/src/types/repository-icon.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export interface RepositoryIcon {
|
||||||
|
fileNameWithExtension: string;
|
||||||
|
sizeInBytes?: number;
|
||||||
|
imageUrl: URL;
|
||||||
|
local: boolean;
|
||||||
|
checksum: string;
|
||||||
|
}
|
||||||
8
packages/icons/tsconfig.json
Normal file
8
packages/icons/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "@homarr/tsconfig/base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||||
|
},
|
||||||
|
"include": ["*.ts", "src"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -360,6 +360,10 @@ export default {
|
|||||||
next: "Next",
|
next: "Next",
|
||||||
checkoutDocs: "Check out the documentation",
|
checkoutDocs: "Check out the documentation",
|
||||||
},
|
},
|
||||||
|
iconPicker: {
|
||||||
|
header:
|
||||||
|
"Type name or objects to filter for icons... Homarr will search through {countIcons} icons for you.",
|
||||||
|
},
|
||||||
notification: {
|
notification: {
|
||||||
create: {
|
create: {
|
||||||
success: "Creation successful",
|
success: "Creation successful",
|
||||||
|
|||||||
9
packages/validation/src/icons.ts
Normal file
9
packages/validation/src/icons.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const findIconsSchema = z.object({
|
||||||
|
searchText: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const iconsSchemas = {
|
||||||
|
findIcons: findIconsSchema,
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { appSchemas } from "./app";
|
import { appSchemas } from "./app";
|
||||||
import { boardSchemas } from "./board";
|
import { boardSchemas } from "./board";
|
||||||
import { groupSchemas } from "./group";
|
import { groupSchemas } from "./group";
|
||||||
|
import { iconsSchemas } from "./icons";
|
||||||
import { integrationSchemas } from "./integration";
|
import { integrationSchemas } from "./integration";
|
||||||
import { locationSchemas } from "./location";
|
import { locationSchemas } from "./location";
|
||||||
import { userSchemas } from "./user";
|
import { userSchemas } from "./user";
|
||||||
@@ -14,6 +15,7 @@ export const validation = {
|
|||||||
app: appSchemas,
|
app: appSchemas,
|
||||||
widget: widgetSchemas,
|
widget: widgetSchemas,
|
||||||
location: locationSchemas,
|
location: locationSchemas,
|
||||||
|
icons: iconsSchemas,
|
||||||
};
|
};
|
||||||
|
|
||||||
export { createSectionSchema, sharedItemSchema } from "./shared";
|
export { createSectionSchema, sharedItemSchema } from "./shared";
|
||||||
|
|||||||
25
pnpm-lock.yaml
generated
25
pnpm-lock.yaml
generated
@@ -235,6 +235,9 @@ importers:
|
|||||||
'@homarr/definitions':
|
'@homarr/definitions':
|
||||||
specifier: workspace:^0.1.0
|
specifier: workspace:^0.1.0
|
||||||
version: link:../../packages/definitions
|
version: link:../../packages/definitions
|
||||||
|
'@homarr/icons':
|
||||||
|
specifier: workspace:^0.1.0
|
||||||
|
version: link:../../packages/icons
|
||||||
'@homarr/log':
|
'@homarr/log':
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
version: link:../../packages/log
|
version: link:../../packages/log
|
||||||
@@ -567,6 +570,28 @@ importers:
|
|||||||
specifier: ^5.4.5
|
specifier: ^5.4.5
|
||||||
version: 5.4.5
|
version: 5.4.5
|
||||||
|
|
||||||
|
packages/icons:
|
||||||
|
dependencies:
|
||||||
|
'@homarr/log':
|
||||||
|
specifier: workspace:^0.1.0
|
||||||
|
version: link:../log
|
||||||
|
devDependencies:
|
||||||
|
'@homarr/eslint-config':
|
||||||
|
specifier: workspace:^0.2.0
|
||||||
|
version: link:../../tooling/eslint
|
||||||
|
'@homarr/prettier-config':
|
||||||
|
specifier: workspace:^0.1.0
|
||||||
|
version: link:../../tooling/prettier
|
||||||
|
'@homarr/tsconfig':
|
||||||
|
specifier: workspace:^0.1.0
|
||||||
|
version: link:../../tooling/typescript
|
||||||
|
eslint:
|
||||||
|
specifier: ^8.57.0
|
||||||
|
version: 8.57.0
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.4.5
|
||||||
|
version: 5.4.5
|
||||||
|
|
||||||
packages/log:
|
packages/log:
|
||||||
dependencies:
|
dependencies:
|
||||||
ioredis:
|
ioredis:
|
||||||
|
|||||||
@@ -4,6 +4,6 @@
|
|||||||
"format": "prettier --check . --ignore-path ../../.gitignore", "typecheck": "tsc
|
"format": "prettier --check . --ignore-path ../../.gitignore", "typecheck": "tsc
|
||||||
--noEmit" }, "devDependencies": { "@homarr/eslint-config": "workspace:^0.2.0",
|
--noEmit" }, "devDependencies": { "@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/tsconfig":
|
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/tsconfig":
|
||||||
"workspace:^0.1.0", "eslint": "^8.57.0", "typescript": "^5.4.2" },
|
"workspace:^0.1.0", "eslint": "^8.57.0", "typescript": "^5.4.5" },
|
||||||
"eslintConfig": { "extends": [ "@homarr/eslint-config/base" ] }, "prettier":
|
"eslintConfig": { "extends": [ "@homarr/eslint-config/base" ] }, "prettier":
|
||||||
"@homarr/prettier-config" }
|
"@homarr/prettier-config" }
|
||||||
Reference in New Issue
Block a user