Files
homarr/packages/modals-collection/src/apps/app-select-modal.tsx
T

209 lines
6.4 KiB
TypeScript

import { useMemo, useState } from "react";
import {
Badge,
Button,
Center,
Group,
Image,
Input,
Paper,
ScrollArea,
SimpleGrid,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import { IconPlus, IconSearch } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { SelectableCard } from "@homarr/ui";
import { QuickAddAppModal } from "./quick-add-app/quick-add-app-modal";
type SelectableApp = RouterOutputs["app"]["selectable"][number];
interface AppSelectModalProps {
onSelect?: (app: SelectableApp) => void;
onSelectMany?: (apps: SelectableApp[]) => void;
withCreate: boolean;
}
const selectGridCols = { base: 1, xs: 2, sm: 2, md: 3, lg: 4 };
export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, innerProps }) => {
const [search, setSearch] = useState("");
const [selectedAppIds, setSelectedAppIds] = useState<Set<string>>(new Set());
const t = useI18n();
const { data: apps = [], isPending } = clientApi.app.selectable.useQuery();
const { openModal: openQuickAddAppModal } = useModalAction(QuickAddAppModal);
const multiSelect = Boolean(innerProps.onSelectMany);
const filteredApps = useMemo(
() =>
apps
.filter((app) => app.name.toLowerCase().includes(search.toLowerCase()))
.sort((appA, appB) => appA.name.localeCompare(appB.name)),
[apps, search],
);
const selectedApps = useMemo(() => apps.filter((app) => selectedAppIds.has(app.id)), [apps, selectedAppIds]);
const handleSelect = (app: SelectableApp) => {
if (multiSelect) {
setSelectedAppIds((current) => {
const next = new Set(current);
if (next.has(app.id)) next.delete(app.id);
else next.add(app.id);
return next;
});
return;
}
innerProps.onSelect?.(app);
actions.closeModal();
};
const handleAddNewApp = () => {
openQuickAddAppModal({
onClose(app) {
if (multiSelect) {
setSelectedAppIds((current) => new Set(current).add(app.id));
return;
}
innerProps.onSelect?.(app);
actions.closeModal();
},
});
};
const handleMultiSubmit = () => {
innerProps.onSelectMany?.(selectedApps);
actions.closeModal();
};
return (
<Stack gap="md">
{/* Top Search Input */}
<Input
value={search}
onChange={(event) => setSearch(event.currentTarget.value)}
leftSection={<IconSearch size={16} />}
placeholder={`${t("app.action.select.search")}...`}
aria-label={t("app.action.select.search")}
data-autofocus
onKeyDown={(event) => {
if (event.key === "Enter" && filteredApps.length === 1 && filteredApps[0]) {
handleSelect(filteredApps[0]);
}
}}
/>
{/* Scrollable Container with App Cards */}
<ScrollArea.Autosize mah="70vh" offsetScrollbars>
<Stack gap="md" pt="xs" pr="xs" px={4}>
<SimpleGrid cols={selectGridCols} spacing="sm">
{innerProps.withCreate && (
<SelectableCard
onClick={handleAddNewApp}
style={{ borderStyle: "dashed" }}
icon={
<ThemeIcon variant="light" color="primaryColor" size={34} radius="md">
<IconPlus size={20} />
</ThemeIcon>
}
title={t("app.action.create.title")}
description={t("app.action.create.description")}
footerLeft={
<Text size="xs" c="dimmed">
Custom Application
</Text>
}
/>
)}
{filteredApps.map((app) => (
<AppCard
key={app.id}
app={app}
isSelected={selectedAppIds.has(app.id)}
multiSelect={multiSelect}
onSelect={handleSelect}
/>
))}
</SimpleGrid>
{filteredApps.length === 0 && !isPending && (
<Center p="xl">
<Text c="dimmed">{t("app.action.select.noResults")}</Text>
</Center>
)}
</Stack>
</ScrollArea.Autosize>
{/* Multi-Select Action Footer */}
{multiSelect && selectedApps.length > 0 && (
<Paper withBorder p="xs" radius="md" bg="light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8))">
<Group justify="space-between" align="center">
<Text size="sm" fw={600}>
{selectedApps.length} app{selectedApps.length > 1 ? "s" : ""} selected
</Text>
<Group gap="xs">
<Button variant="default" size="xs" onClick={() => setSelectedAppIds(new Set())}>
{t("common.action.discard")}
</Button>
<Button color="primaryColor" size="xs" onClick={handleMultiSubmit}>
{t("common.action.add")} ({selectedApps.length})
</Button>
</Group>
</Group>
</Paper>
)}
</Stack>
);
}).withOptions({
defaultTitle: (t) => t("app.action.select.title"),
size: modalSizeSelect,
});
// =========================================================================
// AppCard: Variant 3 (Dashboard Inset) with Medium Title & Large App Icon
// =========================================================================
const AppCard = ({
app,
isSelected,
multiSelect,
onSelect,
}: {
app: SelectableApp;
isSelected: boolean;
multiSelect: boolean;
onSelect: (app: SelectableApp) => void;
}) => {
const t = useI18n();
return (
<SelectableCard
onClick={() => onSelect(app)}
aria-label={app.name}
selected={isSelected}
icon={<Image src={app.iconUrl} alt={app.name} w={28} h={28} fit="contain" style={{ flexShrink: 0 }} />}
title={app.name}
topRight={
isSelected ? (
<Badge variant="dot" color="primaryColor" size="xs">
{t("app.action.select.selected")}
</Badge>
) : null
}
description={app.description}
footerLeft={
<Text size="xs" c="dimmed">
{multiSelect ? (isSelected ? t("app.action.select.selected") : t("app.action.select.toggle")) : "Application"}
</Text>
}
/>
);
};