feat: improve mobile board experience

This commit is contained in:
ajnart committed 2026-08-13 19:55:07 +02:00
1 parent 7d5ec15017
commit 2e3dca3e87
28 files changed
+979 -258

No files matched your search

+8 -37
View File
@@ -10,13 +10,13 @@ import dayjs from "dayjs";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import type { CalendarEvent } from "@homarr/integrations/types";
import { useSettings } from "@homarr/settings";
import { useCurrentIntlLocale, useI18n, useScopedI18n } from "@homarr/translation/client";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import actionTargetClasses from "../common/action-target.module.css";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import { useWidgetRuntimeQueries } from "../runtime-hooks";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { CalendarDay } from "./calender-day";
@@ -27,7 +27,6 @@ import {
splitEvents,
toCalendarDateKey,
} from "./calendar-events";
import type { CalendarEventWithSource } from "./calendar-event-list";
import { CalendarEventList } from "./calendar-event-list";
import classes from "./component.module.css";
@@ -54,7 +53,6 @@ export default function CalendarWidget(props: WidgetComponentProps<"calendar">)
events={[]}
failedIntegrations={[]}
isPending={false}
queryError={undefined}
month={month}
setMonth={setMonth}
/>
@@ -81,20 +79,7 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, d
const data = getUsableWidgetQueryData(calendarQuery);
const { isPending } = calendarQuery;
const events = useMemo(
() =>
data?.flatMap(({ events: integrationEvents, integration, error }) =>
error
? []
: integrationEvents.map(
(event): CalendarEventWithSource => ({
...event,
source: { integrationId: integration.id, integrationName: integration.name },
}),
),
) ?? [],
[data],
);
const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]);
const failedIntegrations =
data?.flatMap(({ integration, error }) =>
error ? [{ integrationId: integration.id, integrationName: integration.name, error }] : [],
@@ -106,7 +91,6 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, d
events={events}
failedIntegrations={failedIntegrations}
isPending={isPending}
queryError={calendarQuery.error}
month={month}
setMonth={setMonth}
options={options}
@@ -117,10 +101,9 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, d
interface CalendarBaseProps {
isEditMode: boolean;
events: CalendarEventWithSource[];
events: CalendarEvent[];
failedIntegrations: { integrationId: string; integrationName: string; error: string }[];
isPending: boolean;
queryError: unknown;
month: Date;
setMonth: (date: Date) => void;
options: WidgetComponentProps<"calendar">["options"];
@@ -132,13 +115,11 @@ const CalendarBase = ({
events,
failedIntegrations,
isPending,
queryError,
month,
setMonth,
options,
displayMode,
}: CalendarBaseProps) => {
const t = useI18n();
const locale = useCurrentIntlLocale();
const { firstDayOfWeek } = useSettings();
const board = useRequiredBoard();
@@ -170,8 +151,6 @@ const CalendarBase = ({
events={agendaEvents}
failedIntegrations={failedIntegrations}
isPending={isPending}
queryError={queryError}
queryErrorLabel={t("widget.calendar.name")}
isEditMode={isEditMode}
locale={locale}
month={month}
@@ -182,10 +161,9 @@ const CalendarBase = ({
return (
<Stack ref={ref} h="100%" w="100%" gap="xs" style={{ overflow: "hidden" }}>
{(failedIntegrations.length > 0 || Boolean(queryError)) && (
{failedIntegrations.length > 0 && (
<Group px="xs" justify="flex-end">
<IntegrationErrorIndicator results={failedIntegrations} />
<WidgetQueryErrorIndicator error={queryError} label={t("widget.calendar.name")} />
</Group>
)}
<Calendar
@@ -265,11 +243,9 @@ const CalendarBase = ({
};
interface CalendarAgendaProps {
events: CalendarEventWithSource[];
events: CalendarEvent[];
failedIntegrations: CalendarBaseProps["failedIntegrations"];
isPending: boolean;
queryError: unknown;
queryErrorLabel: string;
isEditMode: boolean;
locale: string;
month: Date;
@@ -280,8 +256,6 @@ const CalendarAgenda = ({
events,
failedIntegrations,
isPending,
queryError,
queryErrorLabel,
isEditMode,
locale,
month,
@@ -327,10 +301,7 @@ const CalendarAgenda = ({
</ActionIcon>
</Tooltip>
</Group>
<Group gap={0}>
<IntegrationErrorIndicator results={failedIntegrations} />
<WidgetQueryErrorIndicator error={queryError} label={queryErrorLabel} />
</Group>
{failedIntegrations.length > 0 && <IntegrationErrorIndicator results={failedIntegrations} />}
</Group>
<Box style={{ flex: 1, minHeight: 0 }}>
@@ -339,7 +310,7 @@ const CalendarAgenda = ({
<Loader size="sm" />
</Center>
) : events.length > 0 ? (
<CalendarEventList events={events} advanced groupByDate locale={locale} fillHeight />
<CalendarEventList events={events} groupByDate locale={locale} fillHeight />
) : (
<Stack h="100%" align="center" justify="center" p="md">
<Text c="dimmed" size="sm" ta="center">
+1
View File
@@ -6,6 +6,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("clock", {
icon: IconClock,
supportsAdvancedFocus: true,
queryKey: [["widget", "weather", "atLocation"]],
queryMatcher: ({ input }, scope) => {
const location = scope.options.weatherLocation;
@@ -0,0 +1,29 @@
import { Center, Group, Stack, Text } from "@mantine/core";
interface WidgetMobileSummaryProps {
value: number | string;
label: string;
description?: string;
}
export function WidgetMobileSummary({ value, label, description }: WidgetMobileSummaryProps) {
return (
<Center h="100%" p="sm">
<Stack align="center" gap={2} w="100%" style={{ minWidth: 0 }}>
<Group gap="xs" justify="center" wrap="nowrap" w="100%">
<Text fz="xl" fw={700} lh={1}>
{value}
</Text>
<Text size="sm" fw={600} lineClamp={1}>
{label}
</Text>
</Group>
{description !== undefined && (
<Text c="dimmed" size="xs" lineClamp={1} ta="center" w="100%">
{description}
</Text>
)}
</Stack>
</Center>
);
}
+35 -43
View File
@@ -53,18 +53,11 @@ import { showErrorNotification } from "@homarr/notifications";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import { HomarrDataTable } from "../common/homarr-data-table";
import { formatLocalizedDateTime } from "../common/locale";
import { usePersistedTableLayout, useTableLayoutPersistence } from "../common/use-persisted-table-layout";
import {
DOWNLOAD_COLUMN_ACCESSORS,
filterDownloadItemsByStatus,
getAvailableDownloadStates,
getDownloadColumnAccessors,
} from "./helpers";
import { filterDownloadItemsByStatus, getAvailableDownloadStates } from "./helpers";
dayjs.extend(relativeTime);
@@ -91,7 +84,6 @@ interface ColumnContext {
hasTorrents: boolean;
hasMultipleClients: boolean;
hasMultipleTypes: boolean;
isAdvanced: boolean;
size: SizeConfig;
}
@@ -151,8 +143,8 @@ const columnVisibilityChecks: Partial<Record<string, (ctx: ColumnContext) => boo
upSpeed: (ctx) => ctx.hasTorrents && ctx.size.showSpeedColumns,
ratio: (ctx) => ctx.hasTorrents,
sent: (ctx) => ctx.hasTorrents,
integration: (ctx) => ctx.isAdvanced || ctx.hasMultipleClients,
type: (ctx) => ctx.isAdvanced || ctx.hasMultipleTypes,
integration: (ctx) => ctx.hasMultipleClients,
type: (ctx) => ctx.hasMultipleTypes,
downSpeed: (ctx) => ctx.size.showSpeedColumns,
time: (ctx) => ctx.size.showTimeColumn,
state: (ctx) => ctx.size.showStateColumn,
@@ -179,6 +171,24 @@ function progressColor(state: DownloadState, progress: number): string {
return "blue";
}
const columnAccessors = [
"name",
"progress",
"size",
"downSpeed",
"upSpeed",
"time",
"state",
"added",
"ratio",
"received",
"sent",
"category",
"integration",
"index",
"type",
] as const;
function toCategoryArray(category: string | string[]): string[] {
if (Array.isArray(category)) return category;
return [category];
@@ -310,17 +320,10 @@ export default function DownloadClientsWidget({
integrationIds,
limitPerIntegration: options.limitPerIntegration,
});
const currentItems = getUsableWidgetQueryData(downloadsQuery);
const availableItems = useMemo(() => currentItems?.filter((item) => item.data !== null) ?? [], [currentItems]);
const currentItems = getUsableWidgetQueryData(downloadsQuery) ?? [];
const { isFetching } = downloadsQuery;
const t = useScopedI18n("widget.downloads");
const queryIndicators = (
<Group gap={0}>
<IntegrationErrorIndicator results={currentItems ?? []} />
<WidgetQueryErrorIndicator error={downloadsQuery.error} label={t("name")} />
</Group>
);
const [clientFilter, setClientFilter] = useState<string[]>([]);
const [statusFilter, setStatusFilter] = useState<string[]>([]);
@@ -367,7 +370,7 @@ export default function DownloadClientsWidget({
}, [options.defaultSort, defaultSortDirection]);
const eligibleData = useMemo<ExtendedDownloadClientItem[]>(() => {
return availableItems
return currentItems
.filter(({ integration }) => integrationIds.includes(integration.id))
.flatMap((pair) =>
pair.data.items
@@ -396,7 +399,7 @@ export default function DownloadClientsWidget({
}),
);
}, [
availableItems,
currentItems,
integrationIds,
integrationInteractSet,
mutateDeleteItem,
@@ -419,7 +422,7 @@ export default function DownloadClientsWidget({
}, [data, sortStatus]);
const clients = useMemo<ExtendedClientStatus[]>(() => {
return availableItems
return currentItems
.filter(({ integration }) => integrationIds.includes(integration.id))
.map(({ integration, data: clientData }): ExtendedClientStatus => {
const interact = integrationInteractSet.has(integration.id);
@@ -456,7 +459,7 @@ export default function DownloadClientsWidget({
};
});
}, [
availableItems,
currentItems,
integrationIds,
integrationInteractSet,
options.applyFilterToRatio,
@@ -469,13 +472,10 @@ export default function DownloadClientsWidget({
const hasMultipleTypes = new Set(data.map(({ type }) => type)).size > 1;
const size = useMemo(() => getSizeConfig(width, height, isAdvanced), [height, isAdvanced, width]);
const optionsColumnSet = useMemo(
() => new Set<string>(getDownloadColumnAccessors(options.columns, isAdvanced)),
[isAdvanced, options.columns],
);
const optionsColumnSet = useMemo(() => new Set<string>(options.columns), [options.columns]);
const columnContext = useMemo<ColumnContext>(
() => ({ optionsColumnSet, hasTorrents, hasMultipleClients, hasMultipleTypes, isAdvanced, size }),
[optionsColumnSet, hasTorrents, hasMultipleClients, hasMultipleTypes, isAdvanced, size],
() => ({ optionsColumnSet, hasTorrents, hasMultipleClients, hasMultipleTypes, size }),
[optionsColumnSet, hasTorrents, hasMultipleClients, hasMultipleTypes, size],
);
let progressColumnWidth = 120;
@@ -688,7 +688,7 @@ export default function DownloadClientsWidget({
const { effectiveColumns, storeKey } = usePersistedTableLayout({
columns,
columnAccessors: DOWNLOAD_COLUMN_ACCESSORS,
columnAccessors,
columnOrder: options.columnOrder,
columnWidths: options.columnWidths,
itemId,
@@ -733,16 +733,11 @@ export default function DownloadClientsWidget({
return { stateCounts, totalSize, completedSize, totalItems: data.length };
}, [data]);
if (!isAdvanced && options.columns.length === 0) {
if (options.columns.length === 0) {
return (
<Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
{queryIndicators}
</Box>
<Center h="100%">
<Text>{t("errors.noColumns")}</Text>
</Center>
</Box>
<Center h="100%">
<Text>{t("errors.noColumns")}</Text>
</Center>
);
}
@@ -764,13 +759,10 @@ export default function DownloadClientsWidget({
)}
<Box style={{ flex: 1, minHeight: 0, position: "relative" }}>
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
{queryIndicators}
</Box>
<HomarrDataTable
isEditMode={isEditMode}
cellPadding={`${size.cellPadding}px 8px`}
fetching={isFetching && (currentItems?.length ?? 0) === 0}
fetching={isFetching && currentItems.length === 0}
fz={size.fontSize}
records={sortedData}
columns={effectiveColumns}
+21 -3
View File
@@ -2,10 +2,28 @@ import { IconDownload } from "@tabler/icons-react";
import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { ExtendedDownloadClientItem } from "@homarr/integrations";
import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
import { optionsBuilder } from "../options";
import { DOWNLOAD_COLUMN_ACCESSORS } from "./helpers";
const columnsList = [
"name",
"progress",
"size",
"downSpeed",
"upSpeed",
"time",
"state",
"added",
"ratio",
"received",
"sent",
"category",
"integration",
"index",
"type",
] as const satisfies (keyof ExtendedDownloadClientItem)[];
const sortColumns = [
"name",
@@ -20,7 +38,7 @@ const sortColumns = [
"sent",
"index",
"type",
] as const satisfies readonly (typeof DOWNLOAD_COLUMN_ACCESSORS)[number][];
] as const satisfies readonly (typeof columnsList)[number][];
export const { definition, componentLoader } = createWidgetDefinition("downloads", {
icon: IconDownload,
@@ -37,7 +55,7 @@ export const { definition, componentLoader } = createWidgetDefinition("downloads
(factory) => ({
columns: factory.multiSelect({
defaultValue: ["name", "progress", "downSpeed", "time", "state"],
options: DOWNLOAD_COLUMN_ACCESSORS.map((value) => ({
options: columnsList.map((value) => ({
value,
label: (t) => t(`widget.downloads.items.${value}.columnTitle`),
})),
+2
View File
@@ -69,6 +69,8 @@ import * as wud from "./wud";
export type {
NormalizedWidgetQuery,
WidgetDefinition,
WidgetDisplayMode,
WidgetMobilePresentation,
WidgetContextMenuAction,
WidgetContextActionProps,
WidgetOptionsSettings,
@@ -28,7 +28,6 @@ import { useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
import { useWidgetRuntimeQueries } from "../runtime-hooks";
import { NoIntegrationDataError } from "../errors/no-data-integration";
@@ -46,19 +45,18 @@ export default function MediaMissingWidget({
}: WidgetComponentProps<"mediaMissing">) {
const t = useScopedI18n("widget.mediaMissing");
const isAdvanced = displayMode === "advanced";
const showMissing = isAdvanced || options.showMissing;
const showQueued = isAdvanced || options.showQueued;
const pageSize = isAdvanced ? Math.max(Number(options.pageSize), 50) : Number(options.pageSize);
const input = { integrationIds, pageSize };
useWidgetRuntimeQueries(widgetRuntimeRef, [getQueryKey(clientApi.widget.mediaOrganizer.getData, input, "query")]);
const mediaQuery = clientApi.widget.mediaOrganizer.getData.useQuery(input, {
staleTime: 60 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
const data = getUsableWidgetQueryData(mediaQuery);
const [selectedTab, setSelectedTab] = useState<MediaMissingTab>(showMissing ? "missing" : "queued");
const activeTab = resolveMediaMissingTab(selectedTab, showMissing, showQueued);
const data = getUsableWidgetQueryData(
clientApi.widget.mediaOrganizer.getData.useQuery(input, {
staleTime: 60 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
}),
);
const [selectedTab, setSelectedTab] = useState<MediaMissingTab>(options.showMissing ? "missing" : "queued");
const activeTab = resolveMediaMissingTab(selectedTab, options.showMissing, options.showQueued);
useEffect(() => {
if (activeTab !== null && activeTab !== selectedTab) setSelectedTab(activeTab);
@@ -66,7 +64,7 @@ export default function MediaMissingWidget({
if (!data) return <WidgetEmptyState />;
if (data.length === 0) throw new NoIntegrationDataError();
if (!showMissing && !showQueued)
if (!options.showMissing && !options.showQueued)
return (
<Center h="100%" p="sm">
<Text c="dimmed" size="sm" ta="center">
@@ -81,7 +79,7 @@ export default function MediaMissingWidget({
const queuedCount = data.reduce((sum, entry) => sum + entry.queuedCount, 0);
const failedIntegrations = data.filter((entry): entry is typeof entry & { error: string } => Boolean(entry.error));
const enabledPanelCount = Number(showMissing) + Number(showQueued);
const enabledPanelCount = Number(options.showMissing) + Number(options.showQueued);
const panelWidth = isAdvanced && enabledPanelCount > 1 ? width / enabledPanelCount : width;
const isThin = !isAdvanced && panelWidth > 0 && panelWidth < 160;
const isShort = !isAdvanced && height > 0 && height < 180;
@@ -104,12 +102,7 @@ export default function MediaMissingWidget({
) : (
<SimpleGrid cols={columns} spacing="xs" verticalSpacing="xs">
{entries.map(({ item, integrationId }) => (
<MediaCard
key={`${integrationId}-${item.type}-${item.id}`}
item={item}
density={density}
showQueueDetails={isAdvanced}
/>
<MediaCard key={`${integrationId}-${item.type}-${item.id}`} item={item} density={density} />
))}
</SimpleGrid>
)}
@@ -128,17 +121,13 @@ export default function MediaMissingWidget({
))}
</Group>
);
const queryFailure = <WidgetQueryErrorIndicator error={mediaQuery.error} label={t("name")} />;
if (isAdvanced) {
return (
<Stack h="100%" gap={0}>
<Group justify="flex-end" px="xs">
{queryFailure}
{partialFailures}
</Group>
{partialFailures}
<SimpleGrid cols={enabledPanelCount} spacing="sm" p="sm" style={{ flex: 1, minHeight: 0 }}>
{showMissing && (
{options.showMissing && (
<Paper withBorder radius="sm" style={{ minHeight: 0, overflow: "hidden" }}>
<Group p="xs" gap="xs">
<IconQuestionMark size={16} />
@@ -149,7 +138,7 @@ export default function MediaMissingWidget({
<Box h="calc(100% - 40px)">{renderPanel(missing, t("empty.missing"))}</Box>
</Paper>
)}
{showQueued && (
{options.showQueued && (
<Paper withBorder radius="sm" style={{ minHeight: 0, overflow: "hidden" }}>
<Group p="xs" gap="xs">
<IconDownload size={16} />
@@ -174,29 +163,26 @@ export default function MediaMissingWidget({
h="100%"
style={{ display: "flex", flexDirection: "column" }}
>
<Group justify="flex-end" px="xs">
{queryFailure}
{partialFailures}
</Group>
{partialFailures}
<Tabs.List grow>
{showMissing && (
{options.showMissing && (
<Tabs.Tab value="missing" px={isThin ? 6 : undefined} leftSection={<IconQuestionMark size={14} />}>
{tabLabel(t("tab.missing"), missing.length, missingCount)}
</Tabs.Tab>
)}
{showQueued && (
{options.showQueued && (
<Tabs.Tab value="queued" px={isThin ? 6 : undefined} leftSection={<IconDownload size={14} />}>
{tabLabel(t("tab.queued"), queued.length, queuedCount)}
</Tabs.Tab>
)}
</Tabs.List>
{showMissing && (
{options.showMissing && (
<Tabs.Panel value="missing" flex={1} style={{ overflow: "hidden" }}>
{renderPanel(missing, t("empty.missing"))}
</Tabs.Panel>
)}
{showQueued && (
{options.showQueued && (
<Tabs.Panel value="queued" flex={1} style={{ overflow: "hidden" }}>
{renderPanel(queued, t("empty.queued"))}
</Tabs.Panel>
@@ -320,15 +306,7 @@ const CardShell = ({
);
};
const MediaCard = ({
item,
density,
showQueueDetails,
}: {
item: MissingMediaItem | QueuedMediaItem;
density: Density;
showQueueDetails: boolean;
}) => {
const MediaCard = ({ item, density }: { item: MissingMediaItem | QueuedMediaItem; density: Density }) => {
const isQueued = "percentComplete" in item;
return (
@@ -345,11 +323,6 @@ const MediaCard = ({
{item.type === "episode" ? item.title : item.year}
</Text>
)}
{isQueued && showQueueDetails && (
<Text fz="10px" c="dimmed" lineClamp={1} lh={1.1}>
{[item.status, item.timeLeft].filter(Boolean).join(" · ")}
</Text>
)}
</Stack>
{isQueued && <ProgressRing percent={Math.min(100, Math.max(0, item.percentComplete))} density={density} />}
</Group>
@@ -31,7 +31,6 @@ import { OverflowBadge } from "@homarr/ui";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
import classes from "./component.module.css";
@@ -45,43 +44,36 @@ export default function MediaReleasesWidget({
}: WidgetComponentProps<"mediaReleases">) {
const t = useI18n();
const releasesQuery = clientApi.widget.mediaRelease.getMediaReleases.useQuery({ integrationIds });
const response = getUsableWidgetQueryData(releasesQuery);
const releases = getUsableWidgetQueryData(releasesQuery);
if (isInitialWidgetQueryPending(releasesQuery)) return <WidgetQueryLoadingState />;
if (!response) return <WidgetEmptyState />;
const { releases, failedIntegrations } = response;
if (!releases || releases.length === 0) return <WidgetEmptyState />;
const isAdvanced = displayMode === "advanced";
return (
<Box h="100%" pos="relative">
{releases.length === 0 ? (
<WidgetEmptyState />
) : (
<ScrollArea h="100%">
<SimpleGrid cols={isAdvanced ? Math.max(1, Math.floor(width / 360)) : 1} p="xs" spacing="sm">
{releases.map((item, index) => (
<Fragment key={`${item.integration.id}:${item.id}`}>
{!isAdvanced && index !== 0 && options.layout === "poster" && <Divider />}
<Item item={item} options={options} isAdvanced={isAdvanced} width={width} height={height} />
</Fragment>
))}
</SimpleGrid>
</ScrollArea>
)}
{(failedIntegrations.length > 0 || releasesQuery.error) && (
<Group pos="absolute" top={4} right={4} gap={0}>
<IntegrationErrorIndicator results={failedIntegrations} />
<ScrollArea h="100%">
<SimpleGrid cols={isAdvanced ? Math.max(1, Math.floor(width / 360)) : 1} p="xs" spacing="sm">
{releases.map((item, index) => (
<Fragment key={`${item.integration.id}:${item.id}`}>
{!isAdvanced && index !== 0 && options.layout === "poster" && <Divider />}
<Item item={item} options={options} isAdvanced={isAdvanced} width={width} height={height} />
</Fragment>
))}
</SimpleGrid>
</ScrollArea>
{releasesQuery.error && (
<Box pos="absolute" top={4} right={4}>
<WidgetQueryErrorIndicator error={releasesQuery.error} label={t("widget.mediaReleases.name")} />
</Group>
</Box>
)}
</Box>
);
}
interface ItemProps {
item: RouterOutputs["widget"]["mediaRelease"]["getMediaReleases"]["releases"][number];
item: RouterOutputs["widget"]["mediaRelease"]["getMediaReleases"][number];
options: WidgetComponentProps<"mediaReleases">["options"];
isAdvanced: boolean;
width: number;
@@ -219,7 +211,7 @@ const Item = ({ item, options, isAdvanced, width, height }: ItemProps) => {
/>
)}
{isAdvanced && item.description && (
<Text size="xs" c="dimmed">
<Text size="xs" c="dimmed" lineClamp={3}>
{item.description}
</Text>
)}
@@ -238,17 +230,9 @@ const Item = ({ item, options, isAdvanced, width, height }: ItemProps) => {
</Badge>
)}
{(isAdvanced || options.showSource) &&
(isAdvanced ? (
<Group gap={4} wrap="nowrap">
<Avatar size="sm" radius="xl" src={getIconUrl(item.integration.kind)} alt="" />
<Text size="xs" fw={500} lineClamp={1}>
{item.integration.name}
</Text>
</Group>
) : (
<Avatar size="sm" radius="xl" src={getIconUrl(item.integration.kind)} alt={item.integration.name} />
))}
{(isAdvanced || options.showSource) && (
<Avatar size="sm" radius="xl" src={getIconUrl(item.integration.kind)} alt={item.integration.name} />
)}
</Stack>
)}
</Group>
@@ -4,7 +4,6 @@ import { useEffect, useState } from "react";
import type { ReactNode } from "react";
import { getQueryKey } from "@trpc/react-query";
import {
Box,
Center,
Divider,
Group,
@@ -27,7 +26,6 @@ import { views } from ".";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import { useWidgetRuntimeQueries } from "../runtime-hooks";
import { HealthCheckStatus } from "./health-check-status";
import { QueuePanel } from "./panels/queue.panel";
@@ -68,8 +66,7 @@ export default function MediaTranscodingWidget({
useWidgetRuntimeQueries(widgetRuntimeRef, [
getQueryKey(clientApi.widget.mediaTranscoding.getDataAsync, input, "query"),
]);
const transcodingQuery = clientApi.widget.mediaTranscoding.getDataAsync.useQuery(input);
const transcodingData = getUsableWidgetQueryData(transcodingQuery);
const transcodingData = getUsableWidgetQueryData(clientApi.widget.mediaTranscoding.getDataAsync.useQuery(input));
const [view, setView] = useState<View>(options.defaultView);
const t = useI18n("widget.mediaTranscoding");
@@ -97,16 +94,10 @@ export default function MediaTranscodingWidget({
const queuePageCount = totalQueuePages ?? 1;
const isTiny = !isAdvanced && (width < 280 || height < 140);
const footerLayout = getTranscodingFooterLayout(width, height);
const queryIndicator = (
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
<WidgetQueryErrorIndicator error={transcodingQuery.error} label={t("name")} />
</Box>
);
if (isAdvanced) {
return (
<Stack gap="xs" h="100%" p="xs" pos="relative">
{queryIndicator}
<Stack gap="xs" h="100%" p="xs">
<ScrollArea h="100%" style={{ flex: 1 }}>
<SimpleGrid cols={width >= 1100 ? 3 : width >= 700 ? 2 : 1} spacing="sm">
<AdvancedPanel title={t("tab.workers")} icon={IconCpu2}>
@@ -136,8 +127,7 @@ export default function MediaTranscodingWidget({
}
return (
<Stack gap={4} h="100%" pos="relative">
{queryIndicator}
<Stack gap={4} h="100%">
{view === "workers" ? (
<WorkersPanel workers={transcodingData.data.workers} isTiny={isTiny} />
) : view === "queue" ? (
@@ -9,25 +9,21 @@ import { useRequiredBoard } from "@homarr/boards/context";
import { useTimeAgo } from "@homarr/common";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
import { getNotificationDisplay } from "./display";
export default function NotificationsWidget({
options,
integrationIds,
width,
height,
displayMode,
}: WidgetComponentProps<"notifications">) {
const notificationsQuery = clientApi.widget.notifications.getNotifications.useQuery({
...options,
integrationIds,
});
const notificationData = getUsableWidgetQueryData(notificationsQuery);
const notificationIntegrations = useMemo(() => notificationData ?? [], [notificationData]);
const notificationIntegrations = getUsableWidgetQueryData(notificationsQuery) ?? [];
const { isPending } = notificationsQuery;
const t = useI18n();
@@ -53,23 +49,12 @@ export default function NotificationsWidget({
const isDense = width < 280 || height < 180;
const isRoomy = width >= 360 && height >= 220;
const bodyLineClamp = height < 112 ? 1 : isDense ? 2 : height >= 300 ? 8 : 4;
const notificationDisplay = getNotificationDisplay({
displayMode,
hideLogos: options.hideLogos,
isRoomy,
bodyLineClamp,
});
const columns = width >= 720 ? 2 : 1;
const spacing = isRoomy ? "sm" : "xs";
return (
<ScrollArea className="scroll-area-w100" w="100%" h="100%" p="xs">
<Stack w="100%" gap="xs">
{notificationsQuery.error && (
<Group justify="flex-end">
<WidgetQueryErrorIndicator error={notificationsQuery.error} label={t("widget.notifications.name")} />
</Group>
)}
{failedIntegrations.length > 0 && (
<Group gap={4} wrap="wrap">
{failedIntegrations.map((integration) => (
@@ -110,7 +95,7 @@ export default function NotificationsWidget({
}}
>
<Flex gap={isDense ? "xs" : "sm"} align="flex-start" w="100%">
{notificationDisplay.showLogos && notification.source?.iconUrl && (
{!options.hideLogos && notification.source?.iconUrl && (
<Avatar
src={notification.source.iconUrl}
alt={notification.source.name}
@@ -128,7 +113,7 @@ export default function NotificationsWidget({
<Text
c="dimmed"
size={isDense ? "xs" : "sm"}
lineClamp={notificationDisplay.bodyLineClamp}
lineClamp={bodyLineClamp}
style={{ whiteSpace: "pre-line" }}
>
{notification.body}
@@ -136,11 +121,7 @@ export default function NotificationsWidget({
<InfoDisplay
date={notification.time}
source={
notificationDisplay.showSource
? (notification.source?.name ?? notification.integrationName)
: undefined
}
source={isRoomy ? (notification.source?.name ?? notification.integrationName) : undefined}
dense={isDense}
/>
</Flex>
+1 -1
View File
@@ -6,7 +6,7 @@ import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
import { optionsBuilder } from "../options";
export const { componentLoader, definition } = createWidgetDefinition("notifications", {
supportsAdvancedFocus: true,
supportsAdvancedFocus: false,
icon: IconMessage,
queryKey: [["widget", "notifications", "getNotifications"]],
queryMatcher: ({ input }, scope) =>