Replace entire codebase with homarr-labs/homarr
This commit is contained in:
9
packages/modals-collection/eslint.config.js
Normal file
9
packages/modals-collection/eslint.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import baseConfig from "@homarr/eslint-config/base";
|
||||
|
||||
/** @type {import('typescript-eslint').Config} */
|
||||
export default [
|
||||
{
|
||||
ignores: [],
|
||||
},
|
||||
...baseConfig,
|
||||
];
|
||||
1
packages/modals-collection/index.ts
Normal file
1
packages/modals-collection/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./src";
|
||||
51
packages/modals-collection/package.json
Normal file
51
packages/modals-collection/package.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@homarr/modals-collection",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./index.ts"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
"lint": "eslint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@homarr/api": "workspace:^0.1.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/form": "workspace:^0.1.0",
|
||||
"@homarr/forms-collection": "workspace:^0.1.0",
|
||||
"@homarr/modals": "workspace:^0.1.0",
|
||||
"@homarr/notifications": "workspace:^0.1.0",
|
||||
"@homarr/old-import": "workspace:^0.1.0",
|
||||
"@homarr/old-schema": "workspace:^0.1.0",
|
||||
"@homarr/translation": "workspace:^0.1.0",
|
||||
"@homarr/ui": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@mantine/core": "^8.3.10",
|
||||
"@tabler/icons-react": "^3.36.1",
|
||||
"dayjs": "^1.11.19",
|
||||
"next": "16.1.1",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"zod": "^4.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.39.2",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
124
packages/modals-collection/src/apps/app-select-modal.tsx
Normal file
124
packages/modals-collection/src/apps/app-select-modal.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button, Card, Center, Grid, Input, Stack, Text } from "@mantine/core";
|
||||
import { IconPlus, IconSearch } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { createModal, useModalAction } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { QuickAddAppModal } from "./quick-add-app/quick-add-app-modal";
|
||||
|
||||
interface AppSelectModalProps {
|
||||
onSelect?: (app: RouterOutputs["app"]["selectable"][number]) => void;
|
||||
withCreate: boolean;
|
||||
}
|
||||
|
||||
export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, innerProps }) => {
|
||||
const [search, setSearch] = useState("");
|
||||
const t = useI18n();
|
||||
const { data: apps = [], isPending } = clientApi.app.selectable.useQuery();
|
||||
const { openModal: openQuickAddAppModal } = useModalAction(QuickAddAppModal);
|
||||
|
||||
const filteredApps = useMemo(
|
||||
() =>
|
||||
apps
|
||||
.filter((app) => app.name.toLowerCase().includes(search.toLowerCase()))
|
||||
.sort((appA, appB) => appA.name.localeCompare(appB.name)),
|
||||
[apps, search],
|
||||
);
|
||||
|
||||
const handleSelect = (app: RouterOutputs["app"]["selectable"][number]) => {
|
||||
if (innerProps.onSelect) {
|
||||
innerProps.onSelect(app);
|
||||
}
|
||||
actions.closeModal();
|
||||
};
|
||||
|
||||
const handleAddNewApp = () => {
|
||||
openQuickAddAppModal({
|
||||
onClose(app) {
|
||||
if (innerProps.onSelect) {
|
||||
innerProps.onSelect(app);
|
||||
}
|
||||
actions.closeModal();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||
leftSection={<IconSearch />}
|
||||
placeholder={`${t("app.action.select.search")}...`}
|
||||
data-autofocus
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && filteredApps.length === 1 && filteredApps[0]) {
|
||||
handleSelect(filteredApps[0]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Grid>
|
||||
{innerProps.withCreate && (
|
||||
<Grid.Col span={{ xs: 12, sm: 4, md: 3 }}>
|
||||
<Card h="100%">
|
||||
<Stack justify="space-between" h="100%">
|
||||
<Stack gap="xs">
|
||||
<Center>
|
||||
<IconPlus size={24} />
|
||||
</Center>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} ta="center">
|
||||
{t("app.action.create.title")}
|
||||
</Text>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" ta="center" c="dimmed">
|
||||
{t("app.action.create.description")}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Button onClick={handleAddNewApp} variant="light" size="xs" mt="auto" radius="md" fullWidth>
|
||||
{t("app.action.create.action")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
)}
|
||||
|
||||
{filteredApps.map((app) => (
|
||||
<Grid.Col key={app.id} span={{ xs: 12, sm: 4, md: 3 }}>
|
||||
<Card h="100%">
|
||||
<Stack justify="space-between" h="100%">
|
||||
<Stack gap="xs">
|
||||
<Center>
|
||||
<img src={app.iconUrl} alt={app.name} width={24} height={24} />
|
||||
</Center>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} ta="center">
|
||||
{app.name}
|
||||
</Text>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" ta="center" c="dimmed">
|
||||
{app.description ?? ""}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Button onClick={() => handleSelect(app)} variant="light" size="xs" mt="auto" radius="md" fullWidth>
|
||||
{t("app.action.select.action", { app: app.name })}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
))}
|
||||
|
||||
{filteredApps.length === 0 && !isPending && (
|
||||
<Grid.Col span={12}>
|
||||
<Center p="xl">
|
||||
<Text c="dimmed">{t("app.action.select.noResults")}</Text>
|
||||
</Center>
|
||||
</Grid.Col>
|
||||
)}
|
||||
</Grid>
|
||||
</Stack>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle: (t) => t("app.action.select.title"),
|
||||
size: "xl",
|
||||
});
|
||||
2
packages/modals-collection/src/apps/index.ts
Normal file
2
packages/modals-collection/src/apps/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { AppSelectModal } from "./app-select-modal";
|
||||
export { QuickAddAppModal } from "./quick-add-app/quick-add-app-modal";
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { AppForm } from "@homarr/forms-collection";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import type { appManageSchema } from "@homarr/validation/app";
|
||||
|
||||
interface QuickAddAppModalProps {
|
||||
onClose: (createdApp: Omit<RouterOutputs["app"]["create"], "appId">) => void;
|
||||
}
|
||||
|
||||
export const QuickAddAppModal = createModal<QuickAddAppModalProps>(({ actions, innerProps }) => {
|
||||
const tScoped = useScopedI18n("app.page.create.notification");
|
||||
const t = useI18n();
|
||||
|
||||
const { mutate, isPending } = clientApi.app.create.useMutation({
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: tScoped("error.title"),
|
||||
message: tScoped("error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (values: z.infer<typeof appManageSchema>) => {
|
||||
mutate(values, {
|
||||
onSuccess(app) {
|
||||
showSuccessNotification({
|
||||
title: tScoped("success.title"),
|
||||
message: tScoped("success.message"),
|
||||
});
|
||||
|
||||
innerProps.onClose(app);
|
||||
actions.closeModal();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AppForm
|
||||
buttonLabels={{
|
||||
submit: t("board.action.quickCreateApp.modal.createAndUse"),
|
||||
submitAndCreateAnother: undefined,
|
||||
}}
|
||||
showBackToOverview={false}
|
||||
handleSubmit={handleSubmit}
|
||||
isPending={isPending}
|
||||
/>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle(t) {
|
||||
return t("board.action.quickCreateApp.modal.title");
|
||||
},
|
||||
});
|
||||
127
packages/modals-collection/src/boards/add-board-modal.tsx
Normal file
127
packages/modals-collection/src/boards/add-board-modal.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { Button, Group, InputWrapper, Slider, Stack, Switch, TextInput } from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { IconAlertTriangle, IconCircleCheck } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { boardColumnCountSchema, boardCreateSchema, boardNameSchema } from "@homarr/validation/board";
|
||||
|
||||
export const AddBoardModal = createModal(({ actions }) => {
|
||||
const t = useI18n();
|
||||
const form = useZodForm(boardCreateSchema, {
|
||||
mode: "controlled",
|
||||
initialValues: {
|
||||
name: "",
|
||||
columnCount: 10,
|
||||
isPublic: false,
|
||||
},
|
||||
});
|
||||
const { mutate, isPending } = clientApi.board.createBoard.useMutation({
|
||||
onSettled: async () => {
|
||||
await revalidatePathActionAsync("/manage/boards");
|
||||
},
|
||||
});
|
||||
|
||||
const boardNameStatus = useBoardNameStatus(form.values.name);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
// Prevent submit before name availability check
|
||||
if (!boardNameStatus.canSubmit) return;
|
||||
mutate(values, {
|
||||
onSuccess: () => {
|
||||
actions.closeModal();
|
||||
showSuccessNotification({
|
||||
title: "Board created",
|
||||
message: `Board ${values.name} has been created`,
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: "Failed to create board",
|
||||
message: `Board ${values.name} could not be created`,
|
||||
});
|
||||
},
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<TextInput
|
||||
label={t("board.field.name.label")}
|
||||
data-autofocus
|
||||
{...form.getInputProps("name")}
|
||||
description={
|
||||
boardNameStatus.description ? (
|
||||
<Group c={boardNameStatus.description.color} gap="xs" align="center">
|
||||
{boardNameStatus.description.icon ? <boardNameStatus.description.icon size={16} /> : null}
|
||||
<span>{boardNameStatus.description.label}</span>
|
||||
</Group>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<InputWrapper label={t("board.field.columnCount.label")} {...form.getInputProps("columnCount")}>
|
||||
<Slider
|
||||
min={boardColumnCountSchema.minValue ?? undefined}
|
||||
max={boardColumnCountSchema.maxValue ?? undefined}
|
||||
step={1}
|
||||
{...form.getInputProps("columnCount")}
|
||||
/>
|
||||
</InputWrapper>
|
||||
|
||||
<Switch
|
||||
label={t("board.field.isPublic.label")}
|
||||
description={t("board.field.isPublic.description")}
|
||||
{...form.getInputProps("isPublic")}
|
||||
/>
|
||||
|
||||
<Group justify="right">
|
||||
<Button onClick={actions.closeModal} variant="subtle" color="gray">
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.create")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle: (t) => t("management.page.board.action.new.label"),
|
||||
});
|
||||
|
||||
export const useBoardNameStatus = (name: string) => {
|
||||
const t = useI18n();
|
||||
const [debouncedName] = useDebouncedValue(name, 250);
|
||||
const { data: boardExists, isLoading } = clientApi.board.exists.useQuery(debouncedName, {
|
||||
enabled: boardNameSchema.safeParse(debouncedName).success,
|
||||
});
|
||||
|
||||
return {
|
||||
canSubmit: !boardExists && !isLoading,
|
||||
description:
|
||||
debouncedName.trim() === ""
|
||||
? undefined
|
||||
: isLoading
|
||||
? {
|
||||
label: "Checking availability...",
|
||||
}
|
||||
: boardExists === undefined
|
||||
? undefined
|
||||
: boardExists
|
||||
? {
|
||||
icon: IconAlertTriangle,
|
||||
label: t("common.zod.errors.custom.boardAlreadyExists"), // The board ${debouncedName} already exists
|
||||
color: "red",
|
||||
}
|
||||
: {
|
||||
icon: IconCircleCheck,
|
||||
label: `${debouncedName} is available`,
|
||||
color: "green",
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Button, Group, Stack, Text, TextInput } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { boardDuplicateSchema } from "@homarr/validation/board";
|
||||
|
||||
import { createModal } from "../../../modals/src/creator";
|
||||
import { useBoardNameStatus } from "./add-board-modal";
|
||||
|
||||
interface InnerProps {
|
||||
board: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const DuplicateBoardModal = createModal<InnerProps>(({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const form = useZodForm(boardDuplicateSchema.omit({ id: true }), {
|
||||
mode: "controlled",
|
||||
initialValues: {
|
||||
name: innerProps.board.name,
|
||||
},
|
||||
});
|
||||
const boardNameStatus = useBoardNameStatus(form.values.name);
|
||||
const { mutateAsync, isPending } = clientApi.board.duplicateBoard.useMutation({
|
||||
async onSuccess() {
|
||||
await revalidatePathActionAsync("/manage/boards");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit(async (values) => {
|
||||
// Prevent submit before name availability check
|
||||
if (!boardNameStatus.canSubmit) return;
|
||||
await mutateAsync(
|
||||
{
|
||||
...values,
|
||||
id: innerProps.board.id,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
actions.closeModal();
|
||||
showSuccessNotification({
|
||||
title: t("board.action.duplicate.notification.success.title"),
|
||||
message: t("board.action.duplicate.notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t("board.action.duplicate.notification.error.title"),
|
||||
message: t("board.action.duplicate.notification.error.message"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<Text size="sm" c="gray.6">
|
||||
{t("board.action.duplicate.message", { name: innerProps.board.name })}
|
||||
</Text>
|
||||
|
||||
<TextInput
|
||||
label={t("board.field.name.label")}
|
||||
data-autofocus
|
||||
{...form.getInputProps("name")}
|
||||
description={
|
||||
boardNameStatus.description ? (
|
||||
<Group c={boardNameStatus.description.color} gap="xs" align="center">
|
||||
{boardNameStatus.description.icon ? <boardNameStatus.description.icon size={16} /> : null}
|
||||
<span>{boardNameStatus.description.label}</span>
|
||||
</Group>
|
||||
) : null
|
||||
}
|
||||
withAsterisk
|
||||
/>
|
||||
|
||||
<Group justify="end">
|
||||
<Button variant="subtle" color="gray" onClick={actions.closeModal}>
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.create")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle(t) {
|
||||
return t("board.action.duplicate.title");
|
||||
},
|
||||
});
|
||||
156
packages/modals-collection/src/boards/import-board-modal.tsx
Normal file
156
packages/modals-collection/src/boards/import-board-modal.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { useState } from "react";
|
||||
import { Button, FileInput, Group, Stack, TextInput } from "@mantine/core";
|
||||
import { IconFileUpload } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { OldmarrImportAppsSettings, SidebarBehaviourSelect } from "@homarr/old-import/components";
|
||||
import type { OldmarrImportConfiguration } from "@homarr/old-import/shared";
|
||||
import { checkJsonImportFile, oldmarrImportConfigurationSchema } from "@homarr/old-import/shared";
|
||||
import { oldmarrConfigSchema } from "@homarr/old-schema";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useBoardNameStatus } from "./add-board-modal";
|
||||
|
||||
export const ImportBoardModal = createModal(({ actions }) => {
|
||||
const tOldImport = useScopedI18n("board.action.oldImport");
|
||||
const tCommon = useScopedI18n("common");
|
||||
const [fileValid, setFileValid] = useState(true);
|
||||
const form = useZodForm(
|
||||
z.object({
|
||||
file: z.file().check(checkJsonImportFile),
|
||||
configuration: oldmarrImportConfigurationSchema,
|
||||
}),
|
||||
{
|
||||
mode: "controlled",
|
||||
initialValues: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
file: null!,
|
||||
configuration: {
|
||||
onlyImportApps: false,
|
||||
sidebarBehaviour: "last-section",
|
||||
name: "",
|
||||
},
|
||||
},
|
||||
onValuesChange(values, previous) {
|
||||
// This is a workarround until async validation is supported by mantine
|
||||
void (async () => {
|
||||
if (values.file === previous.file) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Before validation it can still be null
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!values.file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await values.file.text();
|
||||
const result = oldmarrConfigSchema.safeParse(JSON.parse(content));
|
||||
|
||||
if (!result.success) {
|
||||
console.error(result.error.issues);
|
||||
setFileValid(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setFileValid(true);
|
||||
form.setFieldValue("configuration.name", result.data.configProperties.name.replaceAll(" ", "-"));
|
||||
})();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const { mutateAsync, isPending } = clientApi.board.importOldmarrConfig.useMutation({
|
||||
async onSuccess() {
|
||||
await revalidatePathActionAsync("/manage/boards");
|
||||
},
|
||||
});
|
||||
const boardNameStatus = useBoardNameStatus(form.values.configuration.name);
|
||||
|
||||
const handleSubmitAsync = async (values: { file: File; configuration: OldmarrImportConfiguration }) => {
|
||||
const formData = new FormData();
|
||||
formData.set("file", values.file);
|
||||
formData.set("configuration", JSON.stringify(values.configuration));
|
||||
|
||||
await mutateAsync(formData, {
|
||||
onSuccess() {
|
||||
actions.closeModal();
|
||||
showSuccessNotification({
|
||||
title: tOldImport("notification.success.title"),
|
||||
message: tOldImport("notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: tOldImport("notification.error.title"),
|
||||
message: tOldImport("notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
if (!fileValid || !boardNameStatus.canSubmit) {
|
||||
return;
|
||||
}
|
||||
|
||||
void handleSubmitAsync({
|
||||
file: values.file,
|
||||
configuration: values.configuration,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<FileInput
|
||||
rightSection={<IconFileUpload />}
|
||||
withAsterisk
|
||||
accept="application/json"
|
||||
{...form.getInputProps("file")}
|
||||
error={
|
||||
(form.getInputProps("file").error as string | undefined) ??
|
||||
(!fileValid && form.isDirty("file") ? tOldImport("form.file.invalidError") : undefined)
|
||||
}
|
||||
type="button"
|
||||
label={tOldImport("form.file.label")}
|
||||
/>
|
||||
|
||||
<OldmarrImportAppsSettings onlyImportApps={form.getInputProps("configuration.onlyImportApps")} />
|
||||
|
||||
<TextInput
|
||||
withAsterisk
|
||||
label={tOldImport("form.name.label")}
|
||||
description={
|
||||
boardNameStatus.description ? (
|
||||
<Group c={boardNameStatus.description.color} gap="xs" align="center">
|
||||
{boardNameStatus.description.icon ? <boardNameStatus.description.icon size={16} /> : null}
|
||||
<span>{boardNameStatus.description.label}</span>
|
||||
</Group>
|
||||
) : null
|
||||
}
|
||||
{...form.getInputProps("configuration.name")}
|
||||
/>
|
||||
|
||||
<SidebarBehaviourSelect {...form.getInputProps("configuration.sidebarBehaviour")} />
|
||||
|
||||
<Group justify="end">
|
||||
<Button variant="subtle" color="gray" onClick={actions.closeModal}>
|
||||
{tCommon("action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isPending}>
|
||||
{tCommon("action.import")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle: (t) => t("board.action.oldImport.label"),
|
||||
size: "lg",
|
||||
});
|
||||
3
packages/modals-collection/src/boards/index.ts
Normal file
3
packages/modals-collection/src/boards/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { AddBoardModal } from "./add-board-modal";
|
||||
export { ImportBoardModal } from "./import-board-modal";
|
||||
export { DuplicateBoardModal } from "./duplicate-board-modal";
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Button, FileInput, Group, Stack } from "@mantine/core";
|
||||
import { IconCertificate } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { MaybePromise } from "@homarr/common/types";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { checkCertificateFile } from "@homarr/validation/certificates";
|
||||
|
||||
interface InnerProps {
|
||||
onSuccess?: () => MaybePromise<void>;
|
||||
}
|
||||
|
||||
export const AddCertificateModal = createModal<InnerProps>(({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const form = useZodForm(
|
||||
z.object({
|
||||
file: z.file().check(checkCertificateFile),
|
||||
}),
|
||||
{
|
||||
initialValues: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
file: null!,
|
||||
},
|
||||
},
|
||||
);
|
||||
const { mutateAsync } = clientApi.certificates.addCertificate.useMutation({
|
||||
async onSuccess() {
|
||||
await innerProps.onSuccess?.();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit(async (values) => {
|
||||
const formData = new FormData();
|
||||
formData.set("file", values.file);
|
||||
await mutateAsync(formData, {
|
||||
onSuccess() {
|
||||
showSuccessNotification({
|
||||
title: t("certificate.action.create.notification.success.title"),
|
||||
message: t("certificate.action.create.notification.success.message"),
|
||||
});
|
||||
actions.closeModal();
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t("certificate.action.create.notification.error.title"),
|
||||
message: t("certificate.action.create.notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<FileInput leftSection={<IconCertificate size={16} />} {...form.getInputProps("file")} />
|
||||
<Group justify="end">
|
||||
<Button onClick={actions.closeModal} variant="subtle" color="gray">
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={form.submitting}>
|
||||
{t("common.action.add")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle(t) {
|
||||
return t("certificate.action.create.label");
|
||||
},
|
||||
});
|
||||
1
packages/modals-collection/src/certificates/index.ts
Normal file
1
packages/modals-collection/src/certificates/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./add-certificate-modal";
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Avatar, Button, Group, List, LoadingOverlay, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface AddDockerAppToHomarrProps {
|
||||
selectedContainers: RouterOutputs["docker"]["getContainers"]["containers"];
|
||||
}
|
||||
|
||||
export const AddDockerAppToHomarrModal = createModal<AddDockerAppToHomarrProps>(({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const form = useZodForm(
|
||||
z.object({
|
||||
containerUrls: z.array(z.string().url().nullable()),
|
||||
}),
|
||||
{
|
||||
initialValues: {
|
||||
containerUrls: innerProps.selectedContainers.map((container) => {
|
||||
if (container.ports[0]) {
|
||||
return `http://${container.ports[0].IP}:${container.ports[0].PublicPort}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}),
|
||||
},
|
||||
},
|
||||
);
|
||||
const { mutate, isPending } = clientApi.app.createMany.useMutation({
|
||||
onSuccess() {
|
||||
actions.closeModal();
|
||||
showSuccessNotification({
|
||||
title: t("docker.action.addToHomarr.notification.success.title"),
|
||||
message: t("docker.action.addToHomarr.notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t("docker.action.addToHomarr.notification.error.title"),
|
||||
message: t("docker.action.addToHomarr.notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
const handleSubmit = () => {
|
||||
mutate(
|
||||
innerProps.selectedContainers.map((container, index) => ({
|
||||
name: container.name,
|
||||
iconUrl: container.iconUrl,
|
||||
description: null,
|
||||
href: form.values.containerUrls[index] ?? null,
|
||||
pingUrl: null,
|
||||
})),
|
||||
);
|
||||
};
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<LoadingOverlay visible={isPending} zIndex={1000} overlayProps={{ radius: "sm", blur: 2 }} />
|
||||
<Stack>
|
||||
<List spacing={"xs"}>
|
||||
{innerProps.selectedContainers.map((container, index) => (
|
||||
<List.Item
|
||||
styles={{ itemWrapper: { width: "100%" }, itemLabel: { flex: 1 } }}
|
||||
icon={
|
||||
<Avatar
|
||||
variant="outline"
|
||||
radius={container.iconUrl ? "sm" : "md"}
|
||||
size={30}
|
||||
styles={{ image: { objectFit: "contain" } }}
|
||||
src={container.iconUrl}
|
||||
>
|
||||
{container.name.at(0)?.toUpperCase()}
|
||||
</Avatar>
|
||||
}
|
||||
key={container.id}
|
||||
>
|
||||
<Group justify="space-between" wrap={"nowrap"}>
|
||||
<Text lineClamp={1}>{container.name}</Text>
|
||||
<TextInput {...form.getInputProps(`containerUrls.${index}`)} />
|
||||
</Group>
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
<Group justify="end">
|
||||
<Button onClick={actions.closeModal} variant="light" px={"xl"}>
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" px={"xl"}>
|
||||
{t("common.action.add")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle(t) {
|
||||
return t("docker.action.addToHomarr.modal.title");
|
||||
},
|
||||
size: "lg",
|
||||
});
|
||||
1
packages/modals-collection/src/docker/index.ts
Normal file
1
packages/modals-collection/src/docker/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { AddDockerAppToHomarrModal as AddDockerAppToHomarr } from "./add-docker-app-to-homarr";
|
||||
56
packages/modals-collection/src/groups/add-group-modal.tsx
Normal file
56
packages/modals-collection/src/groups/add-group-modal.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Button, Group, Stack, TextInput } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { groupCreateSchema } from "@homarr/validation/group";
|
||||
|
||||
export const AddGroupModal = createModal<void>(({ actions }) => {
|
||||
const t = useI18n();
|
||||
const { mutate, isPending } = clientApi.group.createGroup.useMutation();
|
||||
const form = useZodForm(groupCreateSchema, {
|
||||
initialValues: {
|
||||
name: "",
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
mutate(values, {
|
||||
onSuccess() {
|
||||
actions.closeModal();
|
||||
void revalidatePathActionAsync("/manage/users/groups");
|
||||
showSuccessNotification({
|
||||
title: t("common.notification.create.success"),
|
||||
message: t("group.action.create.notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t("common.notification.create.error"),
|
||||
message: t("group.action.create.notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<TextInput label={t("group.field.name")} data-autofocus {...form.getInputProps("name")} />
|
||||
<Group justify="right">
|
||||
<Button onClick={actions.closeModal} variant="subtle" color="gray">
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button loading={isPending} type="submit">
|
||||
{t("common.action.create")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle: (t) => t("group.action.create.label"),
|
||||
});
|
||||
1
packages/modals-collection/src/groups/index.ts
Normal file
1
packages/modals-collection/src/groups/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { AddGroupModal } from "./add-group-modal";
|
||||
7
packages/modals-collection/src/index.ts
Normal file
7
packages/modals-collection/src/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from "./boards";
|
||||
export * from "./invites";
|
||||
export * from "./groups";
|
||||
export * from "./search-engines";
|
||||
export * from "./docker";
|
||||
export * from "./apps";
|
||||
export * from "./certificates";
|
||||
2
packages/modals-collection/src/invites/index.ts
Normal file
2
packages/modals-collection/src/invites/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { InviteCopyModal } from "./invite-copy-modal";
|
||||
export { InviteCreateModal } from "./invite-create-modal";
|
||||
60
packages/modals-collection/src/invites/invite-copy-modal.tsx
Normal file
60
packages/modals-collection/src/invites/invite-copy-modal.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Button, CopyButton, Mark, Stack, Text } from "@mantine/core";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
export const InviteCopyModal = createModal<RouterOutputs["invite"]["createInvite"]>(({ actions, innerProps }) => {
|
||||
const t = useScopedI18n("management.page.user.invite");
|
||||
const inviteUrl = useInviteUrl(innerProps);
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Text>
|
||||
{t.rich("action.copy.description", {
|
||||
b: (children) => <b>{children}</b>,
|
||||
})}
|
||||
</Text>
|
||||
<Link href={createPath(innerProps)}>{t("action.copy.link")}</Link>
|
||||
<Stack gap="xs">
|
||||
<Text fw="bold">{t("field.id.label")}:</Text>
|
||||
<Mark style={{ borderRadius: 4 }} color="gray" px={5}>
|
||||
{innerProps.id}
|
||||
</Mark>
|
||||
|
||||
<Text fw="bold">{t("field.token.label")}:</Text>
|
||||
<Mark style={{ borderRadius: 4 }} color="gray" px={5}>
|
||||
{innerProps.token}
|
||||
</Mark>
|
||||
</Stack>
|
||||
<CopyButton value={inviteUrl}>
|
||||
{({ copy }) => (
|
||||
<Button
|
||||
onClick={() => {
|
||||
copy();
|
||||
actions.closeModal();
|
||||
}}
|
||||
variant="default"
|
||||
fullWidth
|
||||
>
|
||||
{t("action.copy.button")}
|
||||
</Button>
|
||||
)}
|
||||
</CopyButton>
|
||||
</Stack>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle(t) {
|
||||
return t("management.page.user.invite.action.copy.title");
|
||||
},
|
||||
});
|
||||
|
||||
const createPath = ({ id, token }: RouterOutputs["invite"]["createInvite"]) => `/auth/invite/${id}?token=${token}`;
|
||||
|
||||
const useInviteUrl = ({ id, token }: RouterOutputs["invite"]["createInvite"]) => {
|
||||
const pathname = usePathname();
|
||||
|
||||
return window.location.href.replace(pathname, createPath({ id, token }));
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Button, Group, Stack, Text } from "@mantine/core";
|
||||
import { DateTimePicker } from "@mantine/dates";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useForm } from "@homarr/form";
|
||||
import { createModal, useModalAction } from "@homarr/modals";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { InviteCopyModal } from "./invite-copy-modal";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface FormType {
|
||||
expirationDate: string;
|
||||
}
|
||||
|
||||
export const InviteCreateModal = createModal<void>(({ actions }) => {
|
||||
const tInvite = useScopedI18n("management.page.user.invite");
|
||||
const t = useI18n();
|
||||
const { openModal } = useModalAction(InviteCopyModal);
|
||||
|
||||
const utils = clientApi.useUtils();
|
||||
const { mutate, isPending } = clientApi.invite.createInvite.useMutation();
|
||||
const minDate = dayjs().add(1, "hour").toDate();
|
||||
const maxDate = dayjs().add(6, "months").toDate();
|
||||
|
||||
const form = useForm<FormType>({
|
||||
initialValues: {
|
||||
expirationDate: dayjs().add(4, "hours").toDate().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (values: FormType) => {
|
||||
mutate(
|
||||
{
|
||||
expirationDate: new Date(values.expirationDate),
|
||||
},
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
void utils.invite.getAll.invalidate();
|
||||
actions.closeModal();
|
||||
openModal(result);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack>
|
||||
<Text>{tInvite("action.new.description")}</Text>
|
||||
|
||||
<DateTimePicker
|
||||
popoverProps={{ withinPortal: true }}
|
||||
minDate={minDate}
|
||||
maxDate={maxDate}
|
||||
withAsterisk
|
||||
valueFormat="DD MMM YYYY HH:mm"
|
||||
label={tInvite("field.expirationDate.label")}
|
||||
variant="filled"
|
||||
{...form.getInputProps("expirationDate")}
|
||||
/>
|
||||
|
||||
<Group justify="end">
|
||||
<Button onClick={actions.closeModal} variant="subtle" color="gray">
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.create")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle(t) {
|
||||
return t("management.page.user.invite.action.new.title");
|
||||
},
|
||||
});
|
||||
1
packages/modals-collection/src/search-engines/index.ts
Normal file
1
packages/modals-collection/src/search-engines/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { RequestMediaModal } from "./request-media-modal";
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useMemo } from "react";
|
||||
import { Button, Group, Image, LoadingOverlay, Stack, Text } from "@mantine/core";
|
||||
import type { MRT_ColumnDef } from "mantine-react-table";
|
||||
import { MRT_Table } from "mantine-react-table";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||
|
||||
interface RequestMediaModalProps {
|
||||
integrationId: string;
|
||||
mediaId: number;
|
||||
mediaType: "movie" | "tv";
|
||||
}
|
||||
|
||||
export const RequestMediaModal = createModal<RequestMediaModalProps>(({ actions, innerProps }) => {
|
||||
const { data, isPending: isPendingQuery } = clientApi.searchEngine.getMediaRequestOptions.useQuery({
|
||||
integrationId: innerProps.integrationId,
|
||||
mediaId: innerProps.mediaId,
|
||||
mediaType: innerProps.mediaType,
|
||||
});
|
||||
|
||||
const { mutate, isPending: isPendingMutation } = clientApi.searchEngine.requestMedia.useMutation({
|
||||
onSuccess() {
|
||||
actions.closeModal();
|
||||
showSuccessNotification({
|
||||
message: t("common.notification.create.success"),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const isPending = isPendingQuery || isPendingMutation;
|
||||
const t = useI18n();
|
||||
|
||||
const columns = useMemo<MRT_ColumnDef<Season>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: t("search.engine.media.request.modal.table.header.season"),
|
||||
},
|
||||
{
|
||||
accessorKey: "episodeCount",
|
||||
header: t("search.engine.media.request.modal.table.header.episodes"),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
columns,
|
||||
data: data && "seasons" in data ? data.seasons : [],
|
||||
enableColumnActions: false,
|
||||
enableColumnFilters: false,
|
||||
enablePagination: false,
|
||||
enableSorting: false,
|
||||
enableSelectAll: true,
|
||||
enableRowSelection: true,
|
||||
mantineTableProps: {
|
||||
highlightOnHover: false,
|
||||
striped: "odd",
|
||||
withColumnBorders: true,
|
||||
withRowBorders: true,
|
||||
withTableBorder: true,
|
||||
},
|
||||
initialState: {
|
||||
density: "xs",
|
||||
},
|
||||
});
|
||||
|
||||
const anySelected = Object.keys(table.getState().rowSelection).length > 0;
|
||||
|
||||
const handleMutate = () => {
|
||||
const selectedSeasons = table.getSelectedRowModel().rows.flatMap((row) => row.original.seasonNumber);
|
||||
mutate({
|
||||
integrationId: innerProps.integrationId,
|
||||
mediaId: innerProps.mediaId,
|
||||
mediaType: innerProps.mediaType,
|
||||
seasons: selectedSeasons,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<LoadingOverlay visible={isPending} zIndex={1000} overlayProps={{ radius: "sm", blur: 2 }} />
|
||||
{data && (
|
||||
<Group wrap="nowrap" align="start">
|
||||
<Image
|
||||
src={`https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`}
|
||||
alt="poster"
|
||||
w={100}
|
||||
radius="md"
|
||||
/>
|
||||
<Text c="dimmed" style={{ flex: "1" }}>
|
||||
{data.overview}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{innerProps.mediaType === "tv" && <MRT_Table table={table} />}
|
||||
<Group justify="end">
|
||||
<Button onClick={actions.closeModal} variant="light">
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleMutate} disabled={!anySelected && innerProps.mediaType === "tv"}>
|
||||
{t("search.engine.media.request.modal.button.send")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}).withOptions({
|
||||
size: "xl",
|
||||
});
|
||||
|
||||
interface Season {
|
||||
id: number;
|
||||
seasonNumber: number;
|
||||
name: string;
|
||||
episodeCount: number;
|
||||
}
|
||||
8
packages/modals-collection/tsconfig.json
Normal file
8
packages/modals-collection/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"]
|
||||
}
|
||||
Reference in New Issue
Block a user